diff --git a/Cargo.lock b/Cargo.lock index ffbfd58..5aaa0a5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -248,7 +248,7 @@ dependencies = [ "miniz_oxide", "object", "rustc-demangle", - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -285,16 +285,18 @@ dependencies = [ "futures", "glob", "http 1.2.0", + "inquire", "itertools", "k8s-openapi", "kube", "minijinja", "owo-colors", "pretty_assertions", + "regex", "rust-s3", "serde", "serde_nested_with", - "serde_yml", + "serde_yaml_ng", "tar", "tempfile", "tokio", @@ -304,6 +306,12 @@ dependencies = [ "zip", ] +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.6.0" @@ -375,6 +383,12 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8334215b81e418a0a7bdb8ef0849474f40bb10c8b71f1c4ed315cff49f32494d" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.8.0" @@ -406,7 +420,7 @@ dependencies = [ "iana-time-zone", "num-traits", "serde", - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -430,7 +444,7 @@ dependencies = [ "clap_lex", "strsim", "unicase", - "unicode-width", + "unicode-width 0.2.0", ] [[package]] @@ -526,6 +540,31 @@ version = "0.8.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80" +[[package]] +name = "crossterm" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e64e6c0fbe2c17357405f7c758c1ef960fce08bdfb2c03d88d2a18d7e09c4b67" +dependencies = [ + "bitflags 1.3.2", + "crossterm_winapi", + "libc", + "mio 0.8.11", + "parking_lot", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crunchy" version = "0.2.2" @@ -903,6 +942,24 @@ dependencies = [ "slab", ] +[[package]] +name = "fuzzy-matcher" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" +dependencies = [ + "thread_local", +] + +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -1463,6 +1520,23 @@ version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" +[[package]] +name = "inquire" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fddf93031af70e75410a2511ec04d49e758ed2f26dad3404a934e0fb45cc12a" +dependencies = [ + "bitflags 2.6.0", + "crossterm", + "dyn-clone", + "fuzzy-matcher", + "fxhash", + "newline-converter", + "once_cell", + "unicode-segmentation", + "unicode-width 0.1.14", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.1" @@ -1669,21 +1743,11 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" dependencies = [ - "bitflags", + "bitflags 2.6.0", "libc", "redox_syscall", ] -[[package]] -name = "libyml" -version = "0.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3302702afa434ffa30847a83305f0a69d6abd74293b6554c18ec85c7ef30c980" -dependencies = [ - "anyhow", - "version_check", -] - [[package]] name = "linux-raw-sys" version = "0.4.14" @@ -1775,6 +1839,18 @@ dependencies = [ "adler2", ] +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.48.0", +] + [[package]] name = "mio" version = "1.0.2" @@ -1787,6 +1863,15 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "newline-converter" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b6b097ecb1cbfed438542d16e84fd7ad9b0c76c8a65b7f9039212a3d14dc7f" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "nu-ansi-term" version = "0.46.0" @@ -1900,7 +1985,7 @@ dependencies = [ "libc", "redox_syscall", "smallvec", - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -2106,7 +2191,7 @@ version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b6dfecf2c74bce2466cabf93f6664d6998a69eb21e39f4207930065b27b771f" dependencies = [ - "bitflags", + "bitflags 2.6.0", ] [[package]] @@ -2228,7 +2313,7 @@ version = "0.38.39" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "375116bee2be9ed569afe2154ea6a99dfdffd257f533f187498c2a8f5feaf4ee" dependencies = [ - "bitflags", + "bitflags 2.6.0", "errno", "libc", "linux-raw-sys", @@ -2429,7 +2514,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags", + "bitflags 2.6.0", "core-foundation", "core-foundation-sys", "libc", @@ -2565,18 +2650,16 @@ dependencies = [ ] [[package]] -name = "serde_yml" -version = "0.0.12" +name = "serde_yaml_ng" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59e2dd588bf1597a252c3b920e0143eb99b0f76e4e082f4c92ce34fbc9e71ddd" +checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" dependencies = [ "indexmap 2.6.0", "itoa", - "libyml", - "memchr", "ryu", "serde", - "version_check", + "unsafe-libyaml", ] [[package]] @@ -2626,6 +2709,27 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" +dependencies = [ + "libc", + "mio 0.8.11", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.2" @@ -2861,7 +2965,7 @@ dependencies = [ "backtrace", "bytes", "libc", - "mio", + "mio 1.0.2", "pin-project-lite", "signal-hook-registry", "socket2", @@ -2950,7 +3054,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "403fa3b783d4b626a8ad51d766ab03cb6d2dbfc46b1c5d4448395e6628dc9697" dependencies = [ "base64 0.22.1", - "bitflags", + "bitflags 2.6.0", "bytes", "http 1.2.0", "http-body 1.0.1", @@ -3081,6 +3185,18 @@ version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "unicode-width" version = "0.2.0" @@ -3254,7 +3370,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" dependencies = [ "windows-core", - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -3263,7 +3379,16 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", ] [[package]] @@ -3272,7 +3397,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -3281,7 +3406,22 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", ] [[package]] @@ -3290,28 +3430,46 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -3324,24 +3482,48 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" diff --git a/Cargo.toml b/Cargo.toml index 0ee4c3d..ea9bbf2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,13 +9,15 @@ clap = { version = "4.5.4", features = ["unicode", "env", "derive"] } itertools = "0.12.1" glob = "0.3.1" serde = { version = "1.0", features = ["derive"] } -serde_yml = "0.0.12" +serde_yaml_ng = "0.10.0" serde_nested_with = "0.2.5" fully_pub = "0.1.4" void = "1" futures = "0.3.30" figment = { version = "0.10.19", features = ["env", "yaml", "test"] } zip = { version = "2.2.2", default-features = false, features = ["deflate"] } +regex = "1.11.1" +inquire = "0.7.5" # tracing tracing = { version = "0.1.41", features = ["attributes"] } diff --git a/src/asset_files/rcds.yaml.j2 b/src/asset_files/rcds.yaml.j2 new file mode 100644 index 0000000..e85a51c --- /dev/null +++ b/src/asset_files/rcds.yaml.j2 @@ -0,0 +1,80 @@ +# Used to check that all challenges' flags are in the correct format, +# and by the scoreboard frontend as a first check for invalid submissions. +flag_regex: "{{ flag_regex }}" + +# Registry configuration for challenge images. +registry: + domain: "{{ registry.domain }}" + # This is the default tag format; it will create a separate image for each + # challenge pod. Most container registries (Docker, GHCR, Gitlab, Quay, ...) + # are fine with this. If you are using a container registry that requires + # every image within the repository to be created ahead-of-time (AWS ECR) + # before it can be pushed, you can change this to use tags for each separate + # challenge within one image in the registry. + tag_format: "{{ registry.tag_format }}" + # Build-time credentials used to push images during `beavercds deploy`. + build: + user: "{{ registry.build.user }}" + pass: "{{ registry.build.pass }}" + # Used by the cluster to pull the built images. + cluster: + user: "{{ registry.cluster.user }}" + pass: "{{ registry.cluster.pass }}" + +# Default difficulty class and resource requests used for challenges that did +# not set their own. +defaults: + difficulty: "{{ defaults.difficulty }}" + resources: { cpu: {{ defaults.resources.cpu }}, memory: "{{ defaults.resources.memory }}" } + +# The list of different difficulties that challenges can be assigned, and how +# many points challenges of that difficulty class should be worth. All +# challenges use dynamic scoring; for static points set both min and max to the +# same value. +points: + {% for pts in points -%} + - difficulty: "{{ pts.difficulty }}" + min: {{ pts.min }} + max: {{ pts.max }} + {%- else -%} + [] + {% endfor %} + +# Control what challenges are deployed in each environment profile. +deploy: + {% for name, _conf in profiles | items -%} + {{ name }}: {} + {%- else -%} + {} + {% endfor %} + +# Separate environment profiles to allow for multiple independent deployments +# of challenges, e.g. staging and production to test challenges internally +# before going live for all users. +profiles: + {% for name, p in profiles | items -%} + {{ name }}: + # Used to push challenge information into the frontend/scoreboard. + frontend_url: {{ p.frontend_url }} + frontend_token: {{ p.frontend_token }} + # Root domain to expose all challenges under. + challenges_domain: {{ p.challenges_domain }} + # TODO: kubeconfig + kubecontext: {{ p.kubecontext }} + # Credentials for the public challenge file asset bucket. + s3: + bucket_name: {{ p.s3.bucket_name }} + endpoint: {{ p.s3.endpoint }} + region: {{ p.s3.region }} + access_key: {{ p.s3.access_key }} + secret_key: {{ p.s3.secret_key }} + # Config for the environment's external-dns deployment. + dns: + # Place external-dns configuration options here; + # this yaml will be passed directly to external-dns without modification + # Reference: https://github.com/bitnami/charts/tree/main/bitnami/external-dns + {%- else -%} + {} + {% endfor %} + +{# comment to preserve trailing newline -#} diff --git a/src/cli.rs b/src/cli.rs index fb1ef10..3b6272e 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -87,4 +87,21 @@ pub enum Commands { #[arg(short, long, value_name = "PROFILE")] profile: String, }, + + /// Create an initial `rcds.yaml` to the current working directory. + /// + /// By default, this command will prompt for each field of the config file + /// interactively. + #[group(multiple = false)] + Init { + /// Prompt user for each field interactively. [Default if no flags specified] + #[arg(short = 'i', long)] + interactive: bool, + /// Create a minimal config file with all fields blank. + #[arg(short = 'b', long)] + blank: bool, + /// Create a config file with example placeholder values. + #[arg(short = 'p', long)] + placeholders: bool, + }, } diff --git a/src/clients.rs b/src/clients.rs index dd22694..aa6f889 100644 --- a/src/clients.rs +++ b/src/clients.rs @@ -303,7 +303,7 @@ pub async fn apply_manifest_yaml( // this manifest has multiple documents (crds, deployment) for yaml in multidoc_deserialize(manifest)? { - let obj: DynamicObject = serde_yml::from_value(yaml)?; + let obj: DynamicObject = serde_yaml_ng::from_value(yaml)?; trace!( "applying resource {} {}", obj.types.clone().unwrap_or_default().kind, @@ -334,14 +334,14 @@ pub async fn apply_manifest_yaml( } /// Deserialize multi-document yaml string into a Vec of the documents -fn multidoc_deserialize(data: &str) -> Result> { +fn multidoc_deserialize(data: &str) -> Result> { use serde::Deserialize; let mut docs = vec![]; - for de in serde_yml::Deserializer::from_str(data) { - match serde_yml::Value::deserialize(de)? { + for de in serde_yaml_ng::Deserializer::from_str(data) { + match serde_yaml_ng::Value::deserialize(de)? { // discard any empty documents (e.g. from trailing ---) - serde_yml::Value::Null => (), + serde_yaml_ng::Value::Null => (), not_null => docs.push(not_null), }; } diff --git a/src/cluster_setup/mod.rs b/src/cluster_setup/mod.rs index c55ddb1..22a6c92 100644 --- a/src/cluster_setup/mod.rs +++ b/src/cluster_setup/mod.rs @@ -16,7 +16,7 @@ use kube::{Api, ResourceExt}; use minijinja; use owo_colors::OwoColorize; use serde; -use serde_yml; +use serde_yaml_ng; use tempfile; use tracing::{debug, error, info, trace, warn}; @@ -95,7 +95,7 @@ pub async fn install_extdns(profile: &config::ProfileConfig) -> Result<()> { let values = render_strict( VALUES_TEMPLATE, minijinja::context! { - provider_credentials => serde_yml::to_string(&profile.dns)?, + provider_credentials => serde_yaml_ng::to_string(&profile.dns)?, chal_domain => profile.challenges_domain }, )?; diff --git a/src/commands/init.rs b/src/commands/init.rs new file mode 100644 index 0000000..76a4d17 --- /dev/null +++ b/src/commands/init.rs @@ -0,0 +1,30 @@ +use anyhow::{Context, Result}; +use std::fs::File; +use std::io::Write; +use std::process::exit; +use tracing::{error, warn}; + +use crate::init; +use crate::{access_handlers::frontend, commands::deploy}; + +pub fn run(_interactive: &bool, placeholders: &bool, blank: &bool) -> Result<()> { + let options = if *blank { + init::blank_init() + } else if *placeholders { + init::example_init() + } else { + // default to interactive if no flags given + init::interactive_init()? + }; + + let configuration = init::templatize_init(&options).context("could not render template")?; + + let mut f = File::create("rcds.yaml")?; + f.write_all(configuration.as_bytes())?; + + // Note about external-dns + warn!("Note: external-dns configuration settings will need to be provided in rcds.yaml after file creation, under the `profiles.name.dns` key."); + warn!("Reference: https://github.com/bitnami/charts/tree/main/bitnami/external-dns"); + + Ok(()) +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 62beb26..c12fd1e 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -2,6 +2,7 @@ pub mod build; pub mod check_access; pub mod cluster_setup; pub mod deploy; +pub mod init; pub mod validate; // These modules should not do much and act mostly as a thunk to handle diff --git a/src/configparser/config.rs b/src/configparser/config.rs index 6527213..a91bc8e 100644 --- a/src/configparser/config.rs +++ b/src/configparser/config.rs @@ -100,7 +100,7 @@ struct Registry { /// Container registry login for pulling images in cluster. Can and should be read-only. cluster: UserPass, } -fn default_tag_format() -> String { +pub fn default_tag_format() -> String { "{{domain}}/{{challenge}}-{{container}}:{{profile}}".to_string() } @@ -121,7 +121,7 @@ struct Resource { #[derive(Debug, PartialEq, Serialize, Deserialize)] #[fully_pub] struct Defaults { - difficulty: i64, + difficulty: String, resources: Resource, } @@ -142,13 +142,14 @@ struct ProfileConfig { kubeconfig: Option, kubecontext: String, s3: S3Config, - dns: serde_yml::Value, + dns: serde_yaml_ng::Value, } -#[derive(Debug, PartialEq, Serialize, Deserialize)] +#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] #[fully_pub] struct ChallengePoints { - difficulty: i64, + /// Name of this difficulty level + difficulty: String, min: i64, max: i64, } diff --git a/src/init/example_values.rs b/src/init/example_values.rs new file mode 100644 index 0000000..3b9dfe7 --- /dev/null +++ b/src/init/example_values.rs @@ -0,0 +1,28 @@ +// Example strings for rcds.yaml + +pub static FLAG_REGEX: &str = "ctf{.*}"; + +pub static REGISTRY_DOMAIN: &str = "ghcr.io/youraccount"; +pub static REGISTRY_BUILD_USER: &str = "admin"; +pub static REGISTRY_BUILD_PASS: &str = "notrealcreds"; +pub static REGISTRY_CLUSTER_USER: &str = "cluster_user"; +pub static REGISTRY_CLUSTER_PASS: &str = "alsofake"; + +pub static DEFAULTS_DIFFICULTY: &str = "easy"; +pub static DEFAULTS_RESOURCES_CPU: i64 = 1; +pub static DEFAULTS_RESOURCES_MEMORY: &str = "500M"; + +pub static POINTS_DIFFICULTY: &str = "easy"; +pub static POINTS_MIN: i64 = 200; +pub static POINTS_MAX: i64 = 500; + +pub static PROFILES_PROFILE_NAME: &str = "default"; +pub static PROFILES_FRONTEND_URL: &str = "https://ctf.coolguy.invalid"; +pub static PROFILES_FRONTEND_TOKEN: &str = "secretsecretsecret"; +pub static PROFILES_CHALLENGES_DOMAIN: &str = "chals.coolguy.invalid"; +pub static PROFILES_KUBECONTEXT: &str = "ctf-cluster"; +pub static PROFILES_S3_BUCKET_NAME: &str = "ctf-bucket"; +pub static PROFILES_S3_ENDPOINT: &str = "s3.coolguy.invalid"; +pub static PROFILES_S3_REGION: &str = "us-west-2"; +pub static PROFILES_S3_ACCESSKEY: &str = "accesskey"; +pub static PROFILES_S3_SECRETACCESSKEY: &str = "secretkey"; diff --git a/src/init/mod.rs b/src/init/mod.rs new file mode 100644 index 0000000..fab782d --- /dev/null +++ b/src/init/mod.rs @@ -0,0 +1,289 @@ +use anyhow::Result; +use inquire; +use itertools::Itertools; +use minijinja; +use regex::Regex; +use serde; +use std::collections::HashMap; +use std::fmt; +use tracing::{debug, error, info, trace, warn}; + +use crate::configparser::config; +use crate::utils::render_strict; + +pub mod example_values; +pub mod templates; + +pub fn interactive_init() -> inquire::error::InquireResult { + println!("For all prompts below, simply press Enter to leave blank."); + println!("All fields that can be set in rcds.yaml can also be set via environment variables."); + + let difficulty_names; // set during `points` prompt later + + // FORMATTING NOTE: Some of these help messages cause rustfmt to silently + // fail to format this struct definition. Commenting out the marked + // help_message lines temporarily will let the formatting work. + // + // Ref: + // - https://github.com/rust-lang/rustfmt/issues/6687 + // - https://github.com/rust-lang/rustfmt/issues/3863 + + let options = config::RcdsConfig { + //TODO: what flavor of regex is being validated and accepted + flag_regex: inquire::Text::new("Flag regex:") + .with_help_message("This regex will be used to validate the individual flags of your challenges later.") // too long to format + .with_placeholder(example_values::FLAG_REGEX) + .prompt()?, + + registry: config::Registry { + tag_format: inquire::Text::new("Container image/tag format:") + .with_help_message("Template to use for built container images. This default works with most registries.") // too long to format + .with_placeholder(&config::default_tag_format()) + .prompt()?, + domain: inquire::Text::new("Container registry:") + .with_help_message("Registry domain and repository name of the container registry for hosted challenge images.") // too long to format + .with_placeholder(example_values::REGISTRY_DOMAIN) + .prompt()?, + build: config::UserPass { + user: inquire::Text::new("Container registry 'build' user:") + .with_help_message("The username that will be used to push built containers.") + .with_placeholder(example_values::REGISTRY_BUILD_USER) + .prompt()?, + // TODO: do we actually want to be in charge of these credentials vs expecting the local building utility already be logged in? + pass: inquire::Password::new("Container registry 'build' password:") + .with_help_message("The password to the 'build' user account") // TODO: could this support username:pat too? + .with_display_mode(inquire::PasswordDisplayMode::Masked) + .with_custom_confirmation_message("Enter again:") + .prompt()?, + }, + cluster: config::UserPass { + user: inquire::Text::new("Container registry 'cluster' user:") + .with_help_message( + "The username that the cluster will use to pull locally-built containers.", + ) + .with_placeholder(example_values::REGISTRY_CLUSTER_USER) + .prompt()?, + pass: inquire::Password::new("Container registry 'cluster' password:") + .with_help_message("The password to the 'cluster' user account") + .with_display_mode(inquire::PasswordDisplayMode::Masked) + .with_custom_confirmation_message("Enter again:") + .prompt()?, + }, + }, + + points: { + println!("You can define several challenge difficulty classes below."); + let mut again = inquire::Confirm::new("Do you want to provide a difficulty class?") + .with_default(false) + .prompt()?; + // + println!("Challenge points are dynamic. For a static challenge, simply set minimum and maximum points to the same value."); + let mut points = vec![]; + while again { + let points_obj = config::ChallengePoints { + difficulty: inquire::Text::new("Difficulty class:") + .with_validator(inquire::required!("Please provide a name.")) + .with_help_message("The name of the difficulty class.") + .with_placeholder(example_values::POINTS_DIFFICULTY) + .prompt()?, + min: inquire::CustomType::::new("Minimum points:") + .with_error_message("Please type a valid number.") // default parser calls std::u64::from_str + .with_help_message("The minimum number of points that challenges within this difficulty class are worth.") // too long to format + .with_default(example_values::POINTS_MIN) + .prompt()?, + max: inquire::CustomType::::new("Maximum points:") + .with_error_message("Please type a valid number.") // default parser calls std::u64::from_str + .with_help_message("The maximum number of points that challenges within this difficulty class are worth.") // too long to format + .with_default(example_values::POINTS_MAX) + .prompt()?, + }; + points.push(points_obj); + + again = inquire::Confirm::new("Do you want to provide another difficulty class?") + .with_default(false) + .prompt()?; + } + // save owned copy of difficulty category names for use below + difficulty_names = points.iter().map(|p| p.difficulty.clone()).collect_vec(); + points + }, + defaults: config::Defaults { + difficulty: { + if difficulty_names.is_empty() { + String::new() + } else { + inquire::Select::new( + "Please choose the default difficulty class:", + difficulty_names, + ) + .prompt()? + } + }, + + resources: config::Resource { + cpu: inquire::CustomType::::new("Default CPU limit:") + .with_help_message("The default limit of CPU resources per challenge pod.\nhttps://kubernetes.io/docs/concepts/configuration/manage-resources-containers/#resource-units-in-kubernetes") // too long to format + .with_placeholder(&example_values::DEFAULTS_RESOURCES_CPU.to_string()) + .with_default(example_values::DEFAULTS_RESOURCES_CPU) + .prompt()?, + + memory: inquire::Text::new("Default memory limit:") + .with_help_message("The default limit of CPU resources per challenge pod.\nhttps://kubernetes.io/docs/concepts/configuration/manage-resources-containers/#resource-units-in-kubernetes") // too long to format + .with_placeholder(example_values::DEFAULTS_RESOURCES_MEMORY) + .with_default(example_values::DEFAULTS_RESOURCES_MEMORY) + .prompt()?, + }, + }, + + profiles: { + println!("You can define several environment profiles below."); + + let mut again = inquire::Confirm::new("Do you want to provide a Profile?") + .with_default(false) + .prompt()?; + let mut profiles = HashMap::new(); + + while again { + let name = inquire::Text::new("Profile name:") + .with_help_message("The name of the deployment Profile. One Profile named \"default\" is recommended. You can add additional profiles.") // too long to format + .with_placeholder(example_values::PROFILES_PROFILE_NAME) + .prompt()?; + + let prof = config::ProfileConfig { + frontend_url: inquire::Text::new("Frontend URL:") + .with_help_message("The URL of the RNG scoreboard.") + .with_placeholder(example_values::PROFILES_FRONTEND_URL) + .prompt()?, + frontend_token: inquire::Text::new("Frontend token:") + .with_help_message("The token to authenticate into the RNG scoreboard.") + .with_placeholder(example_values::PROFILES_FRONTEND_TOKEN) + .prompt()?, + challenges_domain: inquire::Text::new("Challenges domain:") + .with_help_message("Domain that challenges are hosted under.") + .with_placeholder(example_values::PROFILES_CHALLENGES_DOMAIN) + .prompt()?, + kubecontext: inquire::Text::new("Kubecontext name:") + .with_help_message( + "The name of the context that kubectl uses to connect to the cluster.", + ) + .with_placeholder(example_values::PROFILES_KUBECONTEXT) + .prompt()?, + s3: config::S3Config { + bucket_name: inquire::Text::new("S3 bucket name:") + .with_help_message("Challenge artifacts and static files will be hosted on S3. The name of the S3 bucket.") // too long to format + .with_placeholder(example_values::PROFILES_S3_BUCKET_NAME) + .prompt()?, + endpoint: inquire::Text::new("S3 endpoint:") + .with_help_message("The endpoint of the S3 bucket server.") + .with_placeholder(example_values::PROFILES_S3_ENDPOINT) + .prompt()?, + region: inquire::Text::new("S3 region:") + .with_help_message("The region where the S3 bucket is hosted.") + .with_placeholder(example_values::PROFILES_S3_REGION) + .prompt()?, + access_key: inquire::Text::new("S3 access key:") + .with_help_message("The public access key to the S3 bucket.") + .with_placeholder(example_values::PROFILES_S3_ACCESSKEY) + .prompt()?, + secret_key: inquire::Text::new("S3 secret key:") + .with_help_message("The secret acess key to the S3 bucket.") + .with_placeholder(example_values::PROFILES_S3_SECRETACCESSKEY) + .prompt()?, + }, + kubeconfig: None, + dns: Default::default(), // explicitly leave this blank, user needs to set it + }; + + profiles.insert(name, prof); + + again = inquire::Confirm::new("Do you want to provide another Profile?") + .with_default(false) + .prompt()?; + } + profiles + }, + + deploy: HashMap::new(), // user is init'ing a blank repo, no challenges yet! + }; + + Ok(options) +} + +pub fn blank_init() -> config::RcdsConfig { + trace!("building blank config"); + + // struct does not implement Default on purpose, manually fill out as blank + config::RcdsConfig { + flag_regex: "".to_string(), + registry: config::Registry { + domain: "".to_string(), + tag_format: String::new(), + build: config::UserPass { + user: "".to_string(), + pass: "".to_string(), + }, + cluster: config::UserPass { + user: "".to_string(), + pass: "".to_string(), + }, + }, + defaults: config::Defaults { + difficulty: "".to_string(), + resources: config::Resource { + cpu: 0, + memory: "".to_string(), + }, + }, + points: vec![], + deploy: HashMap::from([]), + profiles: HashMap::from([]), + } +} + +pub fn example_init() -> config::RcdsConfig { + trace!("building example values config"); + + config::RcdsConfig { + flag_regex: example_values::FLAG_REGEX.to_string(), + registry: config::Registry { + domain: example_values::REGISTRY_DOMAIN.to_string(), + tag_format: config::default_tag_format(), + build: config::UserPass { + user: example_values::REGISTRY_BUILD_USER.to_string(), + pass: example_values::REGISTRY_BUILD_PASS.to_string(), + }, + cluster: config::UserPass { + user: example_values::REGISTRY_CLUSTER_USER.to_string(), + pass: example_values::REGISTRY_CLUSTER_PASS.to_string(), + }, + }, + defaults: config::Defaults { + difficulty: example_values::DEFAULTS_DIFFICULTY.to_string(), + resources: config::Resource { + cpu: example_values::DEFAULTS_RESOURCES_CPU, + memory: example_values::DEFAULTS_RESOURCES_MEMORY.to_string(), + }, + }, + points: vec![config::ChallengePoints { + difficulty: example_values::POINTS_DIFFICULTY.to_string(), + min: example_values::POINTS_MIN, + max: example_values::POINTS_MAX, + }], + + deploy: HashMap::from([( + example_values::PROFILES_PROFILE_NAME.to_string(), + config::ProfileDeploy { + challenges: HashMap::new(), + }, + )]), + profiles: HashMap::from([]), + } +} + +pub fn templatize_init(options: &config::RcdsConfig) -> Result { + debug!("rendering template with {options:?}"); + render_strict( + templates::RCDS, + minijinja::context! {.. minijinja::Value::from_serialize(options)}, + ) +} diff --git a/src/init/templates.rs b/src/init/templates.rs new file mode 100644 index 0000000..6ae95d2 --- /dev/null +++ b/src/init/templates.rs @@ -0,0 +1,3 @@ +// Embed template file into binary + +pub static RCDS: &str = include_str!("../asset_files/rcds.yaml.j2"); diff --git a/src/lib.rs b/src/lib.rs index 061b46f..917754c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,6 +9,7 @@ pub mod cluster_setup; pub mod commands; pub mod configparser; pub mod deploy; +pub mod init; pub mod utils; #[cfg(test)] diff --git a/src/main.rs b/src/main.rs index f5c71c5..b0761e6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -89,5 +89,11 @@ fn dispatch(cli: cli::Cli) -> anyhow::Result<()> { commands::validate::run()?; commands::cluster_setup::run(profile) } + + cli::Commands::Init { + interactive, + placeholders, + blank, + } => commands::init::run(interactive, placeholders, blank), } } diff --git a/src/tests/parsing/config.rs b/src/tests/parsing/config.rs index 9d42903..a1d374a 100644 --- a/src/tests/parsing/config.rs +++ b/src/tests/parsing/config.rs @@ -27,11 +27,11 @@ fn all_yaml() { pass: alsofake defaults: - difficulty: 1 + difficulty: "easy" resources: { cpu: 1, memory: 500M } points: - - difficulty: 1 + - difficulty: "easy" min: 0 max: 1337 @@ -58,11 +58,7 @@ fn all_yaml() { "#, )?; - let config = match parse() { - Ok(c) => Ok(c), - // figment::Error cannot coerce from anyhow::Error natively - Err(e) => Err(figment::Error::from(format!("{:?}", e))), - }?; + let config = parse().map_err(|e| figment::Error::from(format!("{:?}", e)))?; let expected = RcdsConfig { flag_regex: "test{[a-zA-Z_]+}".to_string(), @@ -79,14 +75,14 @@ fn all_yaml() { }, }, defaults: Defaults { - difficulty: 1, + difficulty: "easy".to_string(), resources: Resource { cpu: 1, memory: "500M".to_string(), }, }, points: vec![ChallengePoints { - difficulty: 1, + difficulty: "easy".to_string(), min: 0, max: 1337, }], @@ -115,7 +111,7 @@ fn all_yaml() { access_key: "accesskey".to_string(), secret_key: "secretkey".to_string(), }, - dns: serde_yml::to_value(HashMap::from([ + dns: serde_yaml_ng::to_value(HashMap::from([ ("provider", "somebody"), ("thing", "whatever"), ])) @@ -151,11 +147,11 @@ fn registry_tag_format() { pass: alsofake defaults: - difficulty: 1 + difficulty: "easy" resources: { cpu: 1, memory: 500M } points: - - difficulty: 1 + - difficulty: "easy" min: 0 max: 1337 @@ -182,11 +178,7 @@ fn registry_tag_format() { "#, )?; - let config = match parse() { - Ok(c) => Ok(c), - // figment::Error cannot coerce from anyhow::Error natively - Err(e) => Err(figment::Error::from(format!("{:?}", e))), - }?; + let config = parse().map_err(|e| figment::Error::from(format!("{:?}", e)))?; let expected = RcdsConfig { flag_regex: "test{[a-zA-Z_]+}".to_string(), @@ -203,14 +195,14 @@ fn registry_tag_format() { }, }, defaults: Defaults { - difficulty: 1, + difficulty: "easy".to_string(), resources: Resource { cpu: 1, memory: "500M".to_string(), }, }, points: vec![ChallengePoints { - difficulty: 1, + difficulty: "easy".to_string(), min: 0, max: 1337, }], @@ -239,7 +231,7 @@ fn registry_tag_format() { access_key: "accesskey".to_string(), secret_key: "secretkey".to_string(), }, - dns: serde_yml::to_value(HashMap::from([ + dns: serde_yaml_ng::to_value(HashMap::from([ ("provider", "somebody"), ("thing", "whatever"), ])) @@ -274,11 +266,11 @@ fn yaml_with_env_overrides() { pass: alsofake defaults: - difficulty: 1 + difficulty: "easy" resources: { cpu: 1, memory: 500M } points: - - difficulty: 1 + - difficulty: "easy" min: 0 max: 1337 @@ -315,10 +307,7 @@ fn yaml_with_env_overrides() { jail.set_env("BEAVERCDS_PROFILES_TESTING_S3_ACCESS_KEY", "envkey"); jail.set_env("BEAVERCDS_PROFILES_TESTING_S3_SECRET_KEY", "envsecret"); - let config = match parse() { - Err(e) => Err(figment::Error::from(format!("{:?}", e))), - Ok(config) => Ok(config), - }?; + let config = parse().map_err(|e| figment::Error::from(format!("{:?}", e)))?; // also check that the envvar overrides were applied assert_eq!(config.registry.build.user, "envbuilduser"); @@ -350,11 +339,11 @@ fn partial_yaml_with_env() { domain: registry.example/test defaults: - difficulty: 1 + difficulty: "easy" resources: { cpu: 1, memory: 500M } points: - - difficulty: 1 + - difficulty: "easy" min: 0 max: 1337 @@ -388,10 +377,7 @@ fn partial_yaml_with_env() { jail.set_env("BEAVERCDS_PROFILES_TESTING_S3_ACCESS_KEY", "envkey"); jail.set_env("BEAVERCDS_PROFILES_TESTING_S3_SECRET_KEY", "envsecret"); - let config = match parse() { - Err(e) => Err(figment::Error::from(format!("{:?}", e))), - Ok(config) => Ok(config), - }?; + let config = parse().map_err(|e| figment::Error::from(format!("{:?}", e)))?; // also check that the envvar overrides were applied assert_eq!(config.registry.build.user, "envbuilduser"); @@ -449,11 +435,11 @@ fn bad_yaml_missing_secrets() { domain: registry.example/test defaults: - difficulty: 1 + difficulty: "easy" resources: { cpu: 1, memory: 500M } points: - - difficulty: 1 + - difficulty: "easy" min: 0 max: 1337 diff --git a/temp-test/rcds.yaml b/temp-test/rcds.yaml new file mode 100644 index 0000000..99c4e47 --- /dev/null +++ b/temp-test/rcds.yaml @@ -0,0 +1,50 @@ +# Used to check that all challenges' flags are in the correct format, +# and by the scoreboard frontend as a first check for invalid submissions. +flag_regex: "ctf{.*}" + +# Registry configuration for challenge images. +registry: + domain: "ghcr.io/youraccount" + # This is the default tag format; it will create a separate image for each + # challenge pod. Most container registries (Docker, GHCR, Gitlab, Quay, ...) + # are fine with this. If you are using a container registry that requires + # every image within the repository to be created ahead-of-time (AWS ECR) + # before it can be pushed, you can change this to use tags for each separate + # challenge within one image in the registry. + tag_format: "{{domain}}/{{challenge}}-{{container}}:{{profile}}" + # Build-time credentials used to push images during `beavercds deploy`. + build: + user: "admin" + pass: "notrealcreds" + # Used by the cluster to pull the built images. + cluster: + user: "cluster_user" + pass: "alsofake" + +# Default difficulty class and resource requests used for challenges that did +# not set their own. +defaults: + difficulty: "easy" + resources: { cpu: 1, memory: "500M" } + +# The list of different difficulties that challenges can be assigned, and how +# many points challenges of that difficulty class should be worth. All +# challenges use dynamic scoring; for static points set both min and max to the +# same value. +points: + - difficulty: "easy" + min: 200 + max: 500 + +# Control what challenges are deployed in each environment profile. +deploy: + {} + + +# Separate environment profiles to allow for multiple independent deployments +# of challenges, e.g. staging and production to test challenges internally +# before going live for all users. +profiles: + {} + + diff --git a/tests/repo/rcds.yaml b/tests/repo/rcds.yaml index eff3c14..6a2f36c 100644 --- a/tests/repo/rcds.yaml +++ b/tests/repo/rcds.yaml @@ -12,12 +12,12 @@ registry: pass: alsofake defaults: - difficulty: 1 + difficulty: "standard" resources: { cpu: 1, memory: 500M } points: - - difficulty: 1 - min: 0 + - difficulty: "standard" + min: 100 max: 1337 deploy: