Skip to content

Commit 2ca0f4a

Browse files
Merge pull request #41 from osusec/dr/deploy-kubernetes
Implement challenge Kubernetes resource deployment
2 parents 238d6e5 + a56b8a6 commit 2ca0f4a

File tree

18 files changed

+643
-256
lines changed

18 files changed

+643
-256
lines changed

Cargo.lock

Lines changed: 203 additions & 120 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@ figment = { version = "0.10.19", features = ["env", "yaml", "test"] }
2020
zip = { version = "2.2.2", default-features = false, features = ["deflate"] }
2121

2222
# kubernetes:
23-
kube = { version = "0.91.0", features = ["runtime", "derive"] }
24-
k8s-openapi = { version = "0.22.0", features = ["latest"] }
23+
kube = { version = "0.99.0", features = ["runtime", "derive"] }
24+
k8s-openapi = { version = "0.24.0", features = ["latest"] }
2525
tokio = { version = "1.38.0", features = ["rt", "macros"] }
2626
http = { version = "1.2", default-features = false }
2727

@@ -33,7 +33,7 @@ rust-s3 = { version = "0.35.1", default-features = false, features = [
3333
"fail-on-err",
3434
"tokio-rustls-tls",
3535
] }
36-
minijinja = "2.6.0"
36+
minijinja = { version = "2.6.0", features = ["json"] }
3737
duct = "0.13.7"
3838
fastrand = "2.3.0"
3939

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,14 @@
1-
{% set chal = whatever -%}
2-
{% set pod = whatever -%}
3-
{% set slug = chal.name | slugify -%}
4-
51
---
62
apiVersion: apps/v1
73
kind: Deployment
84
metadata:
95
name: "rcds-{{ slug }}-{{ pod.name }}"
106
namespace: "rcds-{{ slug }}"
117
annotations:
8+
app.kubernetes.io/managed-by: rcds
129
rctf/challenge: "{{ chal.name }}"
1310
rctf/category: "{{ chal.category }}"
14-
rctf/description: "{{ chal.description }}"
15-
rctf/flag: "{{ chal.flag }}"
16-
rctf/points: "{{ chal.points }}"
17-
rctf/files: "{{ chal.points }}"
18-
app.kubernetes.io/managed-by: rcds
11+
rctf/challenge-pod: "{{ pod.name }}"
1912
spec:
2013
selector:
2114
matchLabels:
@@ -28,20 +21,29 @@ spec:
2821
spec:
2922
containers:
3023
- name: "{{ pod.name }}"
31-
image: "{{ pod.image }}"
24+
image: "{{ pod_image }}"
3225
ports:
3326
{% for p in pod.ports -%}
3427
- containerPort: {{ p.internal }}
28+
{%- else %}
29+
[]
3530
{%- endfor %}
31+
3632
{% if pod.env -%}
3733
env:
3834
{% for k, v in pod.env -%}
3935
- { name: "{{ k }}", value: "{{ v }}" }
36+
{%- else %}
37+
[]
4038
{%- endfor %}
4139
{%- endif %}
40+
41+
{% if pod.resources -%}
4242
resources:
43-
requests: {{ pod.resources | json_encode() | safe }}
44-
limits: {{ pod.resources | json_encode() | safe }}
43+
{# TODO: use defaults from rcds config -#}
44+
requests: {{ pod.resources | tojson }}
45+
limits: {{ pod.resources | tojson }}
46+
{%- endif %}
4547

4648
# don't give chal pods k8s api tokens
4749
automountServiceAccountToken: false
Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,8 @@
1-
{% set chal = whatever -%}
2-
{% set pod = current from chal -%}
3-
{% set rcds = global rcds yaml -%}
4-
{% set slug = chal.name | slugify -%}
5-
61
---
72
apiVersion: v1
83
kind: Service
94
metadata:
10-
name: "rcds-{{ slug }}-{{ pod.name }}"
5+
name: "rcds-{{ slug }}-{{ pod.name }}-http"
116
namespace: "rcds-{{ slug }}"
127
annotations:
138
app.kubernetes.io/managed-by: rcds
@@ -16,7 +11,7 @@ spec:
1611
rctf/part-of: "{{ slug }}-{{ pod.name }}"
1712
ports:
1813
# host service at same port as container
19-
{% for p in pod.ports -%}
14+
{% for p in http_ports -%}
2015
- port: {{ p.internal }}
2116
targetPort: {{ p.internal }}
2217
{%- endfor %}
@@ -30,18 +25,17 @@ metadata:
3025
annotations:
3126
app.kubernetes.io/managed-by: rcds
3227
spec:
33-
ingressClassName: beaverctf-nginx
28+
ingressClassName: beavercds
3429
rules:
35-
{% for p in pod.ports | filter(attribute="expose.http") -%}
36-
- host: "{{ p.expose.http }}.{{ rcds.domain }}"
30+
{%- for p in http_ports %}
31+
- host: "{{ p.expose.http }}.{{ domain }}"
3732
http:
3833
paths:
3934
- pathType: Prefix
4035
path: "/"
4136
backend:
4237
service:
43-
name: "rcds-{{ slug }}-{{ pod.name }}"
38+
name: "rcds-{{ slug }}-{{ pod.name }}-http"
4439
port:
45-
# find first pod http port
4640
number: {{ p.internal }}
47-
{%- endfor %}
41+
{% endfor -%}
Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
{% set chal = whatever -%}
2-
{% set slug = chal.name | slugify -%}
3-
41
---
52
apiVersion: v1
63
kind: Namespace
74
metadata:
85
name: rcds-{{ slug }}
96
annotations:
107
app.kubernetes.io/managed-by: rcds
8+
rctf/challenge: "{{ chal.name }}"
9+
rctf/category: "{{ chal.category }}"
10+
rctf/description: |
11+
{{ chal.description | indent(6) }}
Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,21 @@
1-
{% set chal = whatever -%}
2-
{% set pod = current from chal -%}
3-
{% set rcds = global rcds yaml -%}
4-
{% set slug = chal.name | slugify -%}
5-
61
---
72
apiVersion: v1
83
kind: Service
94
metadata:
10-
name: "rcds-{{ slug }}-{{ pod.name }}"
5+
name: "rcds-{{ slug }}-{{ pod.name }}-tcp"
116
namespace: "rcds-{{ slug }}"
127
annotations:
138
app.kubernetes.io/managed-by: rcds
149
# still use separate domain for these, since exposed LoadBalancer services
1510
# will all have different ips from each other
16-
external-dns.alpha.kubernetes.io/hostname: "{{ slug }}.{{ rcds.domain }}"
11+
external-dns.alpha.kubernetes.io/hostname: "{{ slug }}.{{ domain }}"
1712
spec:
1813
type: LoadBalancer
1914
selector:
2015
rctf/part-of: "{{ slug }}-{{ pod.name }}"
2116
ports:
22-
{% for p in pod.ports | filter(attribute="expose.tcp") -%}
17+
{%- for p in tcp_ports %}
2318
- port: {{ p.expose.tcp }}
2419
targetPort: {{ p.internal }}
2520
protocol: TCP
26-
{%- endfor %}
21+
{% endfor -%}

src/asset_files/setup_manifests/external-dns.helm.yaml.j2

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ txtPrefix: "k8s-owner."
2121

2222
extraArgs:
2323
# ignore any services with internal ips
24-
exclude-target-net: "10.0.0.0/8"
24+
#exclude-target-net: "10.0.0.0/8"
2525
# special character replacement
2626
txt-wildcard-replacement: star
2727

src/builder/artifacts.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ pub async fn extract_asset(
7979

8080
let name = format!(
8181
"asset-container-{}-{}-{}",
82-
chal.directory.to_string_lossy().replace("/", "-"),
82+
chal.slugify(),
8383
container_name,
8484
// include random discriminator to avoid name collisions
8585
repeat_with(fastrand::alphanumeric)

src/clients.rs

Lines changed: 146 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,17 @@ use std::sync::OnceLock;
55
use anyhow::{anyhow, bail, Context, Error, Result};
66
use bollard;
77
use futures::TryFutureExt;
8+
use k8s_openapi::api::{
9+
apps::v1::Deployment,
10+
core::v1::{Pod, Service},
11+
networking::v1::Ingress,
12+
};
813
use kube::{
914
self,
10-
api::{DynamicObject, GroupVersionKind, TypeMeta},
15+
api::{DynamicObject, GroupVersionKind, Patch, PatchParams},
1116
core::ResourceExt,
12-
discovery::{ApiCapabilities, ApiResource, Discovery, Scope},
17+
discovery::{ApiCapabilities, ApiResource},
18+
runtime::{conditions, wait::await_condition},
1319
};
1420
use s3;
1521
use simplelog::*;
@@ -201,3 +207,141 @@ pub async fn kube_api_for(
201207
Ok(kube::Api::default_namespaced_with(client, &resource))
202208
}
203209
}
210+
211+
/// Apply multi-document manifest file, return created resources
212+
pub async fn apply_manifest_yaml(
213+
client: &kube::Client,
214+
manifest: &str,
215+
) -> Result<Vec<DynamicObject>> {
216+
// set ourself as the owner for managed fields
217+
// https://kubernetes.io/docs/reference/using-api/server-side-apply/#managers
218+
let pp = PatchParams::apply("beavercds").force();
219+
220+
let mut results = vec![];
221+
222+
// this manifest has multiple documents (crds, deployment)
223+
for yaml in multidoc_deserialize(manifest)? {
224+
let obj: DynamicObject = serde_yml::from_value(yaml)?;
225+
debug!(
226+
"applying resource {} {}",
227+
obj.types.clone().unwrap_or_default().kind,
228+
obj.name_any()
229+
);
230+
231+
let obj_api = kube_api_for(&obj, client.clone()).await?;
232+
match obj_api
233+
// patch is idempotent and will create if not present
234+
.patch(&obj.name_any(), &pp, &Patch::Apply(&obj))
235+
.await
236+
{
237+
Ok(d) => {
238+
results.push(d);
239+
Ok(())
240+
}
241+
// if error is from cluster api, mark it as such
242+
Err(kube::Error::Api(ae)) => {
243+
// Err(kube::Error::Api(ae).into())
244+
Err(anyhow!(ae).context("error from cluster when deploying"))
245+
}
246+
// other errors could be anything
247+
Err(e) => Err(anyhow!(e)).context("unknown error when deploying"),
248+
}?;
249+
}
250+
251+
Ok(results)
252+
}
253+
254+
/// Deserialize multi-document yaml string into a Vec of the documents
255+
fn multidoc_deserialize(data: &str) -> Result<Vec<serde_yml::Value>> {
256+
use serde::Deserialize;
257+
258+
let mut docs = vec![];
259+
for de in serde_yml::Deserializer::from_str(data) {
260+
match serde_yml::Value::deserialize(de)? {
261+
// discard any empty documents (e.g. from trailing ---)
262+
serde_yml::Value::Null => (),
263+
not_null => docs.push(not_null),
264+
};
265+
}
266+
Ok(docs)
267+
268+
// // deserialize all chunks
269+
// serde_yml::Deserializer::from_str(data)
270+
// .map(serde_yml::Value::deserialize)
271+
// // discard any empty documents (e.g. from trailing ---)
272+
// .filter_ok(|val| val != &serde_yml::Value::Null)
273+
// // coerce errors to Anyhow
274+
// .map(|r| r.map_err(|e| e.into()))
275+
// .collect()
276+
}
277+
278+
/// Check the status of the passed object and wait for it to become ready.
279+
///
280+
/// This function does not provide a timeout. Callers will need to wrap this with a timeout instead.
281+
pub async fn wait_for_status(client: &kube::Client, object: &DynamicObject) -> Result<()> {
282+
debug!(
283+
"waiting for ok status for {} {}",
284+
object.types.clone().unwrap_or_default().kind,
285+
object.name_any()
286+
);
287+
288+
// handle each separate object type differently
289+
match object.types.clone().unwrap_or_default().kind.as_str() {
290+
// wait for Pod to become running
291+
"Pod" => {
292+
let api = kube::Api::namespaced(client.clone(), &object.namespace().unwrap());
293+
let x = await_condition(api, &object.name_any(), conditions::is_pod_running()).await?;
294+
}
295+
296+
// wait for Deployment to complete rollout
297+
"Deployment" => {
298+
let api = kube::Api::namespaced(client.clone(), &object.namespace().unwrap());
299+
await_condition(
300+
api,
301+
&object.name_any(),
302+
conditions::is_deployment_completed(),
303+
)
304+
.await?;
305+
}
306+
307+
// wait for Ingress to get IP from ingress controller
308+
"Ingress" => {
309+
let api = kube::Api::namespaced(client.clone(), &object.namespace().unwrap());
310+
await_condition(
311+
api,
312+
&object.name_any(),
313+
conditions::is_ingress_provisioned(),
314+
)
315+
.await?;
316+
}
317+
318+
// wait for LoadBalancer service to get IP
319+
"Service" => {
320+
let api = kube::Api::namespaced(client.clone(), &object.namespace().unwrap());
321+
let svc: Service = api.get(&object.name_any()).await?;
322+
323+
// we only care about checking LoadBalancer-type services, return Ok
324+
// for any non-LB services
325+
//
326+
// TODO: do we care about NodePorts? don't need to check any atm
327+
if svc.spec.unwrap_or_default().type_ != Some("LoadBalancer".to_string()) {
328+
trace!(
329+
"not checking status for internal service {}",
330+
object.name_any()
331+
);
332+
return Ok(());
333+
}
334+
335+
await_condition(
336+
api,
337+
&object.name_any(),
338+
conditions::is_service_loadbalancer_provisioned(),
339+
)
340+
.await?;
341+
}
342+
343+
other => trace!("not checking status for resource type {other}"),
344+
};
345+
346+
Ok(())
347+
}

0 commit comments

Comments
 (0)