pub mod bindings;
pub mod checkpoint;
pub mod discovery;
pub mod prepare;
pub mod reset;
pub mod runtime;
use crate::{
durable_io,
local_fleet::{
LocalFleetError,
model::{
LocalAllocationInput, LocalAllocationIntentRecord, LocalAllocationRecord,
LocalCheckpoint, LocalFleetConfig, LocalFleetRecord,
},
view::{LocalCanisterView, LocalFleetView},
},
};
use candid::Principal;
use canic_core::cdk::utils::hash::hex_bytes;
use ic_testkit::pocket_ic::{PocketIc, common::rest::Topology};
use sha2_host::{Digest, Sha256};
#[cfg(unix)]
use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt};
use std::{
collections::BTreeSet,
fs::{self, File, OpenOptions},
io::Read,
path::{Path, PathBuf},
};
pub fn lock_directory(root: &Path, name: &str) -> Result<(PathBuf, File), LocalFleetError> {
crate::component_operation::policy::validate_label(name)
.map_err(|_| LocalFleetError::Configuration)?;
let mut directory = root.canonicalize()?;
for part in [".canic", "local-fleets", name] {
directory.push(part);
match fs::symlink_metadata(&directory) {
Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => {}
Ok(_) => return Err(LocalFleetError::UnsafePath),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
let mut builder = fs::DirBuilder::new();
#[cfg(unix)]
{
builder.mode(0o700);
}
builder.create(&directory)?;
}
Err(error) => return Err(error.into()),
}
}
let mut options = OpenOptions::new();
options.read(true).write(true).create(true).truncate(false);
#[cfg(unix)]
{
options.mode(0o600).custom_flags(
i32::try_from(rustix::fs::OFlags::NOFOLLOW.bits())
.map_err(|_| LocalFleetError::Configuration)?,
);
}
let lock = options.open(directory.join("owner.lock"))?;
if !lock.metadata()?.is_file() {
return Err(LocalFleetError::UnsafePath);
}
lock.try_lock().map_err(|_| LocalFleetError::Busy)?;
Ok((directory, lock))
}
pub fn read_record(directory: &Path) -> Result<Option<LocalFleetRecord>, LocalFleetError> {
match durable_io::read_regular_bytes(&directory.join("environment.json"), 1024 * 1024) {
Ok(bytes) => Ok(Some(serde_json::from_slice(&bytes)?)),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(error.into()),
}
}
pub fn write_record(directory: &Path, record: &LocalFleetRecord) -> Result<(), LocalFleetError> {
let bytes = serde_json::to_vec_pretty(record)?;
if bytes.len() > 1024 * 1024 {
return Err(LocalFleetError::Capacity);
}
durable_io::write_bytes(&directory.join("environment.json"), &bytes)?;
Ok(())
}
pub fn instance_record(
config: &LocalFleetConfig,
previous: Option<LocalFleetRecord>,
pic: &PocketIc,
) -> Result<LocalFleetRecord, LocalFleetError> {
let topology = pic.topology();
let subnets = topology.get_app_subnets();
let root_key_der_hex =
canic_core::cdk::utils::hash::hex_bytes(pic.root_key().ok_or(LocalFleetError::Identity)?);
if let Some(record) = previous {
let expected = InstanceAuthority {
version: env!("CARGO_PKG_VERSION"),
configuration: config,
subnets: &subnets,
root_key: &root_key_der_hex,
};
let retained = InstanceAuthority {
version: &record.canic_version,
configuration: &record.configuration,
subnets: &record.application_subnets,
root_key: &record.root_key_der_hex,
};
if record.schema_version != 1 || retained != expected {
return Err(LocalFleetError::Identity);
}
validate_allocations(&record, &topology)?;
return Ok(record);
}
let mut record = initial_record(config)?;
record.application_subnets = subnets;
record.root_key_der_hex = root_key_der_hex;
Ok(record)
}
#[must_use]
pub fn status(record: &LocalFleetRecord, pic: &PocketIc, gateway: &str) -> LocalFleetView {
LocalFleetView {
schema_version: 1,
session_id: record.session_id.clone(),
name: record.configuration.name.clone(),
environment: environment_name(record),
gateway: gateway.into(),
root_key_der_hex: record.root_key_der_hex.clone(),
application_subnets: record.application_subnets.clone(),
maximum_canisters: record.configuration.maximum_canisters,
canister_memory_bytes: record.configuration.canister_memory_bytes,
simulated_time_ns: pic.get_time().as_nanos_since_unix_epoch().to_string(),
canisters: record
.allocations
.iter()
.map(|entry| {
let exists = entry.canister_id.is_some_and(|id| pic.canister_exists(id));
LocalCanisterView {
name: entry.input.name.clone(),
allocation_role: entry.input.role.clone(),
canister_id: entry.canister_id,
subnet_id: entry.subnet_id,
exists,
native_cycles: if exists {
entry
.canister_id
.map(|id| pic.cycle_balance(id).to_string())
.unwrap_or_default()
} else {
"0".into()
},
}
})
.collect(),
}
}
pub fn reserve(
directory: &Path,
record: &LocalFleetRecord,
input: &LocalAllocationInput,
now: u64,
) -> Result<(LocalFleetRecord, LocalAllocationIntentRecord), LocalFleetError> {
if let Some(existing) = record
.allocations
.iter()
.find(|entry| entry.input.name == input.name)
{
if existing.input != *input {
return Err(LocalFleetError::Identity);
}
return Ok((record.clone(), existing.clone()));
}
if record.release_build_id.is_some() {
return Err(LocalFleetError::Identity);
}
if record.allocations.len() >= usize::from(record.configuration.maximum_canisters) {
return Err(LocalFleetError::Capacity);
}
let subnet_id = *record
.application_subnets
.get(usize::from(input.application_subnet))
.ok_or(LocalFleetError::Configuration)?;
let previous = record
.allocations
.last()
.map_or(0, |entry| entry.created_at_time);
let created_at_time = now.max(previous.checked_add(1).ok_or(LocalFleetError::Capacity)?);
let intent = LocalAllocationIntentRecord {
input: input.clone(),
subnet_id,
created_at_time,
canister_id: None,
};
let mut next = record.clone();
next.allocations.push(intent.clone());
write_record(directory, &next)?;
Ok((next, intent))
}
pub fn complete_allocation(
directory: &Path,
record: &LocalFleetRecord,
intent: &LocalAllocationIntentRecord,
id: Principal,
) -> Result<(LocalFleetRecord, LocalAllocationRecord), LocalFleetError> {
let mut next = record.clone();
let entry = next
.allocations
.iter_mut()
.find(|entry| entry.input.name == intent.input.name)
.ok_or(LocalFleetError::Identity)?;
if entry != intent || entry.canister_id.is_some_and(|previous| previous != id) {
return Err(LocalFleetError::Identity);
}
entry.canister_id = Some(id);
let allocation = LocalAllocationRecord {
input: entry.input.clone(),
canister_id: id,
subnet_id: entry.subnet_id,
};
write_record(directory, &next)?;
Ok((next, allocation))
}
pub fn validate_tree(directory: &Path) -> Result<(), LocalFleetError> {
let mut pending = vec![(directory.to_path_buf(), 0_u16)];
let mut count = 0_usize;
while let Some((path, depth)) = pending.pop() {
count += 1;
if count > 100_000 || depth > 64 {
return Err(LocalFleetError::Capacity);
}
let metadata = fs::symlink_metadata(&path)?;
if metadata.file_type().is_symlink() || (!metadata.is_dir() && !metadata.is_file()) {
return Err(LocalFleetError::UnsafePath);
}
if metadata.is_dir() {
for entry in fs::read_dir(path)? {
if pending.len() + count >= 100_000 {
return Err(LocalFleetError::Capacity);
}
pending.push((entry?.path(), depth + 1));
}
}
}
Ok(())
}
pub fn checkpoint(
directory: &Path,
record: &LocalFleetRecord,
checkpoint: LocalCheckpoint,
) -> Result<LocalFleetRecord, LocalFleetError> {
let mut next = record.clone();
next.checkpoint = checkpoint;
write_record(directory, &next)?;
Ok(next)
}
#[derive(Eq, PartialEq)]
struct InstanceAuthority<'a> {
version: &'a str,
configuration: &'a LocalFleetConfig,
subnets: &'a [Principal],
root_key: &'a str,
}
fn validate_allocations(
record: &LocalFleetRecord,
topology: &Topology,
) -> Result<(), LocalFleetError> {
if record.session_id.len() != 64
|| !record
.session_id
.bytes()
.all(|byte| byte.is_ascii_hexdigit())
{
return Err(LocalFleetError::Identity);
}
if record.allocations.len() > usize::from(record.configuration.maximum_canisters) {
return Err(LocalFleetError::Capacity);
}
let mut names = BTreeSet::new();
let mut ids = BTreeSet::new();
let mut last_time = 0;
for entry in &record.allocations {
crate::local_fleet::policy::validate_allocation(&entry.input, &record.configuration)?;
let expected_subnet = record
.application_subnets
.get(usize::from(entry.input.application_subnet));
if expected_subnet != Some(&entry.subnet_id)
|| !names.insert(&entry.input.name)
|| entry.created_at_time <= last_time
{
return Err(LocalFleetError::Identity);
}
if let Some(id) = entry.canister_id
&& (!ids.insert(id) || topology.get_subnet(id) != Some(entry.subnet_id))
{
return Err(LocalFleetError::Identity);
}
last_time = entry.created_at_time;
}
Ok(())
}
pub fn initial_record(config: &LocalFleetConfig) -> Result<LocalFleetRecord, LocalFleetError> {
let mut nonce = [0; 32];
getrandom::fill(&mut nonce).map_err(|error| LocalFleetError::Platform(error.to_string()))?;
Ok(LocalFleetRecord {
schema_version: 1,
canic_version: env!("CARGO_PKG_VERSION").into(),
session_id: canic_core::cdk::utils::hash::hex_bytes(nonce),
configuration: config.clone(),
application_subnets: Vec::new(),
root_key_der_hex: String::new(),
allocations: Vec::new(),
checkpoint: LocalCheckpoint::Running,
release_build_id: None,
})
}
pub fn admit_initial_instance(
record: &LocalFleetRecord,
pic: &PocketIc,
) -> Result<LocalFleetRecord, LocalFleetError> {
let mut next = instance_record(&record.configuration, None, pic)?;
next.session_id.clone_from(&record.session_id);
Ok(next)
}
pub fn binary_sha256(path: &Path) -> Result<String, LocalFleetError> {
let mut options = OpenOptions::new();
options.read(true);
#[cfg(unix)]
{
options.custom_flags(
i32::try_from(rustix::fs::OFlags::NOFOLLOW.bits())
.map_err(|_| LocalFleetError::Configuration)?,
);
}
let mut file = options.open(path)?;
let metadata = file.metadata()?;
if !metadata.is_file() || metadata.len() > 512 * 1024 * 1024 {
return Err(LocalFleetError::UnsafePath);
}
let mut hash = Sha256::new();
let mut buffer = [0; 16_384];
let mut read = 0_u64;
loop {
let count = file.read(&mut buffer)?;
if count == 0 {
break;
}
read += count as u64;
if read > 512 * 1024 * 1024 {
return Err(LocalFleetError::Capacity);
}
hash.update(&buffer[..count]);
}
Ok(hex_bytes(hash.finalize()))
}
#[must_use]
pub fn environment_name(record: &LocalFleetRecord) -> String {
let name = record
.configuration
.name
.chars()
.take(24)
.collect::<String>();
format!("local-{name}-{}", &record.session_id[..16])
}
pub fn instance_directory(directory: &Path, session: &str) -> Result<PathBuf, LocalFleetError> {
if session.len() != 64 || !session.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return Err(LocalFleetError::Identity);
}
Ok(directory.join(format!("instance-{session}")))
}