1#![allow(dead_code)]
8
9use alloc::{collections::BTreeMap, string::String, vec::Vec};
10
11#[derive(Debug)]
17pub struct MetadataService {
18 base_url: String,
20 cache: BTreeMap<String, String>,
22}
23
24impl Default for MetadataService {
25 fn default() -> Self {
26 Self::new()
27 }
28}
29
30impl MetadataService {
31 pub const DEFAULT_BASE_URL: &'static str = "169.254.169.254";
33
34 pub fn new() -> Self {
36 MetadataService {
37 base_url: String::from(Self::DEFAULT_BASE_URL),
38 cache: BTreeMap::new(),
39 }
40 }
41
42 pub fn with_url(base_url: String) -> Self {
44 MetadataService {
45 base_url,
46 cache: BTreeMap::new(),
47 }
48 }
49
50 pub fn get_instance_id(&self) -> Option<&str> {
52 self.cache.get("instance-id").map(|s| s.as_str())
53 }
54
55 pub fn get_hostname(&self) -> Option<&str> {
57 self.cache.get("hostname").map(|s| s.as_str())
58 }
59
60 pub fn get_public_keys(&self) -> Vec<&str> {
62 self.cache
63 .iter()
64 .filter(|(k, _)| k.starts_with("public-key-"))
65 .map(|(_, v)| v.as_str())
66 .collect()
67 }
68
69 pub fn get(&self, key: &str) -> Option<&str> {
71 self.cache.get(key).map(|s| s.as_str())
72 }
73
74 pub fn set(&mut self, key: String, value: String) {
76 self.cache.insert(key, value);
77 }
78
79 pub fn base_url(&self) -> &str {
81 &self.base_url
82 }
83
84 pub fn fetch_metadata(&mut self) -> Result<(), CloudInitError> {
89 if self.cache.is_empty() {
91 self.cache
92 .insert(String::from("instance-id"), String::from("i-0000000000"));
93 self.cache
94 .insert(String::from("hostname"), String::from("veridian-node"));
95 self.cache
96 .insert(String::from("local-ipv4"), String::from("10.0.0.2"));
97 }
98 Ok(())
99 }
100}
101
102#[derive(Debug, Clone)]
108pub struct UserConfig {
109 pub name: String,
111 pub groups: Vec<String>,
113 pub ssh_authorized_keys: Vec<String>,
115 pub shell: String,
117 pub sudo: String,
119}
120
121impl Default for UserConfig {
122 fn default() -> Self {
123 UserConfig {
124 name: String::new(),
125 groups: Vec::new(),
126 ssh_authorized_keys: Vec::new(),
127 shell: String::from("/bin/sh"),
128 sudo: String::new(),
129 }
130 }
131}
132
133#[derive(Debug, Clone)]
135pub struct WriteFile {
136 pub path: String,
138 pub content: String,
140 pub permissions: String,
142 pub owner: String,
144}
145
146impl Default for WriteFile {
147 fn default() -> Self {
148 WriteFile {
149 path: String::new(),
150 content: String::new(),
151 permissions: String::from("0644"),
152 owner: String::from("root:root"),
153 }
154 }
155}
156
157#[derive(Debug, Clone, Default)]
159pub struct UserData {
160 pub hostname: String,
162 pub users: Vec<UserConfig>,
164 pub ssh_keys: Vec<String>,
166 pub packages: Vec<String>,
168 pub runcmd: Vec<String>,
170 pub write_files: Vec<WriteFile>,
172}
173
174impl UserData {
175 pub fn from_key_value(input: &str) -> Self {
186 let mut data = UserData::default();
187
188 for line in input.lines() {
189 let line = line.trim();
190 if line.is_empty() || line.starts_with('#') {
191 continue;
192 }
193
194 if let Some((key, value)) = line.split_once('=') {
195 let key = key.trim();
196 let value = value.trim();
197
198 match key {
199 "hostname" => data.hostname = String::from(value),
200 "ssh_key" => data.ssh_keys.push(String::from(value)),
201 "package" => data.packages.push(String::from(value)),
202 "runcmd" => data.runcmd.push(String::from(value)),
203 "user" => {
204 let parts: Vec<&str> = value.splitn(4, ':').collect();
205 let mut user = UserConfig::default();
206 if !parts.is_empty() {
207 user.name = String::from(parts[0]);
208 }
209 if parts.len() > 1 && !parts[1].is_empty() {
210 user.groups = parts[1]
211 .split(',')
212 .map(|g| String::from(g.trim()))
213 .collect();
214 }
215 if parts.len() > 2 && !parts[2].is_empty() {
216 user.shell = String::from(parts[2]);
217 }
218 if parts.len() > 3 {
219 user.sudo = String::from(parts[3]);
220 }
221 data.users.push(user);
222 }
223 "write_file" => {
224 let parts: Vec<&str> = value.splitn(4, ':').collect();
225 let mut file = WriteFile::default();
226 if !parts.is_empty() {
227 file.path = String::from(parts[0]);
228 }
229 if parts.len() > 1 && !parts[1].is_empty() {
230 file.permissions = String::from(parts[1]);
231 }
232 if parts.len() > 2 && !parts[2].is_empty() {
233 file.owner = String::from(parts[2]);
234 }
235 if parts.len() > 3 {
236 file.content = String::from(parts[3]);
237 }
238 data.write_files.push(file);
239 }
240 _ => {}
241 }
242 }
243 }
244
245 data
246 }
247}
248
249#[derive(Debug, Clone, PartialEq, Eq)]
255pub enum CloudInitError {
256 MetadataUnavailable,
258 UserDataFetchFailed,
260 HostnameApplyFailed(String),
262 UserCreateFailed(String),
264 WriteFileFailed(String),
266 CommandFailed(String),
268 PackageInstallFailed(String),
270}
271
272#[derive(Debug, Clone)]
278pub struct LogEntry {
279 pub stage: String,
281 pub success: bool,
283 pub detail: String,
285}
286
287#[derive(Debug)]
289pub struct CloudInitRunner {
290 metadata: MetadataService,
292 user_data: Option<UserData>,
294 log: Vec<LogEntry>,
296 completed: bool,
298 applied_hostname: Option<String>,
300 created_users: Vec<String>,
302 written_files: Vec<String>,
304 executed_commands: Vec<String>,
306 installed_packages: Vec<String>,
308}
309
310impl Default for CloudInitRunner {
311 fn default() -> Self {
312 Self::new()
313 }
314}
315
316impl CloudInitRunner {
317 pub fn new() -> Self {
319 CloudInitRunner {
320 metadata: MetadataService::new(),
321 user_data: None,
322 log: Vec::new(),
323 completed: false,
324 applied_hostname: None,
325 created_users: Vec::new(),
326 written_files: Vec::new(),
327 executed_commands: Vec::new(),
328 installed_packages: Vec::new(),
329 }
330 }
331
332 pub fn with_metadata(metadata: MetadataService) -> Self {
334 CloudInitRunner {
335 metadata,
336 user_data: None,
337 log: Vec::new(),
338 completed: false,
339 applied_hostname: None,
340 created_users: Vec::new(),
341 written_files: Vec::new(),
342 executed_commands: Vec::new(),
343 installed_packages: Vec::new(),
344 }
345 }
346
347 pub fn fetch_metadata(&mut self) -> Result<(), CloudInitError> {
349 self.metadata.fetch_metadata()?;
350 self.log.push(LogEntry {
351 stage: String::from("fetch_metadata"),
352 success: true,
353 detail: String::from("metadata fetched"),
354 });
355 Ok(())
356 }
357
358 pub fn fetch_userdata(&mut self, raw_data: &str) -> Result<(), CloudInitError> {
360 let data = UserData::from_key_value(raw_data);
361 self.user_data = Some(data);
362 self.log.push(LogEntry {
363 stage: String::from("fetch_userdata"),
364 success: true,
365 detail: String::from("userdata parsed"),
366 });
367 Ok(())
368 }
369
370 pub fn apply_hostname(&mut self) -> Result<(), CloudInitError> {
372 let hostname = if let Some(ref data) = self.user_data {
373 if !data.hostname.is_empty() {
374 data.hostname.clone()
375 } else {
376 self.metadata
377 .get_hostname()
378 .map(String::from)
379 .unwrap_or_else(|| String::from("veridian"))
380 }
381 } else {
382 self.metadata
383 .get_hostname()
384 .map(String::from)
385 .unwrap_or_else(|| String::from("veridian"))
386 };
387
388 self.applied_hostname = Some(hostname.clone());
389 self.log.push(LogEntry {
390 stage: String::from("apply_hostname"),
391 success: true,
392 detail: hostname,
393 });
394 Ok(())
395 }
396
397 pub fn create_users(&mut self) -> Result<(), CloudInitError> {
399 let users = match &self.user_data {
400 Some(data) => data.users.clone(),
401 None => return Ok(()),
402 };
403
404 for user in &users {
405 if user.name.is_empty() {
406 continue;
407 }
408 self.created_users.push(user.name.clone());
409 self.log.push(LogEntry {
410 stage: String::from("create_users"),
411 success: true,
412 detail: alloc::format!("created user: {}", user.name),
413 });
414 }
415 Ok(())
416 }
417
418 pub fn install_ssh_keys(&mut self) -> Result<(), CloudInitError> {
420 let keys = match &self.user_data {
421 Some(data) => &data.ssh_keys,
422 None => return Ok(()),
423 };
424
425 for key in keys {
426 self.log.push(LogEntry {
427 stage: String::from("install_ssh_keys"),
428 success: true,
429 detail: alloc::format!("installed key: {}...", &key[..key.len().min(20)]),
430 });
431 }
432 Ok(())
433 }
434
435 pub fn run_commands(&mut self) -> Result<(), CloudInitError> {
437 let commands = match &self.user_data {
438 Some(data) => data.runcmd.clone(),
439 None => return Ok(()),
440 };
441
442 for cmd in &commands {
443 self.executed_commands.push(cmd.clone());
444 self.log.push(LogEntry {
445 stage: String::from("run_commands"),
446 success: true,
447 detail: alloc::format!("ran: {}", cmd),
448 });
449 }
450 Ok(())
451 }
452
453 pub fn write_files(&mut self) -> Result<(), CloudInitError> {
455 let files = match &self.user_data {
456 Some(data) => data.write_files.clone(),
457 None => return Ok(()),
458 };
459
460 for file in &files {
461 if file.path.is_empty() {
462 continue;
463 }
464 self.written_files.push(file.path.clone());
465 self.log.push(LogEntry {
466 stage: String::from("write_files"),
467 success: true,
468 detail: alloc::format!("wrote: {} ({} bytes)", file.path, file.content.len()),
469 });
470 }
471 Ok(())
472 }
473
474 pub fn install_packages(&mut self) -> Result<(), CloudInitError> {
476 let packages = match &self.user_data {
477 Some(data) => data.packages.clone(),
478 None => return Ok(()),
479 };
480
481 for pkg in &packages {
482 self.installed_packages.push(pkg.clone());
483 self.log.push(LogEntry {
484 stage: String::from("install_packages"),
485 success: true,
486 detail: alloc::format!("installed: {}", pkg),
487 });
488 }
489 Ok(())
490 }
491
492 pub fn execute(&mut self, raw_userdata: &str) -> Result<(), CloudInitError> {
494 if self.completed {
495 return Ok(());
496 }
497
498 self.fetch_metadata()?;
499 self.fetch_userdata(raw_userdata)?;
500 self.apply_hostname()?;
501 self.create_users()?;
502 self.install_ssh_keys()?;
503 self.write_files()?;
504 self.install_packages()?;
505 self.run_commands()?;
506
507 self.completed = true;
508 self.log.push(LogEntry {
509 stage: String::from("complete"),
510 success: true,
511 detail: String::from("cloud-init complete"),
512 });
513 Ok(())
514 }
515
516 pub fn log(&self) -> &[LogEntry] {
518 &self.log
519 }
520
521 pub fn is_completed(&self) -> bool {
523 self.completed
524 }
525
526 pub fn applied_hostname(&self) -> Option<&str> {
528 self.applied_hostname.as_deref()
529 }
530
531 pub fn created_users(&self) -> &[String] {
533 &self.created_users
534 }
535
536 pub fn written_files(&self) -> &[String] {
538 &self.written_files
539 }
540
541 pub fn executed_commands(&self) -> &[String] {
543 &self.executed_commands
544 }
545
546 pub fn installed_packages(&self) -> &[String] {
548 &self.installed_packages
549 }
550}
551
552#[cfg(test)]
557mod tests {
558 #[allow(unused_imports)]
559 use alloc::string::ToString;
560
561 use super::*;
562
563 #[test]
564 fn test_metadata_service_default() {
565 let mut svc = MetadataService::new();
566 svc.fetch_metadata().unwrap();
567 assert_eq!(svc.get_instance_id(), Some("i-0000000000"));
568 assert_eq!(svc.get_hostname(), Some("veridian-node"));
569 }
570
571 #[test]
572 fn test_metadata_set_get() {
573 let mut svc = MetadataService::new();
574 svc.set(String::from("custom-key"), String::from("custom-val"));
575 assert_eq!(svc.get("custom-key"), Some("custom-val"));
576 }
577
578 #[test]
579 fn test_metadata_public_keys() {
580 let mut svc = MetadataService::new();
581 svc.set(
582 String::from("public-key-0"),
583 String::from("ssh-rsa AAAA..."),
584 );
585 svc.set(
586 String::from("public-key-1"),
587 String::from("ssh-ed25519 BBBB..."),
588 );
589 let keys = svc.get_public_keys();
590 assert_eq!(keys.len(), 2);
591 }
592
593 #[test]
594 fn test_userdata_parse() {
595 let input = "\
596hostname=my-node
597ssh_key=ssh-rsa AAAAB3...
598package=nginx
599package=vim
600runcmd=echo hello
601user=admin:sudo,docker:/bin/bash:ALL=(ALL) NOPASSWD:ALL
602write_file=/etc/motd:0644:root:Welcome to VeridianOS
603";
604 let data = UserData::from_key_value(input);
605 assert_eq!(data.hostname, "my-node");
606 assert_eq!(data.ssh_keys.len(), 1);
607 assert_eq!(data.packages.len(), 2);
608 assert_eq!(data.runcmd.len(), 1);
609 assert_eq!(data.users.len(), 1);
610 assert_eq!(data.users[0].name, "admin");
611 assert_eq!(data.users[0].groups, ["sudo", "docker"]);
612 assert_eq!(data.users[0].shell, "/bin/bash");
613 assert_eq!(data.write_files.len(), 1);
614 assert_eq!(data.write_files[0].path, "/etc/motd");
615 }
616
617 #[test]
618 fn test_userdata_empty() {
619 let data = UserData::from_key_value("");
620 assert!(data.hostname.is_empty());
621 assert!(data.users.is_empty());
622 }
623
624 #[test]
625 fn test_userdata_comments() {
626 let input = "# comment\nhostname=test\n# another\n";
627 let data = UserData::from_key_value(input);
628 assert_eq!(data.hostname, "test");
629 }
630
631 #[test]
632 fn test_runner_execute() {
633 let mut runner = CloudInitRunner::new();
634 let userdata = "hostname=cloud-node\nuser=admin:::\npackage=curl\nruncmd=uname -a\n";
635 runner.execute(userdata).unwrap();
636 assert!(runner.is_completed());
637 assert_eq!(runner.applied_hostname(), Some("cloud-node"));
638 assert_eq!(runner.created_users(), &["admin"]);
639 assert_eq!(runner.installed_packages(), &["curl"]);
640 assert_eq!(runner.executed_commands(), &["uname -a"]);
641 }
642
643 #[test]
644 fn test_runner_idempotent() {
645 let mut runner = CloudInitRunner::new();
646 runner.execute("hostname=test").unwrap();
647 let log_len = runner.log().len();
648 runner.execute("hostname=test").unwrap(); assert_eq!(runner.log().len(), log_len);
650 }
651
652 #[test]
653 fn test_runner_apply_hostname_from_metadata() {
654 let mut runner = CloudInitRunner::new();
655 runner.fetch_metadata().unwrap();
656 runner.apply_hostname().unwrap();
657 assert_eq!(runner.applied_hostname(), Some("veridian-node"));
658 }
659
660 #[test]
661 fn test_runner_write_files() {
662 let mut runner = CloudInitRunner::new();
663 let userdata = "write_file=/etc/test:0755:root:file content\n";
664 runner.fetch_metadata().unwrap();
665 runner.fetch_userdata(userdata).unwrap();
666 runner.write_files().unwrap();
667 assert_eq!(runner.written_files(), &["/etc/test"]);
668 }
669
670 #[test]
671 fn test_runner_no_userdata() {
672 let mut runner = CloudInitRunner::new();
673 runner.fetch_metadata().unwrap();
674 runner.apply_hostname().unwrap();
676 runner.create_users().unwrap();
677 runner.install_ssh_keys().unwrap();
678 runner.write_files().unwrap();
679 runner.run_commands().unwrap();
680 assert!(runner.created_users().is_empty());
681 }
682
683 #[test]
684 fn test_runner_install_ssh_keys() {
685 let mut runner = CloudInitRunner::new();
686 let userdata = "ssh_key=ssh-rsa AAAAB3NzaC1yc2E\nssh_key=ssh-ed25519 AABBCC\n";
687 runner.fetch_metadata().unwrap();
688 runner.fetch_userdata(userdata).unwrap();
689 runner.install_ssh_keys().unwrap();
690 let key_logs: Vec<_> = runner
692 .log()
693 .iter()
694 .filter(|e| e.stage == "install_ssh_keys")
695 .collect();
696 assert_eq!(key_logs.len(), 2);
697 }
698
699 #[test]
700 fn test_write_file_default() {
701 let file = WriteFile::default();
702 assert_eq!(file.permissions, "0644");
703 assert_eq!(file.owner, "root:root");
704 }
705
706 #[test]
707 fn test_user_config_default() {
708 let user = UserConfig::default();
709 assert_eq!(user.shell, "/bin/sh");
710 assert!(user.groups.is_empty());
711 }
712}