Skip to main content

propel_core/
config.rs

1use std::collections::HashMap;
2
3use serde::{Deserialize, Serialize};
4
5/// Top-level `propel.toml` configuration.
6///
7/// All sections are optional — sensible defaults are provided.
8///
9/// # Example
10///
11/// ```toml
12/// [project]
13/// gcp_project_id = "my-project"
14///
15/// [build]
16/// include = ["migrations/", "templates/"]
17///
18/// [build.env]
19/// TEMPLATE_DIR = "/app/templates"
20///
21/// [cloud_run]
22/// memory = "1Gi"
23/// ```
24#[derive(Debug, Clone, Default, Serialize, Deserialize)]
25pub struct PropelConfig {
26    #[serde(default)]
27    pub project: ProjectConfig,
28    #[serde(default)]
29    pub build: BuildConfig,
30    #[serde(default)]
31    pub cloud_run: CloudRunConfig,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct ProjectConfig {
36    /// Project name (defaults to Cargo.toml package name)
37    pub name: Option<String>,
38    /// GCP region (defaults to us-central1)
39    #[serde(default = "default_region")]
40    pub region: String,
41    /// GCP project ID
42    pub gcp_project_id: Option<String>,
43}
44
45/// Build configuration under `[build]`.
46///
47/// Controls Docker image generation and runtime content.
48///
49/// # Bundle strategy
50///
51/// By default, `propel deploy` bundles **all files in the git repository**
52/// (respecting `.gitignore`) into the Docker build context. This mirrors
53/// the `git clone` + `docker build` mental model used by GitHub Actions
54/// and similar CI/CD systems.
55///
56/// The bundle is created via `git ls-files`, so:
57/// - Tracked and untracked (non-ignored) files are included
58/// - `.gitignore`d files (e.g. `target/`) are excluded
59/// - `.propel-bundle/`, `.propel/`, `.git/` are always excluded
60///
61/// # Runtime content control
62///
63/// The `include` field controls what goes into the **final runtime image**:
64///
65/// - **`include` omitted (default)**: the entire bundle is copied into the
66///   runtime container via `COPY . .`. Zero config — migrations, templates,
67///   static assets all work automatically.
68///
69/// - **`include` specified**: only the listed paths (plus the compiled binary)
70///   are copied into the runtime image. This acts as a lightweight alternative
71///   to `propel eject` for users who want smaller images.
72///
73/// # Escalation path
74///
75/// ```text
76/// Zero config  →  include/env  →  propel eject
77/// (all-in)        (selective)      (full Dockerfile control)
78/// ```
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct BuildConfig {
81    /// Rust builder image (default: `rust:1.93-bookworm`).
82    ///
83    /// Must be ≥ 1.85 for `edition = "2024"` support.
84    #[serde(default = "default_builder_image")]
85    pub base_image: String,
86    /// Runtime base image (default: `gcr.io/distroless/cc-debian12`).
87    #[serde(default = "default_runtime_image")]
88    pub runtime_image: String,
89    /// Additional system packages to install via `apt-get` during build.
90    #[serde(default)]
91    pub extra_packages: Vec<String>,
92    /// Cargo Chef version for dependency caching.
93    #[serde(default = "default_cargo_chef_version")]
94    pub cargo_chef_version: String,
95    /// Paths to copy into the runtime image.
96    ///
97    /// When `None`, the entire bundle is copied (`COPY . .`).
98    /// When `Some`, only these paths are copied — overriding the all-in default.
99    ///
100    /// ```toml
101    /// [build]
102    /// include = ["migrations/", "templates/"]
103    /// ```
104    #[serde(default)]
105    pub include: Option<Vec<String>>,
106    /// Static environment variables baked into the container image.
107    ///
108    /// These become `ENV` directives in the generated Dockerfile.
109    /// For runtime-configurable values (API keys, secrets), use
110    /// Cloud Run environment variables or Secret Manager instead.
111    ///
112    /// ```toml
113    /// [build.env]
114    /// TEMPLATE_DIR = "/app/templates"
115    /// ```
116    #[serde(default)]
117    pub env: HashMap<String, String>,
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct CloudRunConfig {
122    /// Memory allocation
123    #[serde(default = "default_memory")]
124    pub memory: String,
125    /// CPU count
126    #[serde(default = "default_cpu")]
127    pub cpu: u32,
128    /// Minimum instances
129    #[serde(default)]
130    pub min_instances: u32,
131    /// Maximum instances
132    #[serde(default = "default_max_instances")]
133    pub max_instances: u32,
134    /// Max concurrent requests per instance
135    #[serde(default = "default_concurrency")]
136    pub concurrency: u32,
137    /// Port the application listens on
138    #[serde(default = "default_port")]
139    pub port: u16,
140}
141
142impl Default for ProjectConfig {
143    fn default() -> Self {
144        Self {
145            name: None,
146            region: default_region(),
147            gcp_project_id: None,
148        }
149    }
150}
151
152impl Default for BuildConfig {
153    fn default() -> Self {
154        Self {
155            base_image: default_builder_image(),
156            runtime_image: default_runtime_image(),
157            extra_packages: Vec::new(),
158            cargo_chef_version: default_cargo_chef_version(),
159            include: None,
160            env: HashMap::new(),
161        }
162    }
163}
164
165impl Default for CloudRunConfig {
166    fn default() -> Self {
167        Self {
168            memory: default_memory(),
169            cpu: default_cpu(),
170            min_instances: 0,
171            max_instances: default_max_instances(),
172            concurrency: default_concurrency(),
173            port: default_port(),
174        }
175    }
176}
177
178impl PropelConfig {
179    /// Load from propel.toml at the given path, or return defaults if not found.
180    pub fn load(project_dir: &std::path::Path) -> crate::Result<Self> {
181        let config_path = project_dir.join("propel.toml");
182        if config_path.exists() {
183            tracing::debug!(path = %config_path.display(), "loading propel.toml");
184            let content =
185                std::fs::read_to_string(&config_path).map_err(|e| crate::Error::ConfigLoad {
186                    path: config_path.clone(),
187                    source: e,
188                })?;
189            let config: Self = toml::from_str(&content).map_err(|e| crate::Error::ConfigParse {
190                path: config_path,
191                source: e,
192            })?;
193            config.build.validate_include_paths()?;
194            tracing::debug!(
195                region = %config.project.region,
196                port = config.cloud_run.port,
197                "config loaded"
198            );
199            Ok(config)
200        } else {
201            tracing::debug!(path = %config_path.display(), "propel.toml not found, using defaults");
202            Ok(Self::default())
203        }
204    }
205}
206
207impl BuildConfig {
208    /// Validate `include` paths, rejecting empty or whitespace-only entries.
209    fn validate_include_paths(&self) -> crate::Result<()> {
210        let paths = match &self.include {
211            Some(p) => p,
212            None => return Ok(()),
213        };
214        for path in paths {
215            let trimmed = path.trim();
216            if trimmed.is_empty() {
217                return Err(crate::Error::InvalidIncludePath {
218                    path: path.clone(),
219                    reason: "path must not be empty or whitespace-only",
220                });
221            }
222            if trimmed.trim_end_matches('/').is_empty() {
223                return Err(crate::Error::InvalidIncludePath {
224                    path: path.clone(),
225                    reason: "bare \"/\" is not a valid include path",
226                });
227            }
228        }
229        Ok(())
230    }
231}
232
233fn default_region() -> String {
234    "us-central1".to_owned()
235}
236
237fn default_builder_image() -> String {
238    "rust:1.93-bookworm".to_owned()
239}
240
241fn default_runtime_image() -> String {
242    "gcr.io/distroless/cc-debian12".to_owned()
243}
244
245fn default_cargo_chef_version() -> String {
246    "0.1.73".to_owned()
247}
248
249fn default_memory() -> String {
250    "512Mi".to_owned()
251}
252
253fn default_cpu() -> u32 {
254    1
255}
256
257fn default_max_instances() -> u32 {
258    10
259}
260
261fn default_concurrency() -> u32 {
262    80
263}
264
265fn default_port() -> u16 {
266    8080
267}