blob: 65c137b178e0d0dc3ce21d4217a4bc3be8e7fcde [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
//! Root-bound discovery is a hint; clients verify its identity against live status.
use super::{Host, LifecycleMode, Status, VERSION};
use maka_event_log::root::{self, RootNamespaces, RootOwner};
use serde::{Deserialize, Serialize};
use std::{
fs,
io::{self, Read, Write},
num::NonZeroU32,
path::{Path, PathBuf},
sync::Arc,
};
use uuid::Uuid;
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Discovery {
pub version: u32,
pub root_id: String,
pub generation: Option<String>,
pub host_epoch: Uuid,
pub source: maka_plugin_core::deployment::DeploymentRef,
pub lifecycle_mode: LifecycleMode,
pub endpoint: PathBuf,
pub pid: NonZeroU32,
pub websocket_endpoints: Vec<String>,
}
impl Discovery {
pub fn verify(&self, status: &Status) -> io::Result<()> {
if self.version != VERSION
|| status.version != VERSION
|| self.root_id != status.root_id
|| self.host_epoch != status.epoch
|| self.pid != status.pid
|| self.source != status.source
|| self.generation != status.generation
|| self.lifecycle_mode != status.lifecycle_mode
|| status.closing
|| status.closed
{
return Err(io::Error::other(
"Host discovery is stale, incompatible or not ready",
));
}
Ok(())
}
}
pub fn read(root: &Path) -> io::Result<Discovery> {
read_with_namespaces(root, &RootNamespaces::for_current_account()?)
}
pub fn read_with_namespaces(root: &Path, namespaces: &RootNamespaces) -> io::Result<Discovery> {
let root = root::resolve(root)?;
let path = namespaces
.control
.join(root.root_id())
.join("registration.json");
let discovery: Discovery = serde_json::from_slice(&read_record(&path)?)?;
if discovery.version != VERSION
|| discovery.root_id != root.root_id()
|| discovery.host_epoch.is_nil()
|| !discovery.endpoint.is_absolute()
{
return Err(io::Error::other(
"Host discovery does not match the native Root or protocol",
));
}
discovery.source.validate().map_err(io::Error::other)?;
#[cfg(windows)]
if !discovery
.endpoint
.to_str()
.and_then(|path| path.strip_prefix(r"\\.\pipe\"))
.is_some_and(|name| !name.is_empty() && !name.contains(['\\', '/', '\0']))
{
return Err(io::Error::other("Host discovery is not a local named pipe"));
}
Ok(discovery)
}
fn read_record(path: &Path) -> io::Result<Vec<u8>> {
let before = path.symlink_metadata()?;
if !before.is_file() {
return Err(io::Error::other("invalid Host discovery record"));
}
#[cfg(unix)]
let file = {
use std::os::unix::fs::OpenOptionsExt;
fs::OpenOptions::new()
.read(true)
.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
.open(path)?
};
#[cfg(windows)]
let file = root::windows::open_nofollow(path, false)?;
if !file.metadata()?.is_file() {
return Err(io::Error::other("Host discovery is not a regular file"));
}
let mut bytes = Vec::new();
let mut file = file;
file.read_to_end(&mut bytes)?;
let after = path.symlink_metadata()?;
if !after.is_file()
|| before.len() != bytes.len() as u64
|| before.len() != after.len()
|| before.modified()? != after.modified()?
{
return Err(io::Error::other("Host discovery changed while reading"));
}
Ok(bytes)
}
/// Retains the original Root lease, and removes only this incarnation's record.
pub struct Registration {
root: Arc<RootOwner>,
epoch: Uuid,
path: PathBuf,
}
impl Host {
pub fn publish_registration(
&self,
endpoint: &Path,
websocket: Option<std::net::SocketAddr>,
) -> io::Result<Registration> {
self.root.validate_current()?;
if !endpoint.is_absolute() || endpoint.to_str().is_none() {
return Err(io::Error::other("invalid Host discovery endpoint"));
}
if self.status().closing {
return Err(io::Error::other("Host is closing"));
}
let value = Discovery {
version: VERSION,
root_id: self.root_id().to_owned(),
generation: self.options.generation.clone(),
host_epoch: self.epoch,
source: self.source.clone(),
lifecycle_mode: self.options.lifecycle_mode,
endpoint: endpoint.into(),
pid: NonZeroU32::new(std::process::id()).expect("process id"),
websocket_endpoints: websocket
.into_iter()
.map(|address| format!("ws://{address}/runtime-host"))
.collect(),
};
let directory = self.root.control_directory();
let path = directory.join("registration.json");
let mut temporary = tempfile::NamedTempFile::new_in(directory)?;
serde_json::to_writer(&mut temporary, &value)?;
temporary.write_all(b"\n")?;
temporary.as_file().sync_all()?;
self.root.validate_current()?;
temporary.persist(&path)?;
Ok(Registration {
root: self.root.clone(),
epoch: self.epoch,
path,
})
}
}
impl Registration {
pub fn remove(self) -> io::Result<()> {
self.remove_current()
}
fn remove_current(&self) -> io::Result<()> {
self.root.validate_current()?;
let bytes = match read_record(&self.path) {
Ok(bytes) => bytes,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
Err(error) => return Err(error),
};
let current: Discovery = serde_json::from_slice(&bytes)?;
if current.host_epoch == self.epoch && current.root_id == self.root.root_id() {
fs::remove_file(&self.path)?;
}
Ok(())
}
}
impl Drop for Registration {
fn drop(&mut self) {
if let Err(error) = self.remove_current() {
eprintln!("could not remove Host registration: {error}");
}
}
}