1use super::super::state::FrameAction;
4use liminal::protocol::{CONVERSATION_REPLY_REQUESTED_FLAG, Frame, MessageEnvelope};
5use serde::Deserialize;
6use serde_json::Value;
7use std::sync::Arc;
8
9use super::LiminalConnectionServices;
10use crate::auth_lys::session::LysSession;
11use crate::auth_lys::{AccessAction, AccessResource, HotAction, SubjectBinding};
12use crate::services::lifecycle::{Lifecycle, plan, site};
13use site::{SiteChange, SiteError};
14
15pub trait SiteDraftHolder: Send + Sync + std::fmt::Debug {
20 fn hold(
25 &self,
26 lifecycle: &Lifecycle,
27 session: &dyn LysSession,
28 operation: &str,
29 target: &str,
30 change: SiteChange,
31 at_ms: u64,
32 ) -> Result<Value, SiteError>;
33}
34
35#[derive(Deserialize)]
36#[serde(deny_unknown_fields)]
37struct Request {
38 operation: String,
39 payload: Value,
40}
41
42#[derive(Deserialize)]
43#[serde(deny_unknown_fields)]
44struct PlanSet {
45 service: String,
46 steps: Vec<Title>,
47}
48#[derive(Deserialize)]
49#[serde(deny_unknown_fields)]
50struct Title {
51 title: String,
52}
53#[derive(Deserialize)]
54#[serde(deny_unknown_fields)]
55struct PlanStep {
56 service: String,
57 step: usize,
58 status: plan::StepStatus,
59 note: Option<String>,
60}
61#[derive(Deserialize)]
62#[serde(deny_unknown_fields)]
63struct Propose {
64 service: String,
65 command: std::path::PathBuf,
66 args: Vec<String>,
67 env: Vec<String>,
68 stop_timeout_ms: u64,
69}
70#[derive(Deserialize)]
71#[serde(deny_unknown_fields)]
72struct Bind {
73 service: String,
74 agent: String,
75}
76#[derive(Deserialize)]
77#[serde(deny_unknown_fields)]
78struct Target {
79 service: String,
80}
81
82#[must_use]
84pub fn is_site_route(subject: &str) -> bool {
85 matches!(
86 subject,
87 "services.create_site"
88 | "services.bind_builder"
89 | "services.unbind_builder"
90 | "services.plan_set"
91 | "services.plan_step"
92 | "services.propose_template"
93 | "services.complete"
94 | "templates.update"
95 | "templates.retire"
96 )
97}
98
99fn parse<T: serde::de::DeserializeOwned>(value: Value) -> Result<T, SiteError> {
100 serde_json::from_value(value).map_err(|_| SiteError::Invalid("construction payload"))
101}
102
103pub fn evaluate_subject<'a>(
109 pass: &'a lys_pass::VerifiedPass,
110 resource: &AccessResource,
111 action: AccessAction,
112 now: u64,
113) -> Result<lys_pass::Decision<'a>, lys_pass::Error> {
114 if resource.kind == crate::auth_lys::ResourceKind::Subject && action == AccessAction::Publish {
115 if let Some(name) = plan_subject(&resource.id) {
116 return pass.evaluate("liminal.service", name, "plan", now);
117 }
118 }
119 crate::agents::binding::evaluate_subject(pass, resource, action, now)
120}
121
122#[must_use]
124pub fn plan_subject(subject: &str) -> Option<&str> {
125 let name = subject.strip_prefix("services.")?.strip_suffix(".plan")?;
126 crate::config::templates::is_segment(name).then_some(name)
127}
128
129fn authorize_plan(
130 session: &dyn LysSession,
131 lifecycle: &Lifecycle,
132 service: &str,
133 at_ms: u64,
134) -> Result<(), SiteError> {
135 let record = lifecycle.record(service)?;
136 if session.holder_kind() != Some("agent") {
137 return Err(SiteError::Refused(format!(
138 "pass holder {} is not the builder of {}",
139 session.holder(),
140 record.name
141 )));
142 }
143 lifecycle.check_plan_holder(&record, session.holder())?;
144 let subject = format!("services.{}.plan", record.name);
145 let binding = SubjectBinding::new(lifecycle.network_id().into(), subject.clone(), subject)
146 .map_err(|_| SiteError::Invalid("plan binding"))?;
147 session
148 .decide(&binding, HotAction::Publish, at_ms / 1000)
149 .map_err(|error| SiteError::Pass(error.to_string()))?;
150 Ok(())
151}
152
153fn prepare_change(
154 lifecycle: &Lifecycle,
155 subject: &str,
156 payload: Value,
157 limits: &site::SiteLimits,
158) -> Result<(String, SiteChange), SiteError> {
159 Ok(match subject {
160 "services.create_site" => {
161 let spec: site::SiteSpec = parse(payload)?;
162 site::validate_contracts(&spec, limits)?;
163 (spec.name.clone(), SiteChange::CreateSite(spec))
164 }
165 "services.bind_builder" => {
166 let body: Bind = parse(payload)?;
167 (body.service, SiteChange::BindBuilder { agent: body.agent })
168 }
169 "services.unbind_builder" => {
170 let body: Target = parse(payload)?;
171 (body.service, SiteChange::UnbindBuilder)
172 }
173 "services.complete" => {
174 let body: Target = parse(payload)?;
175 let record = lifecycle
177 .record(&body.service)
178 .map_err(|_| SiteError::Refused(format!("unknown_service: {}", body.service)))?;
179 plan::refuse_unfinished(&record.plan)?;
180 (body.service, SiteChange::Complete)
181 }
182 "services.propose_template" | "templates.update" => {
183 let body: Propose = parse(payload)?;
184 let proposal = if subject == "templates.update" {
185 lifecycle.prepare_template_update(
186 &body.service,
187 &body.command,
188 body.args,
189 body.env,
190 body.stop_timeout_ms,
191 )?
192 } else {
193 lifecycle.propose_template(
194 &body.service,
195 &body.command,
196 body.args,
197 body.env,
198 body.stop_timeout_ms,
199 )?
200 };
201 let target = proposal.name.clone();
202 (
203 target,
204 if subject == "templates.update" {
205 SiteChange::UpdateTemplate(proposal)
206 } else {
207 SiteChange::CreateTemplate(proposal)
208 },
209 )
210 }
211 "templates.retire" => {
212 let body: Target = parse(payload)?;
213 (body.service, SiteChange::RetireTemplate)
214 }
215 _ => return Err(SiteError::Invalid("construction route")),
216 })
217}
218
219impl LiminalConnectionServices {
220 pub fn install_construction(
226 &self,
227 lifecycle: Arc<Lifecycle>,
228 lys: &crate::config::types::LysConfig,
229 http: Arc<dyn crate::services::lys_http::LysHttp>,
230 drafts: Arc<dyn SiteDraftHolder>,
231 ) -> Result<(), SiteError> {
232 let config = lys
233 .construction
234 .as_ref()
235 .ok_or(SiteError::Invalid("[auth.lys.construction]"))?;
236 let agents = self
237 .agents
238 .as_ref()
239 .ok_or(SiteError::Invalid("[auth.lys.agents]"))?;
240 let pass = lys
241 .machine
242 .as_ref()
243 .map(crate::link_pass::MachinePass::configured)
244 .transpose()
245 .map_err(|_| SiteError::Invalid("construction machine pass"))?;
246 let effects = Arc::new(site::effects::LysSiteEffects::new(
247 http,
248 pass,
249 Arc::clone(agents),
250 site::effects::GrantSources {
251 placement: lys
252 .app_credential_path
253 .clone()
254 .map(|path| (path, lys.network_id.clone())),
255 ..config.grants.clone()
256 },
257 config.limits.text_bytes,
258 ));
259 let mut bounds = *agents.bounds();
260 bounds.max_event_bytes = config.limits.request_bytes.get();
261 let retained = crate::agents::retained::Retained::new(
262 bounds,
263 self.cluster.supervisor().clone(),
264 self.console.device(),
265 );
266 lifecycle.configure_sites(
267 config.limits.clone(),
268 self.durable_store(),
269 lys.templates.policy.clone(),
270 effects,
271 retained,
272 )?;
273 lifecycle.validate_site_storage()?;
274 lifecycle.bind_site_console(&self.console)?;
275 self.site_drafts
276 .set(drafts)
277 .map_err(|_| SiteError::Invalid("construction drafts already installed"))?;
278 self.site_lifecycle
279 .set(lifecycle)
280 .map_err(|_| SiteError::Invalid("construction already installed"))?;
281 Ok(())
282 }
283
284 pub fn serve_site_tool(
289 &self,
290 session: Option<&dyn LysSession>,
291 subject: &str,
292 bytes: &[u8],
293 at_ms: u64,
294 ) -> Result<Value, SiteError> {
295 if !is_site_route(subject) {
296 return Err(SiteError::Invalid("construction route"));
297 }
298 let lifecycle = self
299 .site_lifecycle
300 .get()
301 .ok_or(SiteError::Invalid("construction not installed"))?;
302 let session = session.ok_or(SiteError::Invalid(
303 "construction needs an admitted Lys pass",
304 ))?;
305 let limits = &lifecycle.construction_limits()?;
306 if bytes.len() > limits.request_bytes.get() {
307 return Err(SiteError::Bound("request_bytes"));
308 }
309 let request: Request = serde_json::from_slice(bytes)
310 .map_err(|_| SiteError::Invalid("construction request"))?;
311 if request.operation.is_empty()
312 || request.operation.len() > limits.text_bytes.get()
313 || request.operation.chars().any(char::is_control)
314 {
315 return Err(SiteError::Invalid("operation"));
316 }
317 let (target, change) = match subject {
318 "services.plan_set" => {
319 let body: PlanSet = parse(request.payload)?;
320 authorize_plan(session, lifecycle, &body.service, at_ms)?;
321 let titles = body
322 .steps
323 .into_iter()
324 .map(|step| step.title)
325 .collect::<Vec<_>>();
326 let plan = lifecycle.plan_set(&body.service, session.holder(), &titles, at_ms)?;
327 self.construction_changed()?;
328 return Ok(plan::current_value(&plan));
329 }
330 "services.plan_step" => {
331 let body: PlanStep = parse(request.payload)?;
332 authorize_plan(session, lifecycle, &body.service, at_ms)?;
333 let plan = lifecycle.plan_step(
334 &body.service,
335 session.holder(),
336 body.step,
337 body.status,
338 body.note,
339 at_ms,
340 )?;
341 self.construction_changed()?;
342 return Ok(plan::current_value(&plan));
343 }
344 _ => prepare_change(lifecycle, subject, request.payload, limits)?,
345 };
346 self.site_drafts
347 .get()
348 .ok_or(SiteError::Invalid("construction approval owner absent"))?
349 .hold(
350 lifecycle,
351 session,
352 &request.operation,
353 &target,
354 change,
355 at_ms,
356 )
357 }
358
359 fn construction_changed(&self) -> Result<(), SiteError> {
360 self.site_lifecycle
361 .get()
362 .ok_or(SiteError::Invalid("construction not installed"))?
363 .refresh_sites()
364 }
365
366 pub(super) fn subscribe_site_plan(
367 &self,
368 session: Option<&dyn LysSession>,
369 channel: &str,
370 schemas: &[super::ProtocolSchemaId],
371 install: Option<liminal::channel::InboxInstall>,
372 ) -> Result<super::ConnectionSubscription, crate::ServerError> {
373 let name =
374 plan_subject(channel).ok_or_else(|| site_error(SiteError::Invalid("plan subject")))?;
375 let lifecycle = self
376 .site_lifecycle
377 .get()
378 .ok_or_else(|| site_error(SiteError::Invalid("construction not installed")))?;
379 lifecycle
380 .record(name)
381 .map_err(|error| site_error(error.into()))?;
382 let session =
383 session.ok_or_else(|| site_error(SiteError::Invalid("plan watch needs a pass")))?;
384 let binding = SubjectBinding::new(
385 lifecycle.network_id().into(),
386 channel.into(),
387 channel.into(),
388 )
389 .map_err(|_| site_error(SiteError::Invalid("plan watch binding")))?;
390 session
391 .decide(
392 &binding,
393 HotAction::Subscribe,
394 super::super::apply::unix_seconds_now(),
395 )
396 .map_err(|error| site_error(SiteError::Pass(error.to_string())))?;
397 let id = self
398 .next_subscription_id
399 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
400 lifecycle
401 .subscribe_plan(id, channel, schemas, install)
402 .map_err(site_error)
403 }
404}
405
406pub fn site_error(error: SiteError) -> crate::ServerError {
408 error.into()
409}
410impl From<SiteError> for crate::ServerError {
411 fn from(error: SiteError) -> Self {
412 Self::ListenerAccept {
413 message: error.to_string(),
414 }
415 }
416}
417
418pub(in super::super) fn site_frame_response(
420 frame: SiteFrame<'_>,
421) -> Option<super::super::state::FrameAction> {
422 let SiteFrame {
423 services,
424 session,
425 flags,
426 stream_id,
427 conversation_id,
428 subject,
429 envelope,
430 at_ms,
431 } = frame;
432 let answer = services.construction_request(session, subject, &envelope.payload, at_ms)?;
433 let refused = |message| {
434 FrameAction::Respond(Frame::ConversationError {
435 flags: 0,
436 stream_id,
437 conversation_id,
438 reason_code: super::super::service_route::SERVICE_REFUSED_CODE,
439 message: Some(message),
440 })
441 };
442 Some(match answer {
443 Err(crate::ServerError::ListenerAccept { message }) => refused(message),
444 Err(error) => refused(error.to_string()),
445 Ok(answer) => {
446 if flags & CONVERSATION_REPLY_REQUESTED_FLAG == 0 {
447 return Some(FrameAction::NoResponse);
448 }
449 serde_json::to_vec(&answer).map_or_else(
450 |_| refused("construction_reply_encoding_failed".into()),
451 |bytes| {
452 FrameAction::Respond(Frame::ConversationMessage {
453 flags: CONVERSATION_REPLY_REQUESTED_FLAG,
454 stream_id,
455 conversation_id,
456 envelope: MessageEnvelope::new(
457 envelope.schema_id,
458 envelope.causal_context.clone(),
459 bytes,
460 ),
461 })
462 },
463 )
464 }
465 })
466}
467
468#[derive(Clone, Copy)]
470pub(in super::super) struct SiteFrame<'a> {
471 pub services: &'a dyn super::ConnectionServices,
472 pub session: Option<&'a dyn LysSession>,
473 pub flags: u8,
474 pub stream_id: u32,
475 pub conversation_id: u64,
476 pub subject: &'a str,
477 pub envelope: &'a liminal::protocol::MessageEnvelope,
478 pub at_ms: u64,
479}