Skip to content

Commit 45c7ba7

Browse files
Merge pull request #48 from osusec/dr/image-tag-format
Add config option for custom image tag format
2 parents 0fcbafa + 2a42a68 commit 45c7ba7

File tree

7 files changed

+217
-27
lines changed

7 files changed

+217
-27
lines changed

src/access_handlers/docker.rs

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,11 @@ use bollard::{
66
};
77
use futures::{StreamExt, TryStreamExt};
88
use itertools::Itertools;
9+
use minijinja;
910
use tokio;
1011
use tracing::{debug, error, info, trace, warn};
1112

12-
use crate::clients::docker;
13+
use crate::clients::{docker, render_strict};
1314
use crate::configparser::{get_config, get_profile_config};
1415

1516
/// container registry / daemon access checks
@@ -26,7 +27,16 @@ pub async fn check(profile_name: &str) -> Result<()> {
2627

2728
// build test image string
2829
// registry.example.com/somerepo/testimage:pleaseignore
29-
let test_image = format!("{}/credstestimage", registry_config.domain);
30+
let test_image = render_strict(
31+
&registry_config.tag_format,
32+
minijinja::context! {
33+
domain => registry_config.domain,
34+
challenge => "accesscheck",
35+
container => "testimage",
36+
profile => profile_name
37+
},
38+
)
39+
.context("could not render tag format template")?;
3040
debug!("will push test image to {}", test_image);
3141

3242
// push alpine image with build credentials
@@ -66,16 +76,16 @@ async fn check_build_credentials(client: &Docker, test_image: &str) -> Result<()
6676

6777
let registry_config = &get_config()?.registry;
6878

69-
// rename alpine image as test image
70-
let tag_opts = TagImageOptions {
71-
repo: test_image,
72-
tag: "latest",
73-
};
79+
// rename alpine image as test imag
80+
let (repo, tag) = test_image
81+
.rsplit_once(':')
82+
.unwrap_or((test_image, "latest"));
83+
let tag_opts = TagImageOptions { repo, tag };
7484
client.tag_image("alpine", Some(tag_opts)).await?;
7585

7686
// now push test iamge to configured repo
77-
debug!("pushing alpine to target registry");
78-
let options = PushImageOptions { tag: "latest" };
87+
debug!("pushing alpine to target registry as {}:{}", repo, tag);
88+
let options = PushImageOptions { tag };
7989
let build_creds = DockerCredentials {
8090
username: Some(registry_config.build.user.clone()),
8191
password: Some(registry_config.build.pass.clone()),
@@ -84,7 +94,7 @@ async fn check_build_credentials(client: &Docker, test_image: &str) -> Result<()
8494
};
8595

8696
client
87-
.push_image(test_image, Some(options), Some(build_creds))
97+
.push_image(repo, Some(options), Some(build_creds))
8898
.try_collect::<Vec<_>>()
8999
.await?;
90100

@@ -100,8 +110,11 @@ async fn check_cluster_credentials(client: &Docker, test_image: &str) -> Result<
100110
let registry_config = &get_config()?.registry;
101111

102112
// pull just-pushed alpine image from repo
113+
let (repo, tag) = test_image
114+
.rsplit_once(':')
115+
.unwrap_or((test_image, "latest"));
103116
let alpine_test_image = CreateImageOptions {
104-
from_image: test_image,
117+
from_image: [repo, tag].join(":"),
105118
..Default::default()
106119
};
107120
let cluster_creds = DockerCredentials {

src/builder/mod.rs

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,6 @@ use crate::utils::TryJoinAll;
2020
pub mod artifacts;
2121
pub mod docker;
2222

23-
// define tag format as reusable macro
24-
macro_rules! image_tag_str {
25-
() => {
26-
"{registry}/{challenge}-{container}:{profile}"
27-
};
28-
}
29-
pub(super) use image_tag_str;
30-
3123
/// Information about all of a challenge's build artifacts.
3224
#[derive(Debug)]
3325
pub struct BuildResult {

src/clients.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,3 +345,19 @@ pub async fn wait_for_status(client: &kube::Client, object: &DynamicObject) -> R
345345

346346
Ok(())
347347
}
348+
349+
//
350+
// Minijinja strict rendering with error
351+
//
352+
353+
/// Similar to minijinja.render!(), but return Error if any undefined values.
354+
pub fn render_strict(template: &str, context: minijinja::Value) -> Result<String> {
355+
let mut strict_env = minijinja::Environment::new();
356+
// error on any undefined template variables
357+
strict_env.set_undefined_behavior(minijinja::UndefinedBehavior::Strict);
358+
359+
let r = strict_env
360+
.render_str(template, context)
361+
.context(format!("could not render template {:?}", template))?;
362+
Ok(r)
363+
}

src/configparser/challenge.rs

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ use std::str::FromStr;
1212
use tracing::{debug, error, info, trace, warn};
1313
use void::Void;
1414

15-
use crate::builder::image_tag_str;
15+
use crate::clients::render_strict;
1616
use crate::configparser::config::Resource;
1717
use crate::configparser::field_coersion::string_or_struct;
1818
use crate::configparser::get_config;
@@ -153,13 +153,17 @@ impl ChallengeConfig {
153153

154154
match &pod.image_source {
155155
ImageSource::Image(t) => Ok(t.to_string()),
156-
ImageSource::Build(b) => Ok(format!(
157-
image_tag_str!(),
158-
registry = config.registry.domain,
159-
challenge = self.name,
160-
container = pod.name,
161-
profile = profile_name
162-
)),
156+
// render image tag template from config
157+
ImageSource::Build(b) => render_strict(
158+
&get_config()?.registry.tag_format,
159+
minijinja::context! {
160+
domain => config.registry.domain,
161+
challenge => self.slugify(),
162+
container => pod.name,
163+
profile => profile_name
164+
},
165+
)
166+
.context("error rendering challenge image tag template"),
163167
}
164168
}
165169

src/configparser/config.rs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ pub fn parse() -> Result<RcdsConfig> {
1818
// keys by undoing the s/_/./ that the figment::split() did.
1919
var.to_string()
2020
.to_lowercase()
21+
.replace("registry.tag.format", "registry.tag_format")
2122
.replace("frontend.", "frontend_")
2223
.replace("challenges.", "challenges_")
2324
.replace("s3.access.", "s3.access_")
@@ -60,10 +61,48 @@ struct RcdsConfig {
6061
#[derive(Debug, PartialEq, Serialize, Deserialize)]
6162
#[fully_pub]
6263
struct Registry {
64+
/// Registry base url used to build part of the full image string.
65+
///
66+
/// Example: `domain: "registry.io/myctf"`
6367
domain: String,
68+
69+
/// Container image tag format for challenge images.
70+
///
71+
/// Format:
72+
/// Jinja-style double-braces around field name (`{{ field_name }}`)
73+
///
74+
/// Default, works for most registries (self-hosted, GCP, DigitalOcean, ...):
75+
/// `"{{domain}}/{{challenge}}-{{container}}:{{profile}}"`
76+
///
77+
/// For registries like AWS that make it hard to create individual repositories,
78+
/// keep all the challenge info in the tag:
79+
/// `"{{domain}}:{{challenge}}-{{container}}-{{profile}}"`
80+
///
81+
/// Available fields:
82+
/// - `domain`: the domain config field above; the repository base URL
83+
/// - `challenge`: challenge name, slugified
84+
/// - `container`: name of the specific pod in the challenge this image is for
85+
/// - `profile`: the current deployment profile, for isolating images between environments
86+
///
87+
/// Example:
88+
///
89+
/// For challenge `pwn/notsh`, chal pod container `main`, profile `prod`, and the example domain:
90+
/// ```py
91+
/// the default --> "registry.io/myctf/pwn-notsh-main:prod"
92+
///
93+
/// "{{domain}}:{{challenge}}-{{container}}" --> "registry.io/myctf:pwn-notsh-main"
94+
/// ```
95+
#[serde(default = "default_tag_format")]
96+
tag_format: String,
97+
98+
/// Container registry login for pushing images during build/deploy
6499
build: UserPass,
100+
/// Container registry login for pulling images in cluster. Can and should be read-only.
65101
cluster: UserPass,
66102
}
103+
fn default_tag_format() -> String {
104+
"{{domain}}/{{challenge}}-{{container}}:{{profile}}".to_string()
105+
}
67106

68107
#[derive(Debug, PartialEq, Serialize, Deserialize, Clone)]
69108
#[fully_pub]

src/tests/parsing/config.rs

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,131 @@ fn all_yaml() {
6868
flag_regex: "test{[a-zA-Z_]+}".to_string(),
6969
registry: Registry {
7070
domain: "registry.example/test".to_string(),
71+
tag_format: "{{domain}}/{{challenge}}-{{container}}:{{profile}}".to_string(),
72+
build: UserPass {
73+
user: "admin".to_string(),
74+
pass: "notrealcreds".to_string(),
75+
},
76+
cluster: UserPass {
77+
user: "cluster".to_string(),
78+
pass: "alsofake".to_string(),
79+
},
80+
},
81+
defaults: Defaults {
82+
difficulty: 1,
83+
resources: Resource {
84+
cpu: 1,
85+
memory: "500M".to_string(),
86+
},
87+
},
88+
points: vec![ChallengePoints {
89+
difficulty: 1,
90+
min: 0,
91+
max: 1337,
92+
}],
93+
94+
deploy: HashMap::from([(
95+
"testing".to_string(),
96+
ProfileDeploy {
97+
challenges: HashMap::from([
98+
("web/bar".to_string(), false),
99+
("misc/foo".to_string(), true),
100+
]),
101+
},
102+
)]),
103+
profiles: HashMap::from([(
104+
"testing".to_string(),
105+
ProfileConfig {
106+
frontend_url: "https://frontend.example".to_string(),
107+
frontend_token: "secretsecretsecret".to_string(),
108+
challenges_domain: "chals.frontend.example".to_string(),
109+
kubeconfig: None,
110+
kubecontext: "testcluster".to_string(),
111+
s3: S3Config {
112+
bucket_name: "asset_testing".to_string(),
113+
endpoint: "s3.example".to_string(),
114+
region: "us-fake-1".to_string(),
115+
access_key: "accesskey".to_string(),
116+
secret_key: "secretkey".to_string(),
117+
},
118+
dns: serde_yml::to_value(HashMap::from([
119+
("provider", "somebody"),
120+
("thing", "whatever"),
121+
]))
122+
.unwrap(),
123+
},
124+
)]),
125+
};
126+
127+
assert_eq!(config, expected);
128+
129+
Ok(())
130+
});
131+
}
132+
133+
#[test]
134+
/// Test parsing RCDS config where all fields are specified in the yaml
135+
fn registry_tag_format() {
136+
figment::Jail::expect_with(|jail| {
137+
jail.clear_env();
138+
jail.create_file(
139+
"rcds.yaml",
140+
r#"
141+
flag_regex: test{[a-zA-Z_]+}
142+
143+
registry:
144+
domain: registry.example/test
145+
tag_format: "{{domain}}:{{challenge}}.{{container}}"
146+
build:
147+
user: admin
148+
pass: notrealcreds
149+
cluster:
150+
user: cluster
151+
pass: alsofake
152+
153+
defaults:
154+
difficulty: 1
155+
resources: { cpu: 1, memory: 500M }
156+
157+
points:
158+
- difficulty: 1
159+
min: 0
160+
max: 1337
161+
162+
deploy:
163+
testing:
164+
misc/foo: true
165+
web/bar: false
166+
167+
profiles:
168+
testing:
169+
frontend_url: https://frontend.example
170+
frontend_token: secretsecretsecret
171+
challenges_domain: chals.frontend.example
172+
kubecontext: testcluster
173+
s3:
174+
bucket_name: asset_testing
175+
endpoint: s3.example
176+
region: us-fake-1
177+
access_key: accesskey
178+
secret_key: secretkey
179+
dns:
180+
provider: somebody
181+
thing: whatever
182+
"#,
183+
)?;
184+
185+
let config = match parse() {
186+
Ok(c) => Ok(c),
187+
// figment::Error cannot coerce from anyhow::Error natively
188+
Err(e) => Err(figment::Error::from(format!("{:?}", e))),
189+
}?;
190+
191+
let expected = RcdsConfig {
192+
flag_regex: "test{[a-zA-Z_]+}".to_string(),
193+
registry: Registry {
194+
domain: "registry.example/test".to_string(),
195+
tag_format: "{{domain}}:{{challenge}}.{{container}}".to_string(),
71196
build: UserPass {
72197
user: "admin".to_string(),
73198
pass: "notrealcreds".to_string(),

tests/repo/rcds.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ flag_regex: dam{[a-zA-Z...]}
22

33
registry:
44
domain: localhost:5000/damctf
5+
tag_format: "{{domain}}/{{challenge}}/{{container}}:{{profile}}"
56
# or environment vars e.g. BEAVERCDS_REGISTRY_BUILD_USER / etc
67
build:
78
user: admin

0 commit comments

Comments
 (0)