Skip to content

Commit cf72ec4

Browse files
committed
feat: Adds compatible syntax for docker-compose interpolation
1 parent bcbdcc2 commit cf72ec4

File tree

10 files changed

+94
-24
lines changed

10 files changed

+94
-24
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ clap = { version = "4.5.18", features = ["derive"] }
1111
cron = { version = "0.15.0", features = ["serde"] }
1212
env_logger = "0.11.5"
1313
log = "0.4.22"
14-
minijinja = "2.12.0"
14+
minijinja = { version = "2.12.0", features = ["custom_syntax"] }
1515
rayon = "1.11.0"
1616
sd-notify = "0.4.2"
1717
serde = { version = "1.0.210", features = ["derive"] }

README.md

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,11 @@ In this example, the service defined in the `backup-service` directory will only
172172

173173
### Step 6: Using Variables (Optional)
174174

175-
Dispenser supports using variables in your configuration file via `dispenser.vars`. This file allows you to define values that can be reused inside `dispenser.toml` using `{{ VARIABLE }}` syntax.
175+
Dispenser supports using variables in your configuration file via `dispenser.vars`. This file allows you to define values that can be reused inside `dispenser.toml` using `${VARIABLE}` syntax.
176+
177+
**Note:** While Dispenser uses the `${}` syntax similar to Docker Compose, it does not support all [Docker Compose interpolation features](https://docs.docker.com/compose/how-tos/environment-variables/variable-interpolation/) (such as default values `:-` or error messages `:?`) within `dispenser.toml`.
178+
179+
However, variables defined in `dispenser.vars` are passed as environment variables to the underlying `docker compose` commands. This allows you to use them in your `docker-compose.yaml` files, where full Docker Compose interpolation is supported.
176180

177181
This is useful for reusing the same configuration in multiple deployments.
178182

@@ -194,7 +198,18 @@ This is useful for reusing the same configuration in multiple deployments.
194198
```toml
195199
[[instance]]
196200
path = "my-app"
197-
images = [{ registry = "{{ registry_url }}", name = "my-org/my-app", tag = "{{ app_version }}" }]
201+
images = [{ registry = "${registry_url}", name = "my-org/my-app", tag = "${app_version}" }]
202+
```
203+
204+
4. Use these variables in your `docker-compose.yaml`.
205+
206+
```yaml
207+
services:
208+
my-app:
209+
# You can use the variables defined in dispenser.vars here
210+
image: ${registry_url}/my-org/my-app:${app_version}
211+
ports:
212+
- "8080:80"
198213
```
199214

200215
### Step 7: Validating Configuration
@@ -219,8 +234,8 @@ If there's an error `dispenser` will show you a detailed error message.
219234
2 |
220235
3 | [[instance]]
221236
4 | path = "nginx"
222-
5 > images = [{ registry = "{{ missing }}", name = "nginx", tag = "latest" }]
223-
i ^^^^^^^ undefined value
237+
5 > images = [{ registry = "${missing}", name = "nginx", tag = "latest" }]
238+
i ^^^^^^^^^^ undefined value
224239
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
225240
No referenced variables
226241
-------------------------------------------------------------------------------

example/dispenser.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ delay = 60
44
path = "nginx"
55
# This service will be started immediately on startup, which is the default behavior.
66
# It will be updated if a new 'nginx:latest' image is detected.
7-
images = [{ registry = "{{docker_io}}", name = "nginx", tag = "latest" }]
7+
images = [{ registry = "${docker_io}", name = "nginx", tag = "latest" }]
88

99
[[instance]]
1010
path = "hello-world"

example/nginx/docker-compose.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
version: "3.8"
22
services:
33
nginx:
4-
image: nginx:latest
4+
image: ${docker_io}/nginx:latest
55
ports:
66
- "8080:80"

src/config.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
use rayon::prelude::*;
2+
use serde::Serialize;
23

34
use std::{
5+
collections::HashMap,
46
num::NonZeroU64,
57
path::PathBuf,
68
sync::{Arc, Mutex},
@@ -13,6 +15,27 @@ use crate::{
1315
manifests::DockerWatcher,
1416
};
1517

18+
#[derive(Debug, Default, PartialEq, Eq)]
19+
pub struct DispenserVars {
20+
pub inner: Arc<HashMap<String, String>>,
21+
}
22+
23+
impl Clone for DispenserVars {
24+
fn clone(&self) -> Self {
25+
let inner = Arc::clone(&self.inner);
26+
Self { inner }
27+
}
28+
}
29+
30+
impl Serialize for DispenserVars {
31+
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
32+
where
33+
S: serde::Serializer,
34+
{
35+
self.inner.serialize(serializer)
36+
}
37+
}
38+
1639
pub struct ContposeConfig {
1740
pub delay: NonZeroU64,
1841
pub instances: Vec<ContposeInstanceConfig>,
@@ -52,6 +75,7 @@ pub struct ContposeInstanceConfig {
5275
/// - `Immediately` (default): The service is started as soon as the application starts.
5376
/// - `OnTrigger`: The service is started only when a trigger occurs (e.g., a cron schedule or a detected image update).
5477
pub initialize: Initialize,
78+
pub vars: DispenserVars,
5579
}
5680

5781
#[derive(Clone)]

src/config_file.rs

Lines changed: 40 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@ use minijinja::Environment;
22
use rayon::prelude::*;
33
use serde::{Deserialize, Serialize};
44

5-
use std::{collections::HashMap, num::NonZeroU64, path::PathBuf};
5+
use std::{collections::HashMap, num::NonZeroU64, path::PathBuf, sync::Arc};
66

77
use cron::Schedule;
88

9-
#[derive(Debug, Default)]
9+
#[derive(Debug, Default, Clone)]
1010
pub struct DispenserVars {
1111
inner: HashMap<String, String>,
1212
}
@@ -49,12 +49,19 @@ impl DispenserVars {
4949
}
5050

5151
#[derive(Debug, serde::Deserialize)]
52-
pub struct DispenserConfigFile {
52+
pub struct DispenserConfigFileSerde {
5353
pub delay: NonZeroU64,
5454
#[serde(default)]
5555
pub instance: Vec<DispenserInstanceConfigEntry>,
5656
}
5757

58+
#[derive(Debug)]
59+
pub struct DispenserConfigFile {
60+
pub delay: NonZeroU64,
61+
pub instance: Vec<DispenserInstanceConfigEntry>,
62+
pub vars: DispenserVars,
63+
}
64+
5865
#[derive(Debug, thiserror::Error)]
5966
pub enum DispenserConfigError {
6067
#[error("IO error: {0}")]
@@ -68,14 +75,28 @@ pub enum DispenserConfigError {
6875
impl DispenserConfigFile {
6976
fn try_init_from_string(
7077
mut config: String,
71-
vars: &DispenserVars,
78+
vars: DispenserVars,
7279
) -> Result<Self, DispenserConfigError> {
7380
let mut env = Environment::new();
81+
82+
let syntax = minijinja::syntax::SyntaxConfig::builder()
83+
.variable_delimiters("${", "}")
84+
.build()
85+
.expect("This really should not fail. If this fail something has gone horribly wrong.");
86+
87+
env.set_syntax(syntax);
88+
7489
env.set_undefined_behavior(minijinja::UndefinedBehavior::Strict);
7590
let template = env.template_from_str(&config)?;
7691
config = template.render(&vars)?;
7792

78-
Ok(toml::from_str(&config)?)
93+
let config_toml: DispenserConfigFileSerde = toml::from_str(&config)?;
94+
95+
Ok(DispenserConfigFile {
96+
delay: config_toml.delay,
97+
instance: config_toml.instance,
98+
vars,
99+
})
79100
}
80101
pub fn try_init() -> Result<Self, DispenserConfigError> {
81102
use std::io::Read;
@@ -84,7 +105,7 @@ impl DispenserConfigFile {
84105
// Use handle vars to replace strings with handlevars
85106
let vars = DispenserVars::try_init()?;
86107

87-
Self::try_init_from_string(config, &vars)
108+
Self::try_init_from_string(config, vars)
88109
}
89110
}
90111

@@ -138,6 +159,9 @@ struct Image {
138159

139160
impl DispenserConfigFile {
140161
pub fn into_config(self) -> crate::config::ContposeConfig {
162+
let vars = crate::config::DispenserVars {
163+
inner: Arc::new(self.vars.inner),
164+
};
141165
let instances = self
142166
.instance
143167
.into_par_iter()
@@ -154,6 +178,7 @@ impl DispenserConfigFile {
154178
.collect(),
155179
cron: instance.cron,
156180
initialize: instance.initialize.into(),
181+
vars: vars.clone(),
157182
})
158183
.collect();
159184

@@ -190,18 +215,18 @@ mod tests {
190215
let vars = DispenserVars::try_init_from_string(vars_input).unwrap();
191216

192217
let config_input = r#"
193-
delay = {{ delay_ms }}
218+
delay = ${ delay_ms }
194219
[[instance]]
195-
path = "{{ base_path }}/service"
220+
path = "${ base_path }/service"
196221
initialize = "on-trigger"
197222
198223
[[instance.images]]
199224
registry = "hub"
200225
name = "service"
201-
tag = "{{ img_version }}"
226+
tag = "${ img_version }"
202227
"#;
203228

204-
let config = DispenserConfigFile::try_init_from_string(config_input.to_string(), &vars)
229+
let config = DispenserConfigFile::try_init_from_string(config_input.to_string(), vars)
205230
.expect("Failed to parse config");
206231

207232
assert_eq!(config.delay.get(), 500);
@@ -228,7 +253,8 @@ mod tests {
228253
path = "."
229254
"#;
230255
let cfg =
231-
DispenserConfigFile::try_init_from_string(default_config.to_string(), &vars).unwrap();
256+
DispenserConfigFile::try_init_from_string(default_config.to_string(), vars.clone())
257+
.unwrap();
232258
assert_eq!(cfg.instance[0].initialize, Initialize::Immediately);
233259

234260
// Test aliases
@@ -251,7 +277,7 @@ mod tests {
251277
"#,
252278
alias
253279
);
254-
let cfg = DispenserConfigFile::try_init_from_string(toml, &vars).unwrap();
280+
let cfg = DispenserConfigFile::try_init_from_string(toml, vars.clone()).unwrap();
255281
assert_eq!(cfg.instance[0].initialize, expected);
256282
}
257283
}
@@ -264,9 +290,9 @@ mod tests {
264290
let config = r#"
265291
delay = 1
266292
[[instance]]
267-
path = "{{ non_existent }}"
293+
path = "${ non_existent }"
268294
"#;
269-
let res = DispenserConfigFile::try_init_from_string(config.to_string(), &vars);
295+
let res = DispenserConfigFile::try_init_from_string(config.to_string(), vars.clone());
270296
assert!(
271297
matches!(res, Err(DispenserConfigError::Template(_))),
272298
"{:?}",

src/instance.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ impl Instance {
5050
let master = Arc::new(DockerComposeMaster::initialize(
5151
&config.path,
5252
config.initialize,
53+
config.vars.clone(),
5354
));
5455
let watchers = config.get_watchers();
5556
Self {

src/main.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ fn main() -> ExitCode {
3434
return ExitCode::SUCCESS;
3535
}
3636

37+
log::info!("Dispenser running with PID: {}", std::process::id());
38+
3739
if let Err(e) = rayon::ThreadPoolBuilder::new()
3840
.num_threads(NUM_THREADS)
3941
.build_global()

src/master.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use std::{
99
thread::JoinHandle,
1010
};
1111

12-
use crate::config::Initialize;
12+
use crate::config::{DispenserVars, Initialize};
1313

1414
#[derive(Clone, Copy, Eq, PartialEq)]
1515
#[repr(u32)]
@@ -90,7 +90,7 @@ impl DockerComposeMaster {
9090
pub fn send_msg(&self, msg: MasterMsg) {
9191
let _ = self.update_msg.send(msg);
9292
}
93-
pub fn initialize(path: impl AsRef<Path>, initialize: Initialize) -> Self {
93+
pub fn initialize(path: impl AsRef<Path>, initialize: Initialize, vars: DispenserVars) -> Self {
9494
let status_shared = Arc::new(AtomicMasterStatus::new(MasterStatus::Stopped));
9595
let status = Arc::clone(&status_shared);
9696
let (update_msg, update_recv) = std::sync::mpsc::channel::<MasterMsg>();
@@ -117,6 +117,7 @@ impl DockerComposeMaster {
117117
.args(action.flags())
118118
.arg("-d")
119119
.current_dir(&path)
120+
.envs(vars.inner.iter())
120121
.stdin(Stdio::null())
121122
.stdout(Stdio::null())
122123
.stderr(Stdio::null())
@@ -139,7 +140,7 @@ impl DockerComposeMaster {
139140
log::warn!("Received stop signal for instace {path:?}");
140141
let _ = Command::new("docker")
141142
.arg("compose")
142-
.arg("down")
143+
.arg("stop")
143144
.current_dir(&path)
144145
.stdin(Stdio::null())
145146
.stdout(Stdio::null())

0 commit comments

Comments
 (0)