blob: 785b0b91ba3fd6277772a4b366aa3af2a30145ca [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.
*/
//! Public application caller: an anchored authenticated session, no legacy operations.
use super::{
Configuration, Status, VERSION,
protocol::{Connected, Opening, Request, Response, SessionControl},
};
use crate::{
application::{
Result,
transport::{Call, Catalogue, HostEndpoint, Peer, Source, wire},
},
ipc::{self, LocalStream},
};
use maka_capabilities::call::Attachments;
use maka_plugin_core::{composition::InstanceId, deployment::DeploymentRef};
use serde_json::Value;
use std::{
collections::{BTreeMap, BTreeSet},
path::{Path, PathBuf},
};
use tokio::io::{AsyncWriteExt, BufReader, ReadHalf, WriteHalf};
use uuid::Uuid;
pub type LocalCall = Call<ReadHalf<BufReader<LocalStream>>, WriteHalf<BufReader<LocalStream>>>;
#[derive(PartialEq)]
enum Phase {
Ready,
Lost,
Closing,
Closed,
}
pub struct Client {
address: PathBuf,
connected: Connected,
anchor: wire::Reader<LocalStream>,
phase: Phase,
peer: Option<DeploymentRef>,
}
impl Client {
pub async fn connect(address: &Path, epoch: Option<Uuid>) -> Result<Self> {
let anchor = ipc::connect(address)
.await
.map_err(|error| error.to_string())?;
Self::connect_stream(address, epoch, anchor).await
}
pub async fn connect_stream(
address: &Path,
epoch: Option<Uuid>,
anchor: LocalStream,
) -> Result<Self> {
Self::open(address, epoch, anchor, Request::Connect).await
}
/// Only a trusted current-account launcher may approve an external UI source.
pub async fn connect_peer(
address: &Path,
epoch: Option<Uuid>,
source: DeploymentRef,
consumers: BTreeSet<InstanceId>,
endpoints: BTreeMap<String, HostEndpoint>,
) -> Result<Self> {
let request = Request::ConnectPeer {
source,
consumers,
endpoints,
};
let anchor = ipc::connect(address)
.await
.map_err(|error| error.to_string())?;
Self::open(address, epoch, anchor, request).await
}
async fn open(
address: &Path,
epoch: Option<Uuid>,
mut anchor: LocalStream,
request: Request,
) -> Result<Self> {
let peer = match &request {
Request::ConnectPeer {
source, consumers, ..
} => {
Peer::new(source.clone(), consumers.clone())?;
Some(source.clone())
}
_ => None,
};
wire::write(
&mut anchor,
&Opening {
client_instance: None,
version: VERSION,
epoch,
request,
},
)
.await?;
let mut anchor = wire::Reader::new(anchor);
let Response::Connected(connected) = anchor.read().await? else {
return Err("Host did not establish an application session".into());
};
if connected.version != VERSION
|| connected.epoch.is_nil()
|| connected.session.is_nil()
|| epoch.is_some_and(|epoch| connected.epoch != epoch)
{
return Err("Host protocol or incarnation changed".into());
}
connected.source.validate()?;
connected.catalogue.validate()?;
connected
.catalogue
.check_names(connected.consumers.keys())?;
Ok(Self {
address: address.into(),
connected,
anchor,
phase: Phase::Ready,
peer,
})
}
pub fn catalogue(&self) -> &Catalogue {
&self.connected.catalogue
}
pub fn epoch(&self) -> Uuid {
self.connected.epoch
}
pub fn source(&self) -> &DeploymentRef {
&self.connected.source
}
/// Open inert custody for one exact captured endpoint. A caller retains this
/// Call through errors; reconnection never retries an unconfirmed mutation.
pub async fn call(
&self,
endpoint: &str,
method: String,
input: Value,
resources: Attachments,
) -> Result<LocalCall> {
if self.peer.is_some() {
return Err("UI peer calls require the original checked caller and journal".into());
}
let selection = self.connected.catalogue.select(endpoint)?;
let consumer = self
.connected
.consumers
.get(endpoint)
.ok_or("Host endpoint missing")?
.clone();
let (reader, output) = tokio::io::split(self.open_call_channel().await?);
Ok(Call::new(
reader,
output,
selection,
Source {
deployment: self.connected.source.clone(),
consumer,
mount: self.connected.session,
request: Uuid::new_v4(),
},
method,
input,
resources,
))
}
/// Establish only the authenticated session channel. The native adapter retains
/// its original checked Source, Call and caller-side RecordedIntent.
pub async fn open_call_channel(&self) -> Result<BufReader<LocalStream>> {
self.call_channel_opener()?.open().await
}
/// Freeze only this already authenticated session's physical target. It owns
/// no anchor, caller approval, source, or operation token and cannot reconnect.
pub fn call_channel_opener(&self) -> Result<CallChannelOpener> {
if self.phase != Phase::Ready {
return Err("Host session control is not ready".into());
}
Ok(CallChannelOpener {
address: self.address.clone(),
epoch: self.connected.epoch,
session: self.connected.session,
})
}
fn ready_peer(&self) -> Result<&DeploymentRef> {
if self.phase != Phase::Ready {
return Err("Host session control is not ready".into());
}
self.peer
.as_ref()
.ok_or("Host-owned session cannot approve peer sources".into())
}
async fn control(&mut self, command: SessionControl) -> Result<Response> {
if self.phase != Phase::Ready {
return Err("Host session control is not ready".into());
}
wire::encode_bounded(&command, usize::MAX)?;
// Cancellation or a partial frame permanently fences further commands.
self.phase = Phase::Lost;
wire::write(&mut self.anchor.inner, &command).await?;
self.anchor.read().await
}
pub async fn epochs(&mut self) -> Result<BTreeMap<String, Option<Uuid>>> {
self.ready_peer()?;
match self.control(SessionControl::Epochs).await? {
Response::Epochs(result) => {
self.phase = Phase::Ready;
result
}
_ => Err("Host endpoint observation response mismatch".into()),
}
}
pub async fn capture(&mut self) -> Result<Catalogue> {
self.ready_peer()?;
match self.control(SessionControl::Capture).await? {
Response::Captured(result) => match result {
Ok(catalogue) => {
catalogue.validate()?;
catalogue.check_names(self.connected.consumers.keys())?;
self.phase = Phase::Ready;
Ok(catalogue)
}
Err(error) => {
self.phase = Phase::Ready;
Err(error)
}
},
_ => Err("Host capture response mismatch".into()),
}
}
pub fn check_approval(
&self,
source: &DeploymentRef,
consumers: &BTreeSet<InstanceId>,
retain: &BTreeSet<u64>,
captured: &Option<Catalogue>,
) -> Result<()> {
let peer = self.ready_peer()?;
source.validate()?;
if source.state_domain != peer.state_domain || source.deployment != peer.deployment {
return Err("foreign approval deployment".into());
}
if !consumers.is_empty() {
Peer::new(source.clone(), consumers.clone())?;
}
if consumers.is_empty() != captured.is_none() {
return Err("approval requires its captured endpoints".into());
}
if let Some(catalogue) = captured {
catalogue.validate()?;
catalogue.check_names(self.connected.consumers.keys())?;
}
wire::encode_bounded(
&SessionControl::Approve {
source: source.clone(),
consumers: consumers.clone(),
retain: retain.clone(),
captured: captured.clone(),
},
usize::MAX,
)?;
Ok(())
}
pub async fn approve(
&mut self,
source: DeploymentRef,
consumers: BTreeSet<InstanceId>,
retain: BTreeSet<u64>,
captured: Option<Catalogue>,
) -> Result<()> {
self.check_approval(&source, &consumers, &retain, &captured)?;
match self
.control(SessionControl::Approve {
source: source.clone(),
consumers,
retain,
captured: captured.clone(),
})
.await?
{
Response::Approved {
source: actual,
result,
} if actual == source => {
if result.is_ok()
&& let Some(catalogue) = captured
{
self.connected.catalogue = catalogue;
}
self.phase = Phase::Ready;
result
}
_ => Err("Host approval response mismatch".into()),
}
}
/// After a lost control observation, shut down this anchor without appending
/// a frame. Unix can still read its original cleanup acknowledgement; Windows
/// closes the whole pipe and returns an error unless that acknowledgement was
/// already buffered. EOF alone never confirms cleanup.
pub async fn close(&mut self) -> Result<()> {
if self.phase == Phase::Closed {
return Ok(());
}
if self.phase == Phase::Ready {
self.phase = Phase::Lost;
if wire::write(&mut self.anchor.inner, &SessionControl::Close)
.await
.is_ok()
{
self.phase = Phase::Closing;
}
}
if self.phase == Phase::Lost {
self.anchor
.inner
.shutdown()
.await
.map_err(|error| error.to_string())?;
self.phase = Phase::Closing;
}
loop {
match self.anchor.read::<Response>().await? {
Response::Closed => {
self.phase = Phase::Closed;
return Ok(());
}
// A cancelled control observer may have left its acknowledgement
// buffered. None substitutes for the final session cleanup.
Response::Epochs(_) | Response::Captured(_) | Response::Approved { .. } => {}
_ => return Err("Host session cleanup remains unconfirmed".into()),
}
}
}
/// Close this local session and stop its Host when no other session remains.
/// A separate epoch-pinned channel also works after losing a control observer;
/// a partial frame on the original anchor must never receive another command.
pub async fn quit(&mut self) -> Result<()> {
if self.phase == Phase::Closed {
return Ok(());
}
self.phase = Phase::Lost;
match control(
&self.address,
Some(self.connected.epoch),
Request::Quit {
session: self.connected.session,
},
)
.await?
{
Response::Closed => {
self.phase = Phase::Closed;
Ok(())
}
_ => Err("Host quit cleanup remains unconfirmed".into()),
}
}
/// Persist trusted configuration and apply it to this same Host incarnation.
/// A lost response must be retried with the same source and desired value.
pub async fn configure(
&self,
source: DeploymentRef,
previous: Configuration,
configuration: Configuration,
) -> Result<()> {
if self.phase != Phase::Ready || self.peer.is_some() {
return Err("Host configuration requires a ready local owner session".into());
}
match control(
&self.address,
Some(self.connected.epoch),
Request::Configure {
source,
previous,
configuration,
},
)
.await?
{
Response::Configured(result) => result,
_ => Err("Host configuration response mismatch".into()),
}
}
pub async fn prepare_access(
address: &Path,
epoch: Uuid,
principal: String,
) -> Result<super::Pairing> {
match control(address, Some(epoch), Request::PrepareAccess { principal }).await? {
Response::Pairing(result) => result,
_ => Err("Host pairing response mismatch".into()),
}
}
pub async fn revoke_access(address: &Path, epoch: Uuid, credential: String) -> Result<bool> {
match control(address, Some(epoch), Request::RevokeAccess { credential }).await? {
Response::Revoked(result) => result,
_ => Err("Host revocation response mismatch".into()),
}
}
pub async fn status(address: &Path, epoch: Option<Uuid>) -> Result<Status> {
let stream = ipc::connect(address)
.await
.map_err(|error| error.to_string())?;
Self::status_stream(stream, epoch).await
}
pub async fn status_stream(stream: LocalStream, epoch: Option<Uuid>) -> Result<Status> {
match control_stream(stream, epoch, Request::Status).await? {
Response::Status(status) => Ok(status),
_ => Err("Host status response mismatch".into()),
}
}
pub async fn retire(address: &Path, epoch: Uuid) -> Result<bool> {
Self::retire_with_options(address, epoch, false, true).await
}
/// True confirms the retirement decision; force retirement can still be
/// cleaning up. The original Root lease remains held through actual drain.
pub async fn retire_with_options(
address: &Path,
epoch: Uuid,
interrupt: bool,
allow_idle_connections: bool,
) -> Result<bool> {
match control(
address,
Some(epoch),
Request::Retire {
interrupt,
allow_idle_connections,
},
)
.await?
{
Response::Retired(result) => result,
_ => Err("Host retirement response mismatch".into()),
}
}
}
/// An immutable physical target captured from an authenticated Client. The
/// embedding must fence native calls before detaching that Client's anchor.
#[derive(Clone)]
pub struct CallChannelOpener {
address: PathBuf,
epoch: Uuid,
session: Uuid,
}
impl CallChannelOpener {
pub async fn open(&self) -> Result<BufReader<LocalStream>> {
let mut stream = ipc::connect(&self.address)
.await
.map_err(|error| error.to_string())?;
wire::write(
&mut stream,
&Opening {
client_instance: None,
version: VERSION,
epoch: Some(self.epoch),
request: Request::Call {
session: self.session,
},
},
)
.await?;
let mut reader = wire::Reader::new(stream);
if !matches!(reader.read::<Response>().await?, Response::Accepted) {
return Err("Host did not accept application channel".into());
}
Ok(reader.inner)
}
}
async fn control(address: &Path, epoch: Option<Uuid>, request: Request) -> Result<Response> {
let stream = ipc::connect(address)
.await
.map_err(|error| error.to_string())?;
control_stream(stream, epoch, request).await
}
async fn control_stream(
mut stream: LocalStream,
epoch: Option<Uuid>,
request: Request,
) -> Result<Response> {
wire::write(
&mut stream,
&Opening {
client_instance: None,
version: VERSION,
epoch,
request,
},
)
.await?;
wire::Reader::new(stream).read().await
}