1use anyhow::Result;
3use serde::{Deserialize, Serialize};
4use tracing::{info, error};
5
6use crate::sidecar_service::{SidecarState, SidecarConfig, PodInfo, extract_token_value};
7use crate::nostr::{EncryptedSpawnPodRequest, EncryptedTopUpPodRequest, PodSpec};
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct SpawnPodTool {
12 pub cashu_token: String,
13 pub pod_spec_id: Option<String>,
14 pub pod_image: String,
15 pub ssh_username: String,
16 pub ssh_password: String,
17 pub user_pubkey: Option<String>,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct TopUpPodTool {
23 pub pod_npub: String,
24 pub cashu_token: String,
25}
26
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct GetOffersTool {}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct GetPodStatusTool {
35 pub pod_npub: String,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct SpawnPodResponse {
41 pub success: bool,
42 pub message: String,
43 pub pod_npub: Option<String>,
44 pub ssh_host: Option<String>,
45 pub ssh_port: Option<u16>,
46 pub ssh_username: Option<String>,
47 pub ssh_password: Option<String>,
48 pub expires_at: Option<String>,
49 pub pod_spec_name: Option<String>,
50 pub cpu_millicores: Option<u64>,
51 pub memory_mb: Option<u64>,
52 pub instructions: Vec<String>,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct TopUpPodResponse {
58 pub success: bool,
59 pub message: String,
60 pub pod_npub: String,
61 pub extended_duration_seconds: Option<u64>,
62 pub new_expires_at: Option<String>,
63}
64
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct GetOffersResponse {
69 pub minimum_duration_seconds: u64,
70 pub whitelisted_mints: Vec<String>,
71 pub pod_specs: Vec<PodSpec>,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct GetPodStatusResponse {
77 pub success: bool,
78 pub message: String,
79 pub pod_npub: String,
80 pub found: bool,
81 pub created_at: Option<String>,
82 pub expires_at: Option<String>,
83 pub time_remaining_seconds: Option<u64>,
84 pub pod_spec_name: Option<String>,
85 pub cpu_millicores: Option<u64>,
86 pub memory_mb: Option<u64>,
87 pub status: Option<String>,
88}
89
90pub struct PodProvisioningService {
92 state: SidecarState,
93}
94
95impl PodProvisioningService {
96 pub async fn new(config: SidecarConfig) -> Result<Self> {
97 let state = SidecarState::new(config).await
98 .map_err(|e| anyhow::anyhow!("Failed to initialize sidecar state: {}", e))?;
99
100 Ok(Self { state })
101 }
102
103 pub fn get_config(&self) -> &SidecarConfig {
105 &self.state.config
106 }
107
108 pub async fn spawn_pod(&self, request: SpawnPodTool) -> Result<SpawnPodResponse> {
110 info!("Pod spawn request received for image: {}", request.pod_image);
111
112 let spawn_request = EncryptedSpawnPodRequest {
114 cashu_token: request.cashu_token,
115 pod_spec_id: request.pod_spec_id,
116 pod_image: request.pod_image,
117 ssh_username: request.ssh_username,
118 ssh_password: request.ssh_password,
119 };
120
121 match self.handle_spawn_pod_internal(spawn_request, &request.user_pubkey.unwrap_or_else(|| "mcp-client".to_string())).await {
123 Ok(response) => Ok(response),
124 Err(e) => {
125 error!("Failed to spawn pod: {}", e);
126 Ok(SpawnPodResponse {
127 success: false,
128 message: format!("Failed to spawn pod: {}", e),
129 pod_npub: None,
130 ssh_host: None,
131 ssh_port: None,
132 ssh_username: None,
133 ssh_password: None,
134 expires_at: None,
135 pod_spec_name: None,
136 cpu_millicores: None,
137 memory_mb: None,
138 instructions: vec![],
139 })
140 }
141 }
142 }
143
144 pub async fn topup_pod(&self, request: TopUpPodTool) -> Result<TopUpPodResponse> {
146 info!("Pod top-up request received for NPUB: {}", request.pod_npub);
147
148 let topup_request = EncryptedTopUpPodRequest {
150 pod_npub: request.pod_npub.clone(),
151 cashu_token: request.cashu_token,
152 };
153
154 match self.handle_topup_pod_internal(topup_request).await {
156 Ok(response) => Ok(response),
157 Err(e) => {
158 error!("Failed to top-up pod: {}", e);
159 Ok(TopUpPodResponse {
160 success: false,
161 message: format!("Failed to top-up pod: {}", e),
162 pod_npub: request.pod_npub,
163 extended_duration_seconds: None,
164 new_expires_at: None,
165 })
166 }
167 }
168 }
169
170
171 pub async fn get_offers(&self, _request: GetOffersTool) -> Result<GetOffersResponse> {
173 info!("Get offers request received");
174
175 Ok(GetOffersResponse {
176 minimum_duration_seconds: self.state.config.minimum_pod_duration_seconds,
177 whitelisted_mints: self.state.config.whitelisted_mints.clone(),
178 pod_specs: self.state.config.pod_specs.clone(),
179 })
180 }
181
182 pub async fn get_pod_status(&self, request: GetPodStatusTool) -> Result<GetPodStatusResponse> {
184 info!("Get pod status request received for NPUB: {}", request.pod_npub);
185
186 use kube::{Api, api::ListParams};
187 use k8s_openapi::api::core::v1::Pod;
188 use chrono::Utc;
189
190 let active_pods = self.state.active_pods.read().await;
192 let pod_info = active_pods.get(&request.pod_npub);
193
194 if let Some(info) = pod_info {
195 let now = Utc::now();
197 let time_remaining = if info.expires_at > now {
198 Some((info.expires_at - now).num_seconds() as u64)
199 } else {
200 Some(0) };
202
203 let pod_spec = self.state.config.pod_specs.first(); Ok(GetPodStatusResponse {
207 success: true,
208 message: "Pod found and active".to_string(),
209 pod_npub: request.pod_npub.clone(),
210 found: true,
211 created_at: Some(info.created_at.to_rfc3339()),
212 expires_at: Some(info.expires_at.to_rfc3339()),
213 time_remaining_seconds: time_remaining,
214 pod_spec_name: pod_spec.map(|s| s.name.clone()),
215 cpu_millicores: pod_spec.map(|s| s.cpu_millicores),
216 memory_mb: pod_spec.map(|s| s.memory_mb),
217 status: Some(if time_remaining.unwrap_or(0) > 0 { "running".to_string() } else { "expired".to_string() }),
218 })
219 } else {
220 let pods_api: Api<Pod> = Api::namespaced(self.state.k8s_client.client.clone(), &self.state.config.pod_namespace);
222 let pods = match pods_api.list(&ListParams::default()).await {
223 Ok(pods) => pods,
224 Err(_) => {
225 return Ok(GetPodStatusResponse {
227 success: true,
228 message: "Pod not found".to_string(),
229 pod_npub: request.pod_npub.clone(),
230 found: false,
231 created_at: None,
232 expires_at: None,
233 time_remaining_seconds: None,
234 pod_spec_name: None,
235 cpu_millicores: None,
236 memory_mb: None,
237 status: None,
238 });
239 }
240 };
241
242 let target_pod = pods.items.iter().find(|pod| {
244 pod.metadata.labels.as_ref()
245 .and_then(|labels| labels.get("pod-npub"))
246 .map(|stored_hex| {
247 let requested_hex = if request.pod_npub.starts_with("npub1") {
248 &request.pod_npub[5..]
249 } else {
250 &request.pod_npub
251 };
252 let stored_truncated = if stored_hex.len() > 63 {
253 &stored_hex[..63]
254 } else {
255 stored_hex
256 };
257 let requested_truncated = if requested_hex.len() > 63 {
258 &requested_hex[..63]
259 } else {
260 requested_hex
261 };
262 stored_truncated == requested_truncated
263 })
264 .unwrap_or(false)
265 });
266
267 if let Some(pod) = target_pod {
268 let status = pod.status.as_ref()
270 .and_then(|status| status.phase.as_ref())
271 .cloned()
272 .unwrap_or_else(|| "unknown".to_string());
273
274 Ok(GetPodStatusResponse {
275 success: true,
276 message: "Pod found but not tracked (likely expired)".to_string(),
277 pod_npub: request.pod_npub.clone(),
278 found: true,
279 created_at: pod.metadata.creation_timestamp.as_ref()
280 .map(|ts| ts.0.to_rfc3339()),
281 expires_at: None,
282 time_remaining_seconds: Some(0), pod_spec_name: None,
284 cpu_millicores: None,
285 memory_mb: None,
286 status: Some(status),
287 })
288 } else {
289 Ok(GetPodStatusResponse {
291 success: true,
292 message: "Pod not found".to_string(),
293 pod_npub: request.pod_npub.clone(),
294 found: false,
295 created_at: None,
296 expires_at: None,
297 time_remaining_seconds: None,
298 pod_spec_name: None,
299 cpu_millicores: None,
300 memory_mb: None,
301 status: None,
302 })
303 }
304 }
305 }
306
307 async fn handle_spawn_pod_internal(&self, request: EncryptedSpawnPodRequest, user_pubkey: &str) -> Result<SpawnPodResponse> {
309 use chrono::Utc;
310 use nostr_sdk::{Keys, ToBech32};
311
312 let pod_spec = if let Some(spec_id) = &request.pod_spec_id {
314 self.state.config.pod_specs.iter().find(|s| s.id == *spec_id)
315 } else {
316 self.state.config.pod_specs.first()
317 };
318
319 let pod_spec = match pod_spec {
320 Some(spec) => spec,
321 None => {
322 return Ok(SpawnPodResponse {
323 success: false,
324 message: format!("Pod specification '{}' not found", request.pod_spec_id.as_deref().unwrap_or("default")),
325 pod_npub: None,
326 ssh_host: None,
327 ssh_port: None,
328 ssh_username: None,
329 ssh_password: None,
330 expires_at: None,
331 pod_spec_name: None,
332 cpu_millicores: None,
333 memory_mb: None,
334 instructions: vec!["Please check available specifications in the offer".to_string()],
335 });
336 }
337 };
338
339 let payment_amount_msats = match extract_token_value(&request.cashu_token).await {
341 Ok(msats) => msats,
342 Err(e) => {
343 return Ok(SpawnPodResponse {
344 success: false,
345 message: "Failed to decode Cashu token".to_string(),
346 pod_npub: None,
347 ssh_host: None,
348 ssh_port: None,
349 ssh_username: None,
350 ssh_password: None,
351 expires_at: None,
352 pod_spec_name: None,
353 cpu_millicores: None,
354 memory_mb: None,
355 instructions: vec![format!("Token decode error: {}", e)],
356 });
357 }
358 };
359
360 let minimum_payment = self.state.config.minimum_pod_duration_seconds * pod_spec.rate_msats_per_sec;
362 if payment_amount_msats < minimum_payment {
363 return Ok(SpawnPodResponse {
364 success: false,
365 message: format!("Insufficient payment: {} msats", payment_amount_msats),
366 pod_npub: None,
367 ssh_host: None,
368 ssh_port: None,
369 ssh_username: None,
370 ssh_password: None,
371 expires_at: None,
372 pod_spec_name: Some(pod_spec.name.clone()),
373 cpu_millicores: Some(pod_spec.cpu_millicores),
374 memory_mb: Some(pod_spec.memory_mb),
375 instructions: vec![
376 format!("Minimum required: {} msats for {} seconds with {} spec (rate: {} msats/sec)",
377 minimum_payment,
378 self.state.config.minimum_pod_duration_seconds,
379 pod_spec.name,
380 pod_spec.rate_msats_per_sec)
381 ],
382 });
383 }
384
385 let duration_seconds = payment_amount_msats / pod_spec.rate_msats_per_sec;
387
388 info!("✅ Using payment: {} msats for {} seconds (verified by ngx_l402)", payment_amount_msats, duration_seconds);
390
391 let pod_keys = Keys::generate();
393 let pod_npub = pod_keys.public_key().to_bech32().unwrap();
394 let pod_nsec = pod_keys.secret_key().unwrap().to_secret_hex();
395
396 let pod_name = format!("pod-{}", pod_npub.replace("npub1", "").chars().take(8).collect::<String>());
398 let username = request.ssh_username;
399 let password = request.ssh_password;
400 let image = request.pod_image;
401 let ssh_port = match self.state.generate_ssh_port().await {
402 Ok(port) => port,
403 Err(e) => {
404 return Ok(SpawnPodResponse {
405 success: false,
406 message: "Failed to allocate SSH port".to_string(),
407 pod_npub: None,
408 ssh_host: None,
409 ssh_port: None,
410 ssh_username: None,
411 ssh_password: None,
412 expires_at: None,
413 pod_spec_name: Some(pod_spec.name.clone()),
414 cpu_millicores: Some(pod_spec.cpu_millicores),
415 memory_mb: Some(pod_spec.memory_mb),
416 instructions: vec![format!("Port allocation error: {}", e)],
417 });
418 }
419 };
420
421 let now = Utc::now();
422 let expires_at = now + chrono::Duration::seconds(duration_seconds as i64);
423
424 match self.state.k8s_client.create_ssh_pod(
425 &self.state.config,
426 &self.state.config.pod_namespace,
427 &pod_name,
428 &pod_npub,
429 &pod_nsec,
430 &image,
431 ssh_port,
432 &username,
433 &password,
434 duration_seconds,
435 pod_spec.memory_mb,
436 pod_spec.cpu_millicores,
437 user_pubkey,
438 ).await {
439 Ok(node_port) => {
440 let pod_info = PodInfo {
441 pod_npub: pod_npub.clone(),
442 namespace: self.state.config.pod_namespace.clone(),
443 created_at: now,
444 expires_at,
445 allocated_port: ssh_port,
446 ssh_username: username.clone(),
447 ssh_password: password.clone(),
448 payment_amount_msats,
449 duration_seconds,
450 node_port: Some(node_port),
451 nostr_public_key: pod_npub.clone(),
452 nostr_private_key: pod_nsec,
453 };
454 self.state.active_pods.write().await.insert(pod_npub.clone(), pod_info.clone());
455
456 let instructions = vec![
457 "🚀 SSH access available:".to_string(),
458 "".to_string(),
459 "Direct access (no kubectl needed):".to_string(),
460 format!(" ssh -o PreferredAuthentications=password -o PubkeyAuthentication=no {}@{} -p {}", username, self.state.config.ssh_host, node_port),
461 "".to_string(),
462 "⚠️ Pod expires at:".to_string(),
463 format!(" {}", expires_at.format("%Y-%m-%d %H:%M:%S UTC")),
464 "".to_string(),
465 "📋 Pod Details:".to_string(),
466 format!(" Pod NPUB: {}", pod_npub),
467 format!(" Specification: {} ({})", pod_spec.name, pod_spec.description),
468 format!(" CPU: {} millicores", pod_spec.cpu_millicores),
469 format!(" Memory: {} MB", pod_spec.memory_mb),
470 format!(" Duration: {} seconds", duration_seconds),
471 ];
472
473 info!("Pod with NPUB {} created successfully", pod_npub);
474
475 Ok(SpawnPodResponse {
476 success: true,
477 message: format!("Pod created successfully. SSH access available for {} seconds", duration_seconds),
478 pod_npub: Some(pod_npub),
479 ssh_host: Some(self.state.config.ssh_host.clone()),
480 ssh_port: Some(node_port),
481 ssh_username: Some(username),
482 ssh_password: Some(password),
483 expires_at: Some(expires_at.to_rfc3339()),
484 pod_spec_name: Some(pod_spec.name.clone()),
485 cpu_millicores: Some(pod_spec.cpu_millicores),
486 memory_mb: Some(pod_spec.memory_mb),
487 instructions,
488 })
489 }
490 Err(e) => {
491 Ok(SpawnPodResponse {
492 success: false,
493 message: "Failed to create pod".to_string(),
494 pod_npub: None,
495 ssh_host: None,
496 ssh_port: None,
497 ssh_username: None,
498 ssh_password: None,
499 expires_at: None,
500 pod_spec_name: Some(pod_spec.name.clone()),
501 cpu_millicores: Some(pod_spec.cpu_millicores),
502 memory_mb: Some(pod_spec.memory_mb),
503 instructions: vec![format!("Pod creation error: {}", e)],
504 })
505 }
506 }
507 }
508
509 async fn handle_topup_pod_internal(&self, request: EncryptedTopUpPodRequest) -> Result<TopUpPodResponse> {
511 use kube::{Api, api::ListParams};
512 use k8s_openapi::api::core::v1::Pod;
513 use chrono::Utc;
514
515 info!("Pod top-up request received for NPUB: {}", request.pod_npub);
516
517 let pods_api: Api<Pod> = Api::namespaced(self.state.k8s_client.client.clone(), &self.state.config.pod_namespace);
519 let pods = match pods_api.list(&ListParams::default()).await {
520 Ok(pods) => pods,
521 Err(e) => {
522 error!("Failed to list pods: {}", e);
523 return Ok(TopUpPodResponse {
524 success: false,
525 message: format!("Failed to list pods: {}", e),
526 pod_npub: request.pod_npub,
527 extended_duration_seconds: None,
528 new_expires_at: None,
529 });
530 }
531 };
532
533 let target_pod = match pods.items.iter().find(|pod| {
535 pod.metadata.labels.as_ref()
536 .and_then(|labels| labels.get("pod-npub"))
537 .map(|stored_hex| {
538 let requested_hex = if request.pod_npub.starts_with("npub1") {
540 &request.pod_npub[5..] } else {
542 &request.pod_npub };
544 let stored_truncated = if stored_hex.len() > 63 {
546 &stored_hex[..63]
547 } else {
548 stored_hex
549 };
550 let requested_truncated = if requested_hex.len() > 63 {
551 &requested_hex[..63]
552 } else {
553 requested_hex
554 };
555 stored_truncated == requested_truncated
556 })
557 .unwrap_or(false)
558 }) {
559 Some(pod) => pod,
560 None => {
561 return Ok(TopUpPodResponse {
562 success: false,
563 message: format!("Pod with NPUB '{}' not found or already expired", request.pod_npub),
564 pod_npub: request.pod_npub,
565 extended_duration_seconds: None,
566 new_expires_at: None,
567 });
568 }
569 };
570
571 let pod_name = match &target_pod.metadata.name {
573 Some(name) => name.clone(),
574 None => {
575 return Ok(TopUpPodResponse {
576 success: false,
577 message: "Pod has no name in metadata".to_string(),
578 pod_npub: request.pod_npub,
579 extended_duration_seconds: None,
580 new_expires_at: None,
581 });
582 }
583 };
584
585 let payment_amount_msats = match extract_token_value(&request.cashu_token).await {
587 Ok(msats) => msats,
588 Err(e) => {
589 return Ok(TopUpPodResponse {
590 success: false,
591 message: format!("Failed to decode Cashu token: {}", e),
592 pod_npub: request.pod_npub,
593 extended_duration_seconds: None,
594 new_expires_at: None,
595 });
596 }
597 };
598
599 let additional_duration_seconds = self.state.calculate_duration_from_payment(payment_amount_msats);
601
602 if additional_duration_seconds == 0 {
603 return Ok(TopUpPodResponse {
604 success: false,
605 message: format!("Insufficient payment: {} msats. Minimum required: {} msats for 1 second extension",
606 payment_amount_msats, self.state.config.pod_specs.first().map(|s| s.rate_msats_per_sec).unwrap_or(100)),
607 pod_npub: request.pod_npub,
608 extended_duration_seconds: None,
609 new_expires_at: None,
610 });
611 }
612
613 info!("✅ Top-up payment: {} msats for {} additional seconds (verified by ngx_l402)", payment_amount_msats, additional_duration_seconds);
615
616 let pods_api: Api<Pod> = Api::namespaced(self.state.k8s_client.client.clone(), &self.state.config.pod_namespace);
619 let current_pod = match pods_api.get(&pod_name).await {
620 Ok(pod) => pod,
621 Err(e) => {
622 error!("Failed to get current pod configuration: {}", e);
623 return Ok(TopUpPodResponse {
624 success: false,
625 message: format!("Failed to get pod configuration: {}", e),
626 pod_npub: request.pod_npub,
627 extended_duration_seconds: None,
628 new_expires_at: None,
629 });
630 }
631 };
632
633 if let Err(e) = self.state.k8s_client.extend_pod_deadline(&self.state.config.pod_namespace, &pod_name, additional_duration_seconds).await {
635 error!("Failed to extend pod deadline: {}", e);
636 return Ok(TopUpPodResponse {
637 success: false,
638 message: format!("Failed to extend pod deadline: {}", e),
639 pod_npub: request.pod_npub,
640 extended_duration_seconds: None,
641 new_expires_at: None,
642 });
643 }
644
645 let current_deadline_seconds = current_pod
647 .spec
648 .as_ref()
649 .and_then(|spec| spec.active_deadline_seconds)
650 .unwrap_or(0);
651
652 let new_expires_at = match ¤t_pod.metadata.creation_timestamp {
654 Some(creation_time) => {
655 let creation_utc = creation_time.0;
656 let new_deadline_seconds = current_deadline_seconds + additional_duration_seconds as i64;
657 creation_utc + chrono::Duration::seconds(new_deadline_seconds)
658 }
659 None => Utc::now() + chrono::Duration::seconds(additional_duration_seconds as i64), };
661
662 let mut active_pods = self.state.active_pods.write().await;
664 if let Some(pod_info) = active_pods.get_mut(&request.pod_npub) {
665 pod_info.expires_at = new_expires_at;
666 pod_info.duration_seconds = current_deadline_seconds as u64 + additional_duration_seconds;
667 }
668 drop(active_pods);
669
670 info!(
671 "🔄 Pod '{}' (NPUB: {}) extended by {} seconds (new deadline: {} seconds)",
672 pod_name,
673 request.pod_npub,
674 additional_duration_seconds,
675 current_deadline_seconds + additional_duration_seconds as i64
676 );
677
678 Ok(TopUpPodResponse {
679 success: true,
680 message: format!(
681 "Pod '{}' (NPUB: {}) successfully extended by {} seconds. New expiration: {}",
682 pod_name,
683 request.pod_npub,
684 additional_duration_seconds,
685 new_expires_at.format("%Y-%m-%d %H:%M:%S UTC")
686 ),
687 pod_npub: request.pod_npub,
688 extended_duration_seconds: Some(additional_duration_seconds),
689 new_expires_at: Some(new_expires_at.to_rfc3339()),
690 })
691 }
692}