Source commit: 6756e5208c8b79e83d55610251430bc1baef53a3 Public tree identity: sha256:2f58837c3759b4f466cbc274571ee71bc0d24c7552dc009c186394e99123da87
288 lines
8.2 KiB
Rust
288 lines
8.2 KiB
Rust
use std::collections::BTreeSet;
|
|
use std::fs;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
use thiserror::Error;
|
|
|
|
use crate::{Capability, Digest, Os};
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
|
pub enum EnvironmentKind {
|
|
Containerfile,
|
|
Dockerfile,
|
|
NixFlake,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct EnvironmentRequirements {
|
|
pub os: Option<Os>,
|
|
pub arch: Option<String>,
|
|
pub capabilities: BTreeSet<Capability>,
|
|
}
|
|
|
|
impl EnvironmentRequirements {
|
|
pub fn linux_container() -> Self {
|
|
Self {
|
|
os: Some(Os::Linux),
|
|
arch: None,
|
|
capabilities: BTreeSet::from([Capability::Containers, Capability::RootlessPodman]),
|
|
}
|
|
}
|
|
|
|
pub fn windows_command_dev() -> Self {
|
|
Self {
|
|
os: Some(Os::Windows),
|
|
arch: None,
|
|
capabilities: BTreeSet::from([Capability::WindowsCommandDev]),
|
|
}
|
|
}
|
|
|
|
pub fn unconstrained() -> Self {
|
|
Self {
|
|
os: None,
|
|
arch: None,
|
|
capabilities: BTreeSet::new(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct EnvironmentResource {
|
|
pub name: String,
|
|
pub kind: EnvironmentKind,
|
|
pub recipe_path: PathBuf,
|
|
pub context_path: PathBuf,
|
|
pub digest: Digest,
|
|
pub requirements: EnvironmentRequirements,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct EnvironmentReference {
|
|
pub name: String,
|
|
pub byte_offset: usize,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct EnvironmentDiagnostic {
|
|
pub reference: EnvironmentReference,
|
|
pub message: String,
|
|
}
|
|
|
|
#[derive(Debug, Error)]
|
|
pub enum EnvironmentError {
|
|
#[error("failed to read environment resources under {path}: {source}")]
|
|
Read {
|
|
path: PathBuf,
|
|
#[source]
|
|
source: std::io::Error,
|
|
},
|
|
}
|
|
|
|
pub fn discover_environments(
|
|
project_root: &Path,
|
|
) -> Result<Vec<EnvironmentResource>, EnvironmentError> {
|
|
let envs_dir = project_root.join("envs");
|
|
if !envs_dir.exists() {
|
|
return Ok(Vec::new());
|
|
}
|
|
|
|
let mut resources = Vec::new();
|
|
let entries = fs::read_dir(&envs_dir).map_err(|source| EnvironmentError::Read {
|
|
path: envs_dir.clone(),
|
|
source,
|
|
})?;
|
|
|
|
for entry in entries {
|
|
let entry = entry.map_err(|source| EnvironmentError::Read {
|
|
path: envs_dir.clone(),
|
|
source,
|
|
})?;
|
|
let path = entry.path();
|
|
if !path.is_dir() {
|
|
continue;
|
|
}
|
|
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
|
|
continue;
|
|
};
|
|
|
|
if let Some(resource) = discover_one(project_root, name, &path)? {
|
|
resources.push(resource);
|
|
}
|
|
}
|
|
|
|
resources.sort_by(|left, right| left.name.cmp(&right.name));
|
|
Ok(resources)
|
|
}
|
|
|
|
pub fn diagnose_environment_references(
|
|
source: &str,
|
|
environments: &[EnvironmentResource],
|
|
) -> Vec<EnvironmentDiagnostic> {
|
|
let known = environments
|
|
.iter()
|
|
.map(|environment| environment.name.as_str())
|
|
.collect::<BTreeSet<_>>();
|
|
|
|
find_env_macro_references(source)
|
|
.into_iter()
|
|
.filter(|reference| !known.contains(reference.name.as_str()))
|
|
.map(|reference| EnvironmentDiagnostic {
|
|
message: format!(
|
|
"missing Clusterflux environment `{}`; expected envs/{}/Containerfile or envs/{}/Dockerfile",
|
|
reference.name, reference.name, reference.name
|
|
),
|
|
reference,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn discover_one(
|
|
project_root: &Path,
|
|
name: &str,
|
|
env_dir: &Path,
|
|
) -> Result<Option<EnvironmentResource>, EnvironmentError> {
|
|
let candidates = [
|
|
("Containerfile", EnvironmentKind::Containerfile),
|
|
("Dockerfile", EnvironmentKind::Dockerfile),
|
|
("flake.nix", EnvironmentKind::NixFlake),
|
|
];
|
|
|
|
for (file_name, kind) in candidates {
|
|
let recipe_path = env_dir.join(file_name);
|
|
if !recipe_path.exists() {
|
|
continue;
|
|
}
|
|
|
|
let recipe_bytes = fs::read(&recipe_path).map_err(|source| EnvironmentError::Read {
|
|
path: recipe_path.clone(),
|
|
source,
|
|
})?;
|
|
let relative_recipe = recipe_path
|
|
.strip_prefix(project_root)
|
|
.unwrap_or(&recipe_path)
|
|
.to_string_lossy();
|
|
let digest = Digest::from_parts([
|
|
b"environment:v1".as_slice(),
|
|
name.as_bytes(),
|
|
format!("{kind:?}").as_bytes(),
|
|
relative_recipe.as_bytes(),
|
|
recipe_bytes.as_slice(),
|
|
]);
|
|
let requirements = match kind {
|
|
EnvironmentKind::Containerfile | EnvironmentKind::Dockerfile
|
|
if name.eq_ignore_ascii_case("windows") =>
|
|
{
|
|
EnvironmentRequirements::windows_command_dev()
|
|
}
|
|
EnvironmentKind::Containerfile | EnvironmentKind::Dockerfile => {
|
|
EnvironmentRequirements::linux_container()
|
|
}
|
|
EnvironmentKind::NixFlake => EnvironmentRequirements::unconstrained(),
|
|
};
|
|
|
|
return Ok(Some(EnvironmentResource {
|
|
name: name.to_owned(),
|
|
kind,
|
|
recipe_path,
|
|
context_path: env_dir.to_path_buf(),
|
|
digest,
|
|
requirements,
|
|
}));
|
|
}
|
|
|
|
Ok(None)
|
|
}
|
|
|
|
fn find_env_macro_references(source: &str) -> Vec<EnvironmentReference> {
|
|
let mut references = Vec::new();
|
|
let mut cursor = 0;
|
|
|
|
while let Some(index) = source[cursor..].find("env!(") {
|
|
let start = cursor + index;
|
|
let mut pos = start + "env!(".len();
|
|
while source[pos..].starts_with(char::is_whitespace) {
|
|
pos += source[pos..]
|
|
.chars()
|
|
.next()
|
|
.map(char::len_utf8)
|
|
.unwrap_or(1);
|
|
}
|
|
if !source[pos..].starts_with('"') {
|
|
cursor = pos;
|
|
continue;
|
|
}
|
|
pos += 1;
|
|
let name_start = pos;
|
|
while pos < source.len() && !source[pos..].starts_with('"') {
|
|
pos += source[pos..]
|
|
.chars()
|
|
.next()
|
|
.map(char::len_utf8)
|
|
.unwrap_or(1);
|
|
}
|
|
if pos < source.len() {
|
|
references.push(EnvironmentReference {
|
|
name: source[name_start..pos].to_owned(),
|
|
byte_offset: start,
|
|
});
|
|
}
|
|
cursor = pos.saturating_add(1);
|
|
}
|
|
|
|
references
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::fs;
|
|
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn discovers_containerfile_environments_by_logical_name() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let linux = temp.path().join("envs/linux");
|
|
fs::create_dir_all(&linux).unwrap();
|
|
fs::write(linux.join("Containerfile"), "FROM alpine\n").unwrap();
|
|
|
|
let envs = discover_environments(temp.path()).unwrap();
|
|
|
|
assert_eq!(envs.len(), 1);
|
|
assert_eq!(envs[0].name, "linux");
|
|
assert_eq!(envs[0].kind, EnvironmentKind::Containerfile);
|
|
assert!(!envs[0].digest.as_str().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn missing_env_macro_reference_reports_clear_diagnostic() {
|
|
let source = r#"fn main() { let _ = env!("windows"); }"#;
|
|
let diagnostics = diagnose_environment_references(source, &[]);
|
|
|
|
assert_eq!(diagnostics.len(), 1);
|
|
assert!(diagnostics[0]
|
|
.message
|
|
.contains("envs/windows/Containerfile"));
|
|
}
|
|
|
|
#[test]
|
|
fn windows_environment_name_uses_windows_development_requirements() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let windows = temp.path().join("envs/windows");
|
|
fs::create_dir_all(&windows).unwrap();
|
|
fs::write(
|
|
windows.join("Dockerfile"),
|
|
"# user-attached windows dev contract\n",
|
|
)
|
|
.unwrap();
|
|
|
|
let envs = discover_environments(temp.path()).unwrap();
|
|
|
|
assert_eq!(envs[0].name, "windows");
|
|
assert_eq!(envs[0].requirements.os, Some(Os::Windows));
|
|
assert!(envs[0]
|
|
.requirements
|
|
.capabilities
|
|
.contains(&Capability::WindowsCommandDev));
|
|
}
|
|
}
|