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