Skip to content

Commit b8a721b

Browse files
committed
new: added validate command
1 parent 323ac7e commit b8a721b

File tree

8 files changed

+206
-10
lines changed

8 files changed

+206
-10
lines changed

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,19 @@ robopages create --name my_first_page.yml --template docker-image
107107
robopages create --name my_first_page.yml --template docker-build
108108
```
109109

110+
Validate one or more files:
111+
112+
```bash
113+
# validate all pages in ~/.robopages
114+
robopages validate
115+
116+
# validate a specific page
117+
robopages validate --path my_first_page.yml
118+
119+
# do not attempt to pull or build containers
120+
robopages validate --skip-docker
121+
```
122+
110123
Start the REST API:
111124

112125
> [!IMPORTANT]

src/book/mod.rs

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ pub struct Container {
5656
#[serde(default = "default_preserve_app")]
5757
#[serde(skip_serializing_if = "is_false")]
5858
pub preserve_app: bool,
59+
#[serde(skip_serializing_if = "Option::is_none")]
60+
pub platform: Option<String>,
5961
}
6062

6163
fn is_false(b: &bool) -> bool {
@@ -107,6 +109,10 @@ impl Container {
107109

108110
Ok(dockerized)
109111
}
112+
113+
pub async fn resolve(&self) -> anyhow::Result<()> {
114+
self.source.resolve(self.platform.clone()).await
115+
}
110116
}
111117

112118
// TODO: add optional parsers to reduce output tokens
@@ -143,9 +149,12 @@ impl Page {
143149
}
144150

145151
pub fn from_path(path: &Utf8PathBuf) -> anyhow::Result<Self> {
146-
let text = std::fs::read_to_string(path)?;
147-
let text = Self::preprocess(path, text)?;
148-
let page = serde_yaml::from_str(&text)?;
152+
let text = std::fs::read_to_string(path)
153+
.map_err(|e| anyhow::anyhow!("error while reading {:?}: {}", path, e))?;
154+
let text = Self::preprocess(path, text)
155+
.map_err(|e| anyhow::anyhow!("error while preprocessing {:?}: {}", path, e))?;
156+
let page = serde_yaml::from_str(&text)
157+
.map_err(|e| anyhow::anyhow!("error while parsing {:?}: {}", path, e))?;
149158
Ok(page)
150159
}
151160
}
@@ -163,7 +172,9 @@ impl Book {
163172
shellexpand::full(path.as_str())
164173
.map_err(|e| anyhow::anyhow!("failed to expand path: {}", e))?
165174
.into_owned(),
166-
);
175+
)
176+
.canonicalize_utf8()
177+
.map_err(|e| anyhow::anyhow!("failed to canonicalize path: {}", e))?;
167178

168179
if path.is_file() {
169180
eval_if_in_filter!(path, filter, page_paths.push(path.to_path_buf()));
@@ -194,6 +205,7 @@ impl Book {
194205
let mut function_names = HashMap::new();
195206

196207
for page_path in page_paths {
208+
let page_path = page_path.canonicalize_utf8()?;
197209
let mut page = Page::from_path(&page_path)?;
198210

199211
// if name is not set, use the file name
@@ -370,6 +382,7 @@ mod tests {
370382
volumes: None,
371383
force: false,
372384
preserve_app: true,
385+
platform: None,
373386
};
374387

375388
let original_cmdline = CommandLine {

src/cli/mod.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,14 @@ mod create;
77
mod install;
88
mod run;
99
mod serve;
10+
mod validate;
1011
mod view;
1112

1213
pub(crate) use create::*;
1314
pub(crate) use install::*;
1415
pub(crate) use run::*;
1516
pub(crate) use serve::*;
17+
pub(crate) use validate::*;
1618
pub(crate) use view::*;
1719

1820
use crate::book::templates::Template;
@@ -39,6 +41,8 @@ pub(crate) enum Command {
3941
Serve(ServeArgs),
4042
/// Execute a function from the robopages.
4143
Run(RunArgs),
44+
/// Validate a robopage YML file.
45+
Validate(ValidateArgs),
4246
}
4347

4448
#[derive(Debug, Args)]
@@ -106,6 +110,16 @@ pub(crate) struct RunArgs {
106110
auto: bool,
107111
}
108112

113+
#[derive(Debug, Args)]
114+
pub(crate) struct ValidateArgs {
115+
/// Path to the robopage YML file or files to validate.
116+
#[clap(long, short = 'P', default_value = DEFAULT_PATH)]
117+
path: Utf8PathBuf,
118+
/// Do not attempt to pull or build containers.
119+
#[clap(long)]
120+
skip_docker: bool,
121+
}
122+
109123
/// Parse a single key-value pair
110124
fn parse_key_val<T, U>(s: &str) -> Result<(T, U), Box<dyn Error + Send + Sync + 'static>>
111125
where

src/cli/serve.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ pub(crate) async fn serve(args: ServeArgs) -> anyhow::Result<()> {
8282
for (func_name, func) in page.functions.iter() {
8383
if let Some(container) = &func.container {
8484
log::info!("pre building container for function {} ...", func_name);
85-
container.source.resolve().await?;
85+
container.resolve().await?;
8686
}
8787
}
8888
}

src/cli/validate.rs

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
use crate::{book::Book, runtime::CommandLine};
2+
3+
use super::ValidateArgs;
4+
5+
pub(crate) async fn validate(args: ValidateArgs) -> anyhow::Result<()> {
6+
let book = Book::from_path(args.path.clone(), None)?;
7+
8+
// we need at least one page
9+
if book.pages.is_empty() {
10+
return Err(anyhow::anyhow!("no pages found in {:?}", &args.path));
11+
}
12+
13+
for (page_path, page) in book.pages {
14+
log::info!("validating {:?} ...", page_path);
15+
16+
// and at least one function per page, at least what's the point of the page?
17+
if page.functions.is_empty() {
18+
return Err(anyhow::anyhow!("no functions found in {:?}", page_path));
19+
} else if page.name.is_empty() {
20+
// set by Book::from_path if not specified
21+
return Err(anyhow::anyhow!("page name is empty in {:?}", page_path));
22+
} else if page.categories.is_empty() {
23+
// set by Book::from_path if not specified
24+
return Err(anyhow::anyhow!(
25+
"page categories are empty in {:?}",
26+
page_path
27+
));
28+
}
29+
30+
for (func_name, func) in page.functions {
31+
// the model needs at least a name and a description
32+
if func_name.is_empty() {
33+
return Err(anyhow::anyhow!("function name is empty in {:?}", page_path));
34+
} else if func.description.is_empty() {
35+
return Err(anyhow::anyhow!(
36+
"function description is empty in {:?}",
37+
page_path
38+
));
39+
}
40+
41+
if func.parameters.is_empty() {
42+
return Err(anyhow::anyhow!(
43+
"function {} parameters are empty in {:?}",
44+
func_name,
45+
page_path
46+
));
47+
}
48+
49+
// make sure the function resolves to a valid command line
50+
let cmdline = func.execution.get_command_line().map_err(|e| {
51+
anyhow::anyhow!(
52+
"error while getting command line for function {}: {}",
53+
func_name,
54+
e
55+
)
56+
})?;
57+
58+
if cmdline.is_empty() {
59+
return Err(anyhow::anyhow!(
60+
"command line is empty for function {} in {:?}",
61+
func_name,
62+
page_path
63+
));
64+
}
65+
66+
let cmdline = CommandLine::from_vec(&cmdline).map_err(|e| {
67+
anyhow::anyhow!(
68+
"error while parsing command line for function {}: {}",
69+
func_name,
70+
e
71+
)
72+
})?;
73+
74+
// validate container requirements - a container is required if:
75+
let container = if !cmdline.app_in_path {
76+
// the binary is not in $PATH
77+
if let Some(container) = &func.container {
78+
Some(container)
79+
} else {
80+
return Err(anyhow::anyhow!(
81+
"binary for function {} in {:?} not in $PATH and container not specified",
82+
func_name,
83+
page_path
84+
));
85+
}
86+
} else if func.container.is_some() && func.container.as_ref().unwrap().force {
87+
// it's set and forced
88+
Some(func.container.as_ref().unwrap())
89+
} else {
90+
None
91+
};
92+
93+
// validate the container if any
94+
if let Some(container) = container {
95+
if args.skip_docker {
96+
// or not :P
97+
log::warn!("skipping container resolution for function {}", func_name);
98+
} else {
99+
// this will pull or build the image
100+
container.resolve().await.map_err(|e| {
101+
anyhow::anyhow!(
102+
"error while resolving container for function {} in {}: {}",
103+
func_name,
104+
page_path,
105+
e
106+
)
107+
})?;
108+
109+
// if volumes are defined make sure they exist
110+
if let Some(volumes) = &container.volumes {
111+
for volume in volumes {
112+
let (on_host, on_guest) =
113+
volume.split_once(':').unwrap_or((volume, volume));
114+
115+
let on_host = shellexpand::full(on_host)
116+
.map_err(|e| {
117+
anyhow::anyhow!(
118+
"error while expanding volume path for function {}: {}",
119+
func_name,
120+
e
121+
)
122+
})?
123+
.to_string();
124+
125+
if !std::path::Path::new(&on_host).exists() {
126+
return Err(anyhow::anyhow!(
127+
"page {}, function {}, path {} for volume '{}' does not exist",
128+
page_path,
129+
func_name,
130+
on_host,
131+
on_guest
132+
));
133+
}
134+
}
135+
}
136+
}
137+
}
138+
139+
log::info!(" {} - ok", func_name);
140+
log::debug!(" cmdline = {:?}", cmdline);
141+
if let Some(container) = container {
142+
log::debug!(" container = {:?}", container);
143+
}
144+
}
145+
}
146+
147+
Ok(())
148+
}

src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ async fn main() -> anyhow::Result<()> {
2929
cli::Command::View(args) => cli::view(args).await,
3030
cli::Command::Serve(args) => cli::serve(args).await,
3131
cli::Command::Run(args) => cli::run(args).await,
32+
cli::Command::Validate(args) => cli::validate(args).await,
3233
};
3334

3435
if let Err(e) = result {

src/runtime/docker.rs

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,9 @@ pub enum ContainerSource {
1919
}
2020

2121
impl ContainerSource {
22-
pub async fn resolve(&self) -> anyhow::Result<()> {
22+
pub async fn resolve(&self, platform: Option<String>) -> anyhow::Result<()> {
2323
match self {
24-
Self::Image(image) => pull_image(image).await,
24+
Self::Image(image) => pull_image(image, platform).await,
2525
Self::Build { name, path } => build_image(name, path).await,
2626
}
2727
}
@@ -72,12 +72,19 @@ async fn run_command(command: &str, args: &[&str]) -> anyhow::Result<()> {
7272
}
7373
}
7474

75-
pub(crate) async fn pull_image(image: &str) -> anyhow::Result<()> {
75+
pub(crate) async fn pull_image(image: &str, platform: Option<String>) -> anyhow::Result<()> {
7676
run_command(
7777
"sh",
7878
&[
7979
"-c",
80-
&format!("docker images -q '{image}' | grep -q . || docker pull '{image}'"),
80+
&format!(
81+
"docker images -q '{image}' | grep -q . || docker pull {}'{image}'",
82+
if let Some(platform) = platform {
83+
format!("--platform '{}' ", platform)
84+
} else {
85+
"".to_string()
86+
}
87+
),
8188
],
8289
)
8390
.await

src/runtime/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ pub(crate) async fn execute_call(
102102
log::debug!("using container: {:?}", container);
103103

104104
// build or pull the image if needed
105-
container.source.resolve().await?;
105+
container.resolve().await?;
106106

107107
// wrap the command line
108108
container.wrap(command_line)?

0 commit comments

Comments
 (0)