Public release release-6756e5208c8b
Source commit: 6756e5208c8b79e83d55610251430bc1baef53a3 Public tree identity: sha256:2f58837c3759b4f466cbc274571ee71bc0d24c7552dc009c186394e99123da87
This commit is contained in:
commit
18cba9c609
210 changed files with 78616 additions and 0 deletions
288
crates/clusterflux-core/src/environment.rs
Normal file
288
crates/clusterflux-core/src/environment.rs
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
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));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue