meta: initial rewrite (final)
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
/target
|
||||
/build
|
||||
Generated
+1813
File diff suppressed because it is too large
Load Diff
+14
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "builder"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
allocative = "0.3.4"
|
||||
anyhow = "1.0.102"
|
||||
clap = { version = "4.6.1", features = ["derive"] }
|
||||
petgraph = "0.8.3"
|
||||
smallvec = "1.15.1"
|
||||
starlark = "0.13.0"
|
||||
starlark_derive = "0.13.0"
|
||||
thiserror = "2.0.18"
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
arch = "x86_64"
|
||||
libc = "glibc"
|
||||
|
||||
if libc == "glibc":
|
||||
env = "gnu"
|
||||
elif libc == "musl":
|
||||
env = "musl"
|
||||
else:
|
||||
fail(f"Unknown libc: {libc}")
|
||||
|
||||
prefix = path("/usr")
|
||||
|
||||
host_cflags = ["-O2", "-pipe"]
|
||||
host_cxxflags = host_cflags + []
|
||||
host_ldflags = ["-Wl,-O1", "-Wl,--sort-common", "-Wl,--as-needed"]
|
||||
|
||||
target_cflags = host_cflags + []
|
||||
target_cxxflags = host_cxxflags + []
|
||||
target_ldflags = host_ldflags + ["-Wl,-z,now"]
|
||||
|
||||
if arch == "x86_64":
|
||||
flags = [
|
||||
"-march=x86-64-v3",
|
||||
"-mtune=generic",
|
||||
"-fstack-clash-protection",
|
||||
"-fstack-protector-strong",
|
||||
"-fcf-protection",
|
||||
]
|
||||
|
||||
target_cflags += flags
|
||||
target_cxxflags += flags
|
||||
target_ldflags += ["-Wl,-z,pack-relative-relocs"]
|
||||
|
||||
config(
|
||||
arch = arch,
|
||||
recipes_dir = path("./recipes"),
|
||||
host_recipes_dir = path("./host-recipes"),
|
||||
container = podman(
|
||||
image = "local/builder:latest",
|
||||
dockerfile = path("./Dockerfile"),
|
||||
),
|
||||
|
||||
target_arch = arch,
|
||||
target_triple = f"{arch}-orchid-linux-{env}",
|
||||
|
||||
libc = libc,
|
||||
|
||||
prefix = prefix,
|
||||
bindir = prefix / "bin",
|
||||
sbindir = prefix / "bin",
|
||||
libdir = prefix / "lib",
|
||||
libexecdir = prefix / "libexec",
|
||||
includedir = prefix / "include",
|
||||
sysconfdir = path("/etc"),
|
||||
localstatedir = path("/var"),
|
||||
|
||||
host_cflags = " ".join(host_cflags),
|
||||
host_cxxflags = " ".join(host_cxxflags),
|
||||
host_ldflags = " ".join(host_ldflags),
|
||||
|
||||
cflags = " ".join(target_cflags),
|
||||
cxxflags = " ".join(target_cxxflags),
|
||||
ldflags = " ".join(target_ldflags),
|
||||
)
|
||||
@@ -0,0 +1,39 @@
|
||||
version = "2.46.0"
|
||||
revision = 1
|
||||
metadata = meta(
|
||||
description = "GNU binutils cross-compiled for the target triple",
|
||||
license = "GPL-3.0-or-later",
|
||||
)
|
||||
source = tarball(
|
||||
url = f"https://ftp.gnu.org/gnu/binutils/binutils-{version}.tar.xz",
|
||||
sha256 = "d75a94f4d73e7a4086f7513e67e439e8fcdcbb726ffe63f4661744e6256b2cf2",
|
||||
strip_components = 1,
|
||||
)
|
||||
|
||||
def configure(ctx):
|
||||
ctx.run(
|
||||
ctx.source_dir / "configure",
|
||||
"--prefix=" + cfg.prefix,
|
||||
"--target=" + cfg.target_triple,
|
||||
"--with-sysroot=" + ctx.sysroot_dir,
|
||||
"--with-pic",
|
||||
"--enable-cet",
|
||||
"--enable-default-execstack=no",
|
||||
"--enable-deterministic-archives",
|
||||
"--enable-ld=default",
|
||||
"--enable-new-dtags",
|
||||
"--enable-plugins",
|
||||
"--enable-relro",
|
||||
"--enable-separate-code",
|
||||
"--enable-threads",
|
||||
"--disable-nls",
|
||||
"--disable-werror",
|
||||
# gprofng's libcollector relies on glibc-specific internals.
|
||||
"--disable-gprofng",
|
||||
env = {
|
||||
"CFLAGS": cfg.host_cflags,
|
||||
"CXXFLAGS": cfg.host_cxxflags,
|
||||
"LDFLAGS": cfg.host_ldflags,
|
||||
})
|
||||
|
||||
_, build, install = autotools()
|
||||
@@ -0,0 +1,20 @@
|
||||
version = "7.0.9"
|
||||
revision = 1
|
||||
metadata = meta(
|
||||
description = "Linux kernel headers for userspace development",
|
||||
license = "GPL-2.0-only",
|
||||
)
|
||||
source = tarball(
|
||||
url = f"https://cdn.kernel.org/pub/linux/kernel/v7.x/linux-{version}.tar.xz",
|
||||
sha256 = "ac07acdf76cf4621cc5187a2670270a1a699533c8a6b225e4878c416ad83f1c4",
|
||||
strip_components = 1,
|
||||
)
|
||||
|
||||
def build(ctx):
|
||||
ctx.run("cp", "-rp", ctx.source_dir / ".", ctx.build_dir)
|
||||
ctx.run("make", "headers_install", "ARCH=" + cfg.target_arch)
|
||||
ctx.run("find", ctx.build_dir / "usr" / "include", "-type", "f", "!", "-name", "*.h", "-delete")
|
||||
|
||||
def install(ctx):
|
||||
ctx.run("mkdir", "-p", ctx.dest_dir / options.prefix)
|
||||
ctx.run("cp", "-rp", ctx.build_dir / "usr" / "include", ctx.dest_dir / options.prefix)
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
use crate::{
|
||||
container::{ContainerManager, PodmanRuntime},
|
||||
eval::{Config, ContainerConfig, config_globals, eval_files, types_globals},
|
||||
recipe::RecipeSet,
|
||||
};
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
use starlark::environment::GlobalsBuilder;
|
||||
use std::{cell::Cell, path::PathBuf, sync::Arc};
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
struct Cli {
|
||||
#[arg(
|
||||
long,
|
||||
short = 'C',
|
||||
default_value = ".",
|
||||
help = "Directory containing the configuration and recipe files"
|
||||
)]
|
||||
root: PathBuf,
|
||||
#[command(subcommand)]
|
||||
command: Command,
|
||||
}
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(about = "Fetch sources for the given recipes")]
|
||||
struct FetchCommand {
|
||||
#[arg(
|
||||
required = true,
|
||||
help = "List of recipes to fetch, host recipes should be prefixed with `host:`"
|
||||
)]
|
||||
recipes: Vec<String>,
|
||||
#[arg(long, short = 'n', help = "Print what will be done and exit")]
|
||||
dry_run: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(about = "Build the given recipes")]
|
||||
struct BuildCommand {
|
||||
#[arg(
|
||||
required = true,
|
||||
help = "List of recipes to build, host recipes should be prefixed with `host:`"
|
||||
)]
|
||||
recipes: Vec<String>,
|
||||
#[arg(long, short, help = "Perform a full rebuild of the given recipes")]
|
||||
rebuild: bool,
|
||||
#[arg(long, short = 'n', help = "Print what will be done and exit")]
|
||||
dry_run: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
enum Command {
|
||||
Fetch(FetchCommand),
|
||||
Build(BuildCommand),
|
||||
}
|
||||
|
||||
pub fn run() -> anyhow::Result<()> {
|
||||
let cli = Cli::parse();
|
||||
let root_path = cli.root.canonicalize().unwrap_or(cli.root);
|
||||
|
||||
let config: Config = {
|
||||
let cell = Cell::new(None);
|
||||
let config_path = root_path.join("config.star");
|
||||
|
||||
eval_files(
|
||||
&[&config_path],
|
||||
&GlobalsBuilder::standard()
|
||||
.with(types_globals)
|
||||
.with(config_globals)
|
||||
.build(),
|
||||
None,
|
||||
None,
|
||||
Some(&cell),
|
||||
)?;
|
||||
|
||||
cell.take()
|
||||
.ok_or_else(|| anyhow::anyhow!("`config` was not called"))?
|
||||
};
|
||||
|
||||
let container_runtime = match config.container() {
|
||||
ContainerConfig::Podman(_) => Arc::new(PodmanRuntime::new()?),
|
||||
};
|
||||
|
||||
let mut container_manager = ContainerManager::new(container_runtime);
|
||||
let mut recipes = RecipeSet::default();
|
||||
|
||||
recipes.load_recipes(
|
||||
&root_path.join(config.recipes_dir()),
|
||||
&root_path.join(config.host_recipes_dir()),
|
||||
)?;
|
||||
|
||||
println!("{:?}", recipes);
|
||||
|
||||
match cli.command {
|
||||
Command::Fetch(_) => {}
|
||||
Command::Build(_) => {}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
use std::{collections::HashMap, path::Path, sync::Arc};
|
||||
|
||||
mod podman;
|
||||
|
||||
pub use podman::PodmanRuntime;
|
||||
|
||||
pub struct Container {
|
||||
id: String,
|
||||
runtime: Arc<dyn ContainerRuntime>,
|
||||
}
|
||||
|
||||
impl Container {
|
||||
fn new(id: String, runtime: Arc<dyn ContainerRuntime>) -> Self {
|
||||
Self { id, runtime }
|
||||
}
|
||||
|
||||
pub fn id(&self) -> &str {
|
||||
&self.id
|
||||
}
|
||||
|
||||
pub fn exec<'a, 'e, 'c>(
|
||||
&self,
|
||||
argv: impl Into<Vec<&'a str>>,
|
||||
env: impl Into<Vec<(&'e str, &'e str)>>,
|
||||
cwd: impl Into<&'c Path>,
|
||||
) -> anyhow::Result<()> {
|
||||
self.runtime
|
||||
.exec(self.id(), argv.into(), env.into(), cwd.into())
|
||||
}
|
||||
}
|
||||
|
||||
pub trait ContainerRuntime {
|
||||
/// Starts a new container, returns the ID.
|
||||
fn start_container(
|
||||
&self,
|
||||
image_name: &str,
|
||||
mounts: &[(&Path, &str, bool)],
|
||||
) -> anyhow::Result<String>;
|
||||
|
||||
/// Stops a container.
|
||||
fn stop_container(&self, container_id: &str);
|
||||
|
||||
/// Executes a command in a container.
|
||||
fn exec(
|
||||
&self,
|
||||
container_id: &str,
|
||||
argv: Vec<&str>,
|
||||
env: Vec<(&str, &str)>,
|
||||
cwd: &Path,
|
||||
) -> anyhow::Result<()>;
|
||||
}
|
||||
|
||||
pub struct ContainerManager {
|
||||
containers: HashMap<String, Container>,
|
||||
runtime: Arc<dyn ContainerRuntime>,
|
||||
}
|
||||
|
||||
impl ContainerManager {
|
||||
pub fn new(runtime: Arc<dyn ContainerRuntime>) -> Self {
|
||||
Self {
|
||||
containers: HashMap::new(),
|
||||
runtime,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn container(&mut self, name: &str) -> anyhow::Result<&Container> {
|
||||
if self.containers.get(name).is_none() {
|
||||
let container_id = self.runtime.start_container("alpine:edge", &[])?;
|
||||
|
||||
crate::log!("info", "Started new container ({container_id})");
|
||||
|
||||
self.containers.insert(
|
||||
name.into(),
|
||||
Container::new(container_id, self.runtime.clone()),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(self.containers.get(name).unwrap())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ContainerManager {
|
||||
fn drop(&mut self) {
|
||||
for (_, container) in self.containers.iter() {
|
||||
self.runtime.stop_container(container.id());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
use crate::container::ContainerRuntime;
|
||||
|
||||
use anyhow::Context;
|
||||
use std::{
|
||||
path::Path,
|
||||
process::{Command, Stdio},
|
||||
};
|
||||
|
||||
pub struct PodmanRuntime;
|
||||
|
||||
impl PodmanRuntime {
|
||||
pub fn new() -> anyhow::Result<Self> {
|
||||
let output = Command::new("podman").arg("--version").output()?;
|
||||
|
||||
if output.status.success() {
|
||||
Ok(Self)
|
||||
} else {
|
||||
anyhow::bail!(
|
||||
"Could not execute `podman --version` - make sure you have podman installed."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ContainerRuntime for PodmanRuntime {
|
||||
fn start_container(
|
||||
&self,
|
||||
image_name: &str,
|
||||
mounts: &[(&Path, &str, bool)],
|
||||
) -> anyhow::Result<String> {
|
||||
let mut cmd = Command::new("podman");
|
||||
|
||||
cmd.arg("run");
|
||||
cmd.arg("--detach");
|
||||
cmd.arg("--read-only");
|
||||
cmd.arg("--network=none");
|
||||
cmd.arg("--userns=keep-id");
|
||||
cmd.arg("--tmpfs").arg("/builder/dest");
|
||||
cmd.arg("--tmpfs").arg("/builder/sysroot");
|
||||
|
||||
for &(host, container, read_only) in mounts {
|
||||
cmd.arg("--volume").arg(format!(
|
||||
"{}:{}{}",
|
||||
host.display(),
|
||||
container,
|
||||
if read_only { ":ro" } else { "" }
|
||||
));
|
||||
}
|
||||
|
||||
cmd.arg(image_name);
|
||||
cmd.arg("sleep").arg("infinity");
|
||||
|
||||
let output = cmd.output()?;
|
||||
|
||||
if output.status.success() {
|
||||
Ok(String::from_utf8(output.stdout)
|
||||
.context("container ID is not valid UTF-8")?
|
||||
.trim()
|
||||
.into())
|
||||
} else {
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
|
||||
fn stop_container(&self, container_id: &str) {
|
||||
Command::new("podman")
|
||||
.arg("kill")
|
||||
.arg(container_id)
|
||||
.output()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn exec(
|
||||
&self,
|
||||
container_id: &str,
|
||||
argv: Vec<&str>,
|
||||
env: Vec<(&str, &str)>,
|
||||
cwd: &Path,
|
||||
) -> anyhow::Result<()> {
|
||||
let mut cmd = Command::new("podman");
|
||||
|
||||
cmd.arg("exec");
|
||||
cmd.arg("--workdir").arg(cwd);
|
||||
|
||||
for (key, value) in env {
|
||||
cmd.arg("--env").arg(format!("{key}={value}"));
|
||||
}
|
||||
|
||||
cmd.arg(container_id);
|
||||
cmd.args(argv);
|
||||
cmd.stdout(Stdio::inherit()).stderr(Stdio::inherit());
|
||||
|
||||
let output = cmd.output()?;
|
||||
|
||||
if output.status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
anyhow::bail!("Failed to execute command");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
use crate::eval::Path;
|
||||
|
||||
use allocative::Allocative;
|
||||
use starlark::{
|
||||
collections::SmallMap,
|
||||
environment::GlobalsBuilder,
|
||||
eval::Evaluator,
|
||||
starlark_module, starlark_simple_value,
|
||||
values::{StarlarkValue, Value, ValueLike, none::NoneType},
|
||||
};
|
||||
use starlark_derive::{NoSerialize, ProvidesStaticType, starlark_value};
|
||||
use std::{
|
||||
cell::Cell,
|
||||
collections::HashMap,
|
||||
path::{Path as StdPath, PathBuf},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Allocative, NoSerialize, ProvidesStaticType)]
|
||||
pub struct PodmanConfig {
|
||||
image: String,
|
||||
dockerfile: PathBuf,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for PodmanConfig {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "podman_config")
|
||||
}
|
||||
}
|
||||
|
||||
starlark_simple_value!(PodmanConfig);
|
||||
|
||||
#[starlark_value(type = "podman_config")]
|
||||
impl<'v> StarlarkValue<'v> for PodmanConfig {}
|
||||
|
||||
#[derive(Debug, Clone, Allocative, NoSerialize, ProvidesStaticType)]
|
||||
pub enum ContainerConfig {
|
||||
Podman(PodmanConfig),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ContainerConfig {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "container_config")
|
||||
}
|
||||
}
|
||||
|
||||
starlark_simple_value!(ContainerConfig);
|
||||
|
||||
#[starlark_value(type = "container_config")]
|
||||
impl<'v> StarlarkValue<'v> for ContainerConfig {}
|
||||
|
||||
#[derive(Debug, Clone, Allocative, ProvidesStaticType)]
|
||||
pub enum ConfigValue {
|
||||
String(String),
|
||||
Integer(i32),
|
||||
Bool(bool),
|
||||
Path(Path),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Allocative, NoSerialize, ProvidesStaticType)]
|
||||
pub struct Config {
|
||||
arch: String,
|
||||
container: ContainerConfig,
|
||||
recipes_dir: Path,
|
||||
host_recipes_dir: Path,
|
||||
|
||||
options: HashMap<String, ConfigValue>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn arch(&self) -> &str {
|
||||
&self.arch
|
||||
}
|
||||
|
||||
pub fn container(&self) -> &ContainerConfig {
|
||||
&self.container
|
||||
}
|
||||
|
||||
pub fn recipes_dir(&self) -> &StdPath {
|
||||
&self.recipes_dir.path()
|
||||
}
|
||||
|
||||
pub fn host_recipes_dir(&self) -> &StdPath {
|
||||
&self.host_recipes_dir.path()
|
||||
}
|
||||
|
||||
pub fn options(&self) -> &HashMap<String, ConfigValue> {
|
||||
&self.options
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Config {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "container_config")
|
||||
}
|
||||
}
|
||||
|
||||
starlark_simple_value!(Config);
|
||||
|
||||
#[starlark_value(type = "config")]
|
||||
impl<'v> StarlarkValue<'v> for Config {}
|
||||
|
||||
#[starlark_module]
|
||||
pub fn config_globals(b: &mut GlobalsBuilder) {
|
||||
fn podman(
|
||||
#[starlark(require = named)] image: &str,
|
||||
#[starlark(require = named)] dockerfile: &Path,
|
||||
) -> anyhow::Result<ContainerConfig> {
|
||||
Ok(ContainerConfig::Podman(PodmanConfig {
|
||||
image: image.to_string(),
|
||||
dockerfile: dockerfile.path().to_owned(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn config(
|
||||
#[starlark(require = named)] arch: &str,
|
||||
#[starlark(require = named)] container: &ContainerConfig,
|
||||
#[starlark(require = named)] recipes_dir: &Path,
|
||||
#[starlark(require = named)] host_recipes_dir: &Path,
|
||||
#[starlark(kwargs)] kwargs: SmallMap<&str, Value>,
|
||||
eval: &mut Evaluator,
|
||||
) -> anyhow::Result<NoneType> {
|
||||
let config = eval
|
||||
.extra
|
||||
.and_then(|extra| extra.downcast_ref::<Cell<Option<Config>>>())
|
||||
.ok_or_else(|| anyhow::anyhow!("`config` called outside of config.star"))?;
|
||||
|
||||
config.set(Some(Config {
|
||||
arch: arch.to_string(),
|
||||
container: container.clone(),
|
||||
recipes_dir: recipes_dir.clone(),
|
||||
host_recipes_dir: host_recipes_dir.clone(),
|
||||
options: kwargs
|
||||
.iter()
|
||||
.map(|(&k, v)| {
|
||||
let value = if let Some(str) = v.unpack_str() {
|
||||
ConfigValue::String(str.to_string())
|
||||
} else if let Some(num) = v.unpack_i32() {
|
||||
ConfigValue::Integer(num)
|
||||
} else if let Some(bool) = v.unpack_bool() {
|
||||
ConfigValue::Bool(bool)
|
||||
} else if let Some(path) = v.downcast_ref::<Path>() {
|
||||
ConfigValue::Path(path.clone())
|
||||
} else {
|
||||
anyhow::bail!("config option must be a `string`, `int`, `bool` or `path`");
|
||||
};
|
||||
|
||||
Ok((k.to_string(), value))
|
||||
})
|
||||
.collect::<Result<_, _>>()?,
|
||||
}));
|
||||
|
||||
Ok(NoneType)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
use anyhow::Context;
|
||||
use starlark::{
|
||||
any::AnyLifetime,
|
||||
environment::{FrozenModule, Globals, Module},
|
||||
eval::Evaluator,
|
||||
syntax::{AstModule, Dialect, DialectTypes},
|
||||
};
|
||||
use std::path::Path as StdPath;
|
||||
|
||||
mod config;
|
||||
mod types;
|
||||
|
||||
#[allow(unused_imports)]
|
||||
pub use config::*;
|
||||
#[allow(unused_imports)]
|
||||
pub use types::*;
|
||||
|
||||
pub fn eval_files(
|
||||
path: &[&StdPath],
|
||||
globals: &Globals,
|
||||
lib_module: Option<&FrozenModule>,
|
||||
config: Option<&Config>,
|
||||
extra: Option<&dyn AnyLifetime>,
|
||||
) -> anyhow::Result<Module> {
|
||||
let module = Module::new();
|
||||
|
||||
if let Some(lib_module) = lib_module {
|
||||
module.import_public_symbols(lib_module);
|
||||
}
|
||||
|
||||
if let Some(config) = config {
|
||||
module.set("cfg", module.heap().alloc(config.clone()));
|
||||
}
|
||||
|
||||
let mut paths = path.to_vec();
|
||||
|
||||
paths.sort();
|
||||
|
||||
let ast_modules = paths
|
||||
.iter()
|
||||
.map(|&path| {
|
||||
let module = AstModule::parse_file(path, &default_dialect())
|
||||
.map_err(|err| anyhow::anyhow!("{err}"))
|
||||
.context(format!("parsing file {:?}", path.display()))?;
|
||||
|
||||
Ok((path, module))
|
||||
})
|
||||
.collect::<anyhow::Result<Vec<(&StdPath, AstModule)>>>()?;
|
||||
|
||||
for (path, ast) in ast_modules {
|
||||
let mut eval = Evaluator::new(&module);
|
||||
|
||||
if let Some(extra) = extra {
|
||||
eval.extra = Some(extra);
|
||||
}
|
||||
|
||||
eval.eval_module(ast, globals)
|
||||
.map_err(|err| anyhow::anyhow!("{err}"))
|
||||
.context(format!("evaluating file {:?}", path.display()))?;
|
||||
}
|
||||
|
||||
Ok(module)
|
||||
}
|
||||
|
||||
fn default_dialect() -> Dialect {
|
||||
Dialect {
|
||||
enable_def: true,
|
||||
enable_lambda: true,
|
||||
enable_load: false,
|
||||
enable_keyword_only_arguments: false,
|
||||
enable_positional_only_arguments: false,
|
||||
enable_types: DialectTypes::Disable,
|
||||
enable_load_reexport: false,
|
||||
enable_top_level_stmt: true,
|
||||
enable_f_strings: true,
|
||||
..Dialect::Standard
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
use allocative::Allocative;
|
||||
use starlark::{
|
||||
environment::GlobalsBuilder,
|
||||
starlark_module, starlark_simple_value,
|
||||
values::{Heap, StarlarkValue, Value, ValueLike},
|
||||
};
|
||||
use starlark_derive::{NoSerialize, ProvidesStaticType, starlark_value};
|
||||
use std::path::{Path as StdPath, PathBuf};
|
||||
|
||||
#[derive(Debug, Clone, Allocative, NoSerialize, ProvidesStaticType)]
|
||||
pub struct Path {
|
||||
inner: PathBuf,
|
||||
}
|
||||
|
||||
impl Path {
|
||||
pub fn new(value: impl Into<PathBuf>) -> Self {
|
||||
Self {
|
||||
inner: value.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn path(&self) -> &StdPath {
|
||||
&self.inner
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Path {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "path({:?})", self.inner)
|
||||
}
|
||||
}
|
||||
|
||||
starlark_simple_value!(Path);
|
||||
|
||||
#[starlark_value(type = "path")]
|
||||
impl<'v> StarlarkValue<'v> for Path {
|
||||
fn div(&self, other: Value<'v>, heap: &'v Heap) -> starlark::Result<Value<'v>> {
|
||||
let rhs = if let Some(str) = other.unpack_str() {
|
||||
str.to_string()
|
||||
} else if let Some(path) = other.downcast_ref::<Path>() {
|
||||
path.inner.to_str().unwrap_or("").to_string()
|
||||
} else {
|
||||
return Err(starlark::Error::new_other(anyhow::anyhow!(
|
||||
"expected a `string` or `path`, got `{}`",
|
||||
other.get_type()
|
||||
)));
|
||||
};
|
||||
|
||||
Ok(heap.alloc(Path {
|
||||
inner: self
|
||||
.inner
|
||||
.join(rhs.trim_start_matches(std::path::MAIN_SEPARATOR_STR)),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[starlark_module]
|
||||
pub fn types_globals(b: &mut GlobalsBuilder) {
|
||||
fn path(value: &str) -> anyhow::Result<Path> {
|
||||
Ok(Path {
|
||||
inner: PathBuf::from(value),
|
||||
})
|
||||
}
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
use std::{io::IsTerminal, sync::LazyLock};
|
||||
|
||||
static IS_STDERR_TERMINAL: LazyLock<bool> = LazyLock::new(|| std::io::stderr().is_terminal());
|
||||
|
||||
pub fn __emit(color: &str, action: &str, args: std::fmt::Arguments) {
|
||||
const ARROW: &str = "==>";
|
||||
|
||||
if *IS_STDERR_TERMINAL {
|
||||
eprintln!("\x1b[{color}m{ARROW}\x1b[0m \x1b[1m{action}\x1b[0m {args}");
|
||||
} else {
|
||||
eprintln!("{ARROW} {action} {args}");
|
||||
}
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! log {
|
||||
($action:literal) => {{
|
||||
$crate::log::__emit("1;34", $action, format_args!());
|
||||
}};
|
||||
($action:literal, $($arg:tt)*) => {{
|
||||
$crate::log::__emit("1;34", $action, format_args!($($arg)*));
|
||||
}};
|
||||
}
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
use crate::{
|
||||
container::{ContainerManager, PodmanRuntime},
|
||||
plan::{Plan, PlanKey},
|
||||
recipe::{PackageRecipe, RecipeSet, SourceRecipe, ToolRecipe},
|
||||
};
|
||||
|
||||
use std::{path::Path, sync::Arc};
|
||||
|
||||
mod cli;
|
||||
mod container;
|
||||
mod eval;
|
||||
mod log;
|
||||
mod plan;
|
||||
mod recipe;
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
cli::run()
|
||||
|
||||
// let podman_runtime = Arc::new(PodmanRuntime::new()?);
|
||||
// let mut container_manager = ContainerManager::new(podman_runtime);
|
||||
|
||||
// container_manager.container("example")?.exec(
|
||||
// vec!["sh", "-c", "uname -a && id"],
|
||||
// vec![],
|
||||
// Path::new("/"),
|
||||
// )?;
|
||||
|
||||
// let mut recipes = RecipeSet::default();
|
||||
|
||||
// recipes.load_recipes(Path::new("./recipes"), Path::new("./host-recipes"))?;
|
||||
|
||||
// recipes.add_source(
|
||||
// "binutils-2.46",
|
||||
// SourceRecipe {
|
||||
// name: "binutils-2.46".into(),
|
||||
// },
|
||||
// );
|
||||
|
||||
// recipes.add_source(
|
||||
// "gcc-16.1.0",
|
||||
// SourceRecipe {
|
||||
// name: "gcc-16.1.0".into(),
|
||||
// },
|
||||
// );
|
||||
|
||||
// recipes.add_source(
|
||||
// "linux-7.0.9",
|
||||
// SourceRecipe {
|
||||
// name: "linux-7.0.9".into(),
|
||||
// },
|
||||
// );
|
||||
|
||||
// recipes.add_source(
|
||||
// "glibc-2.41",
|
||||
// SourceRecipe {
|
||||
// name: "glibc-2.41".into(),
|
||||
// },
|
||||
// );
|
||||
|
||||
// recipes.add_source(
|
||||
// "bash-5.3",
|
||||
// SourceRecipe {
|
||||
// name: "bash-5.3".into(),
|
||||
// },
|
||||
// );
|
||||
|
||||
// recipes.add_tool(
|
||||
// "binutils",
|
||||
// ToolRecipe {
|
||||
// name: "binutils".into(),
|
||||
// sources: vec!["binutils-2.46".into()],
|
||||
// tools_wanted: vec![],
|
||||
// pkgs_wanted: vec![],
|
||||
// },
|
||||
// );
|
||||
|
||||
// recipes.add_tool(
|
||||
// "gcc-bootstrap",
|
||||
// ToolRecipe {
|
||||
// name: "gcc-bootstrap".into(),
|
||||
// sources: vec!["gcc-16.1.0".into()],
|
||||
// tools_wanted: vec!["binutils".into()],
|
||||
// pkgs_wanted: vec![],
|
||||
// },
|
||||
// );
|
||||
|
||||
// recipes.add_tool(
|
||||
// "gcc",
|
||||
// ToolRecipe {
|
||||
// name: "gcc".into(),
|
||||
// sources: vec!["gcc-16.1.0".into()],
|
||||
// tools_wanted: vec!["binutils".into()],
|
||||
// pkgs_wanted: vec!["glibc".into()],
|
||||
// },
|
||||
// );
|
||||
|
||||
// recipes.add_package(
|
||||
// "linux-headers",
|
||||
// PackageRecipe {
|
||||
// name: "linux-headers".into(),
|
||||
// sources: vec!["linux-7.0.9".into()],
|
||||
// tools_wanted: vec![],
|
||||
// pkgs_wanted: vec![],
|
||||
// },
|
||||
// );
|
||||
|
||||
// recipes.add_package(
|
||||
// "glibc",
|
||||
// PackageRecipe {
|
||||
// name: "glibc".into(),
|
||||
// sources: vec!["glibc-2.41".into()],
|
||||
// tools_wanted: vec!["gcc-bootstrap".into()],
|
||||
// pkgs_wanted: vec!["linux-headers".into()],
|
||||
// },
|
||||
// );
|
||||
|
||||
// recipes.add_package(
|
||||
// "bash",
|
||||
// PackageRecipe {
|
||||
// name: "bash".into(),
|
||||
// sources: vec!["bash-5.3".into()],
|
||||
// tools_wanted: vec!["gcc".into()],
|
||||
// pkgs_wanted: vec!["glibc".into()],
|
||||
// },
|
||||
// );
|
||||
|
||||
// let mut plan = Plan::new(&recipes);
|
||||
|
||||
// plan.add_wanted(PlanKey::PkgPackage(
|
||||
// recipes.package("bash").expect("back package"),
|
||||
// ));
|
||||
|
||||
// println!("{:#?}", plan.steps()?);
|
||||
|
||||
// Ok(())
|
||||
}
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
use crate::recipe::{PackageRecipe, RecipeSet, SourceRecipe, ToolRecipe};
|
||||
|
||||
use petgraph::{
|
||||
Direction,
|
||||
graph::{DiGraph, NodeIndex},
|
||||
};
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use std::{
|
||||
cmp::Reverse,
|
||||
collections::{BinaryHeap, HashMap, HashSet},
|
||||
};
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum PlanError {
|
||||
#[error("missing source recipe '{0}'")]
|
||||
MissingSource(String),
|
||||
#[error("missing tool recipe '{0}'")]
|
||||
MissingTool(String),
|
||||
#[error("missing package recipe '{0}'")]
|
||||
MissingPackage(String),
|
||||
#[error("plan cycle detected")]
|
||||
CycleDetected,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum PlanKey<'a> {
|
||||
SourceFetch(&'a SourceRecipe),
|
||||
SourcePatch(&'a SourceRecipe),
|
||||
SourcePrepare(&'a SourceRecipe),
|
||||
|
||||
ToolConfigure(&'a ToolRecipe),
|
||||
ToolBuild(&'a ToolRecipe),
|
||||
ToolInstall(&'a ToolRecipe),
|
||||
|
||||
PkgConfigure(&'a PackageRecipe),
|
||||
PkgBuild(&'a PackageRecipe),
|
||||
PkgInstall(&'a PackageRecipe),
|
||||
PkgPackage(&'a PackageRecipe),
|
||||
}
|
||||
|
||||
impl<'a> PlanKey<'a> {
|
||||
fn weight(&self) -> i8 {
|
||||
match self {
|
||||
PlanKey::SourceFetch(_) => 0,
|
||||
PlanKey::SourcePatch(_) => 1,
|
||||
PlanKey::SourcePrepare(_) => 2,
|
||||
PlanKey::ToolConfigure(_) => 3,
|
||||
PlanKey::ToolBuild(_) => 4,
|
||||
PlanKey::ToolInstall(_) => 5,
|
||||
PlanKey::PkgConfigure(_) => 6,
|
||||
PlanKey::PkgBuild(_) => 7,
|
||||
PlanKey::PkgInstall(_) => 8,
|
||||
PlanKey::PkgPackage(_) => 9,
|
||||
}
|
||||
}
|
||||
|
||||
fn dependencies(
|
||||
&self,
|
||||
recipes: &'a RecipeSet,
|
||||
) -> Result<SmallVec<[PlanKey<'a>; 8]>, PlanError> {
|
||||
match self {
|
||||
PlanKey::SourceFetch(_) => Ok(smallvec![]),
|
||||
PlanKey::SourcePatch(recipe) => Ok(smallvec![PlanKey::SourceFetch(recipe)]),
|
||||
PlanKey::SourcePrepare(recipe) => Ok(smallvec![PlanKey::SourcePatch(recipe)]),
|
||||
PlanKey::ToolConfigure(recipe) => {
|
||||
let source_deps = recipe.sources.iter().map(|name| {
|
||||
recipes
|
||||
.source(name)
|
||||
.map(PlanKey::SourcePrepare)
|
||||
.ok_or(PlanError::MissingSource(name.clone()))
|
||||
});
|
||||
|
||||
let tool_deps = recipe.tools_wanted.iter().map(|name| {
|
||||
recipes
|
||||
.tool(name)
|
||||
.map(PlanKey::ToolInstall)
|
||||
.ok_or(PlanError::MissingTool(name.clone()))
|
||||
});
|
||||
|
||||
let pkg_deps = recipe.pkgs_wanted.iter().map(|name| {
|
||||
recipes
|
||||
.package(name)
|
||||
.map(PlanKey::PkgPackage)
|
||||
.ok_or(PlanError::MissingPackage(name.clone()))
|
||||
});
|
||||
|
||||
source_deps.chain(tool_deps).chain(pkg_deps).collect()
|
||||
}
|
||||
PlanKey::ToolBuild(recipe) => Ok(smallvec![PlanKey::ToolConfigure(recipe)]),
|
||||
PlanKey::ToolInstall(recipe) => Ok(smallvec![PlanKey::ToolBuild(recipe)]),
|
||||
PlanKey::PkgConfigure(recipe) => {
|
||||
let source_deps = recipe.sources.iter().map(|name| {
|
||||
recipes
|
||||
.source(name)
|
||||
.map(PlanKey::SourcePrepare)
|
||||
.ok_or(PlanError::MissingSource(name.clone()))
|
||||
});
|
||||
|
||||
let tool_deps = recipe.tools_wanted.iter().map(|name| {
|
||||
recipes
|
||||
.tool(name)
|
||||
.map(PlanKey::ToolInstall)
|
||||
.ok_or(PlanError::MissingTool(name.clone()))
|
||||
});
|
||||
|
||||
let pkg_deps = recipe.pkgs_wanted.iter().map(|name| {
|
||||
recipes
|
||||
.package(name)
|
||||
.map(PlanKey::PkgPackage)
|
||||
.ok_or(PlanError::MissingPackage(name.clone()))
|
||||
});
|
||||
|
||||
source_deps.chain(tool_deps).chain(pkg_deps).collect()
|
||||
}
|
||||
PlanKey::PkgBuild(recipe) => Ok(smallvec![PlanKey::PkgConfigure(recipe)]),
|
||||
PlanKey::PkgInstall(recipe) => Ok(smallvec![PlanKey::PkgBuild(recipe)]),
|
||||
PlanKey::PkgPackage(recipe) => Ok(smallvec![PlanKey::PkgInstall(recipe)]),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_active(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Plan<'a> {
|
||||
recipes: &'a RecipeSet,
|
||||
wanted: HashSet<PlanKey<'a>>,
|
||||
}
|
||||
|
||||
impl<'a> Plan<'a> {
|
||||
pub fn new(recipes: &'a RecipeSet) -> Self {
|
||||
Self {
|
||||
recipes,
|
||||
wanted: HashSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_wanted(&mut self, key: PlanKey<'a>) {
|
||||
self.wanted.insert(key);
|
||||
}
|
||||
|
||||
pub fn steps(&self) -> Result<Vec<PlanKey<'a>>, PlanError> {
|
||||
let mut stack: Vec<_> = self.wanted.iter().copied().collect();
|
||||
let mut graph: DiGraph<_, ()> = DiGraph::new();
|
||||
let mut nodes = HashMap::new();
|
||||
|
||||
while let Some(node) = stack.pop() {
|
||||
let node_idx = match nodes.get(&node) {
|
||||
Some(&idx) => idx,
|
||||
None => {
|
||||
let idx = graph.add_node(node);
|
||||
nodes.insert(node, idx);
|
||||
idx
|
||||
}
|
||||
};
|
||||
|
||||
for dep in node.dependencies(self.recipes)?.iter().copied() {
|
||||
let dep_idx = match nodes.get(&dep) {
|
||||
Some(&idx) => idx,
|
||||
None => {
|
||||
let idx = graph.add_node(dep);
|
||||
nodes.insert(dep, idx);
|
||||
stack.push(dep);
|
||||
idx
|
||||
}
|
||||
};
|
||||
|
||||
graph.update_edge(dep_idx, node_idx, ());
|
||||
}
|
||||
}
|
||||
|
||||
// petgraph::algo::toposort(&graph, None)
|
||||
// .and_then(|nodes| {
|
||||
// Ok(nodes
|
||||
// .iter()
|
||||
// .map(|&k| graph[k])
|
||||
// .filter(|node| node.is_active())
|
||||
// .collect())
|
||||
// })
|
||||
// .map_err(|_| PlanError::CycleDetected)
|
||||
|
||||
let mut in_degree: HashMap<NodeIndex, usize> = graph
|
||||
.node_indices()
|
||||
.map(|i| (i, graph.neighbors_directed(i, Direction::Incoming).count()))
|
||||
.collect();
|
||||
|
||||
let mut heap: BinaryHeap<(Reverse<i8>, NodeIndex)> = in_degree
|
||||
.iter()
|
||||
.filter(|&(_, d)| *d == 0)
|
||||
.map(|(&i, _)| (Reverse(graph[i].weight()), i))
|
||||
.collect();
|
||||
|
||||
let mut result = Vec::with_capacity(graph.node_count());
|
||||
|
||||
while let Some((_, idx)) = heap.pop() {
|
||||
result.push(graph[idx]);
|
||||
|
||||
for neighbor in graph.neighbors_directed(idx, Direction::Outgoing) {
|
||||
let d = in_degree.get_mut(&neighbor).unwrap();
|
||||
*d -= 1;
|
||||
if *d == 0 {
|
||||
heap.push((Reverse(graph[neighbor].weight()), neighbor));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if result.len() != graph.node_count() {
|
||||
Err(PlanError::CycleDetected)
|
||||
} else {
|
||||
result.retain(|node| node.is_active());
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
use anyhow::Context;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Hash)]
|
||||
pub struct SourceRecipe {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Hash)]
|
||||
pub struct ToolRecipe {
|
||||
pub name: String,
|
||||
pub sources: Vec<String>,
|
||||
pub tools_wanted: Vec<String>,
|
||||
pub pkgs_wanted: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Hash)]
|
||||
pub struct PackageRecipe {
|
||||
pub name: String,
|
||||
pub sources: Vec<String>,
|
||||
pub tools_wanted: Vec<String>,
|
||||
pub pkgs_wanted: Vec<String>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for SourceRecipe {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "source:{}", self.name)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ToolRecipe {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "tool:{}", self.name)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for PackageRecipe {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "package:{}", self.name)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Debug)]
|
||||
pub struct RecipeSet {
|
||||
sources: HashMap<String, SourceRecipe>,
|
||||
tools: HashMap<String, ToolRecipe>,
|
||||
packages: HashMap<String, PackageRecipe>,
|
||||
}
|
||||
|
||||
impl RecipeSet {
|
||||
fn add_source(&mut self, name: &str, recipe: SourceRecipe) -> anyhow::Result<()> {
|
||||
if self.sources.insert(name.to_string(), recipe).is_some() {
|
||||
anyhow::bail!("source '{name}' already exists");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn add_tool(&mut self, name: &str, recipe: ToolRecipe) -> anyhow::Result<()> {
|
||||
if self.tools.insert(name.to_string(), recipe).is_some() {
|
||||
anyhow::bail!("tool '{name}' already exists");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn add_package(&mut self, name: &str, recipe: PackageRecipe) -> anyhow::Result<()> {
|
||||
if self.packages.insert(name.to_string(), recipe).is_some() {
|
||||
anyhow::bail!("package '{name}' already exists");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_tool_recipe(&mut self, name: &str, path: &Path) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_package_recipe(&mut self, name: &str, path: &Path) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load_recipes(
|
||||
&mut self,
|
||||
recipes_dir: &Path,
|
||||
host_recipes_dir: &Path,
|
||||
) -> anyhow::Result<()> {
|
||||
for (dir, tool_recipe) in [(recipes_dir, false), (host_recipes_dir, true)] {
|
||||
for entry in std::fs::read_dir(dir)? {
|
||||
let entry = entry.context("reading directory entry")?;
|
||||
|
||||
if let Some((name, path)) = get_recipe_name_and_patch(&entry)? {
|
||||
if tool_recipe {
|
||||
self.load_tool_recipe(&name, &path)?;
|
||||
} else {
|
||||
self.load_package_recipe(&name, &path)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn source(&self, name: &str) -> Option<&SourceRecipe> {
|
||||
self.sources.get(name)
|
||||
}
|
||||
|
||||
pub fn tool(&self, name: &str) -> Option<&ToolRecipe> {
|
||||
self.tools.get(name)
|
||||
}
|
||||
|
||||
pub fn package(&self, name: &str) -> Option<&PackageRecipe> {
|
||||
self.packages.get(name)
|
||||
}
|
||||
}
|
||||
|
||||
fn get_recipe_name_and_patch(
|
||||
entry: &std::fs::DirEntry,
|
||||
) -> anyhow::Result<Option<(String, PathBuf)>> {
|
||||
let file_type = entry.file_type()?;
|
||||
let path = entry.path();
|
||||
|
||||
if file_type.is_dir() {
|
||||
let recipe_path = path.join("recipe.star");
|
||||
|
||||
if recipe_path.exists() {
|
||||
return Ok(Some((
|
||||
entry.file_name().to_str().unwrap_or("").to_string(),
|
||||
recipe_path,
|
||||
)));
|
||||
}
|
||||
} else {
|
||||
let name = path.file_stem().unwrap().to_str().unwrap_or("").to_string();
|
||||
let extension = path
|
||||
.extension()
|
||||
.ok_or(anyhow::anyhow!("File did not have an extension"))?;
|
||||
|
||||
if extension == "star" {
|
||||
return Ok(Some((name, path)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
Reference in New Issue
Block a user