⚠️ VeridianOS Kernel Documentation - This is low-level kernel code. All functions are unsafe unless explicitly marked otherwise. no_std

veridian_kernel/services/
cloud_init.rs

1//! Cloud-Init Service
2//!
3//! Provides instance metadata retrieval and user-data processing
4//! for cloud environment initialization (hostname, users, SSH keys,
5//! packages, commands, file creation).
6
7#![allow(dead_code)]
8
9use alloc::{collections::BTreeMap, string::String, vec::Vec};
10
11// ---------------------------------------------------------------------------
12// Metadata Service
13// ---------------------------------------------------------------------------
14
15/// Instance metadata source (link-local address 169.254.169.254).
16#[derive(Debug)]
17pub struct MetadataService {
18    /// Base URL for the metadata service.
19    base_url: String,
20    /// Cached metadata.
21    cache: BTreeMap<String, String>,
22}
23
24impl Default for MetadataService {
25    fn default() -> Self {
26        Self::new()
27    }
28}
29
30impl MetadataService {
31    /// Standard metadata service IP.
32    pub const DEFAULT_BASE_URL: &'static str = "169.254.169.254";
33
34    /// Create a new metadata service.
35    pub fn new() -> Self {
36        MetadataService {
37            base_url: String::from(Self::DEFAULT_BASE_URL),
38            cache: BTreeMap::new(),
39        }
40    }
41
42    /// Create with a custom base URL.
43    pub fn with_url(base_url: String) -> Self {
44        MetadataService {
45            base_url,
46            cache: BTreeMap::new(),
47        }
48    }
49
50    /// Get the instance ID.
51    pub fn get_instance_id(&self) -> Option<&str> {
52        self.cache.get("instance-id").map(|s| s.as_str())
53    }
54
55    /// Get the hostname.
56    pub fn get_hostname(&self) -> Option<&str> {
57        self.cache.get("hostname").map(|s| s.as_str())
58    }
59
60    /// Get public SSH keys.
61    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    /// Get a metadata value by key.
70    pub fn get(&self, key: &str) -> Option<&str> {
71        self.cache.get(key).map(|s| s.as_str())
72    }
73
74    /// Set a metadata value (for testing or manual override).
75    pub fn set(&mut self, key: String, value: String) {
76        self.cache.insert(key, value);
77    }
78
79    /// Get the base URL.
80    pub fn base_url(&self) -> &str {
81        &self.base_url
82    }
83
84    /// Simulate fetching metadata from the link-local service.
85    ///
86    /// In a real implementation this would make HTTP GET requests to
87    /// `http://169.254.169.254/latest/meta-data/{path}`.
88    pub fn fetch_metadata(&mut self) -> Result<(), CloudInitError> {
89        // Populate default metadata for testing
90        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// ---------------------------------------------------------------------------
103// User Data Types
104// ---------------------------------------------------------------------------
105
106/// User account configuration.
107#[derive(Debug, Clone)]
108pub struct UserConfig {
109    /// Username.
110    pub name: String,
111    /// Groups the user belongs to.
112    pub groups: Vec<String>,
113    /// SSH authorized keys.
114    pub ssh_authorized_keys: Vec<String>,
115    /// Login shell.
116    pub shell: String,
117    /// Sudo configuration (e.g., "ALL=(ALL) NOPASSWD:ALL").
118    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/// File to write during cloud-init.
134#[derive(Debug, Clone)]
135pub struct WriteFile {
136    /// Absolute file path.
137    pub path: String,
138    /// File content.
139    pub content: String,
140    /// File permissions (octal string, e.g., "0644").
141    pub permissions: String,
142    /// File owner (e.g., "root:root").
143    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/// User data configuration.
158#[derive(Debug, Clone, Default)]
159pub struct UserData {
160    /// Desired hostname.
161    pub hostname: String,
162    /// Users to create.
163    pub users: Vec<UserConfig>,
164    /// SSH keys to install (global).
165    pub ssh_keys: Vec<String>,
166    /// Packages to install.
167    pub packages: Vec<String>,
168    /// Commands to run.
169    pub runcmd: Vec<String>,
170    /// Files to write.
171    pub write_files: Vec<WriteFile>,
172}
173
174impl UserData {
175    /// Parse user data from a simple key=value format.
176    ///
177    /// This is a simplified parser. Real cloud-init uses YAML.
178    /// Recognized keys:
179    /// - `hostname=value`
180    /// - `ssh_key=value`
181    /// - `package=value`
182    /// - `runcmd=value`
183    /// - `user=name:groups:shell:sudo`
184    /// - `write_file=path:permissions:owner:content`
185    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// ---------------------------------------------------------------------------
250// Cloud-Init Error
251// ---------------------------------------------------------------------------
252
253/// Cloud-init error.
254#[derive(Debug, Clone, PartialEq, Eq)]
255pub enum CloudInitError {
256    /// Metadata service unreachable.
257    MetadataUnavailable,
258    /// User data fetch failed.
259    UserDataFetchFailed,
260    /// Failed to apply hostname.
261    HostnameApplyFailed(String),
262    /// Failed to create user.
263    UserCreateFailed(String),
264    /// Failed to write file.
265    WriteFileFailed(String),
266    /// Command execution failed.
267    CommandFailed(String),
268    /// Package installation failed.
269    PackageInstallFailed(String),
270}
271
272// ---------------------------------------------------------------------------
273// Cloud-Init Runner
274// ---------------------------------------------------------------------------
275
276/// Execution log entry.
277#[derive(Debug, Clone)]
278pub struct LogEntry {
279    /// Stage name.
280    pub stage: String,
281    /// Success or failure.
282    pub success: bool,
283    /// Detail message.
284    pub detail: String,
285}
286
287/// Cloud-Init runner: orchestrates the full initialization sequence.
288#[derive(Debug)]
289pub struct CloudInitRunner {
290    /// Metadata service.
291    metadata: MetadataService,
292    /// Parsed user data.
293    user_data: Option<UserData>,
294    /// Execution log.
295    log: Vec<LogEntry>,
296    /// Whether cloud-init has already run.
297    completed: bool,
298    /// Applied hostname.
299    applied_hostname: Option<String>,
300    /// Created users.
301    created_users: Vec<String>,
302    /// Written files.
303    written_files: Vec<String>,
304    /// Executed commands.
305    executed_commands: Vec<String>,
306    /// Installed packages.
307    installed_packages: Vec<String>,
308}
309
310impl Default for CloudInitRunner {
311    fn default() -> Self {
312        Self::new()
313    }
314}
315
316impl CloudInitRunner {
317    /// Create a new cloud-init runner.
318    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    /// Create with custom metadata service.
333    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    /// Fetch metadata from the metadata service.
348    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    /// Fetch and parse user data.
359    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    /// Apply the configured hostname.
371    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    /// Create user accounts.
398    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    /// Install SSH keys.
419    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    /// Run commands.
436    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    /// Write files.
454    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    /// Install packages.
475    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    /// Execute the full cloud-init sequence.
493    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    /// Get the execution log.
517    pub fn log(&self) -> &[LogEntry] {
518        &self.log
519    }
520
521    /// Check if cloud-init has completed.
522    pub fn is_completed(&self) -> bool {
523        self.completed
524    }
525
526    /// Get the applied hostname.
527    pub fn applied_hostname(&self) -> Option<&str> {
528        self.applied_hostname.as_deref()
529    }
530
531    /// Get created users.
532    pub fn created_users(&self) -> &[String] {
533        &self.created_users
534    }
535
536    /// Get written files.
537    pub fn written_files(&self) -> &[String] {
538        &self.written_files
539    }
540
541    /// Get executed commands.
542    pub fn executed_commands(&self) -> &[String] {
543        &self.executed_commands
544    }
545
546    /// Get installed packages.
547    pub fn installed_packages(&self) -> &[String] {
548        &self.installed_packages
549    }
550}
551
552// ---------------------------------------------------------------------------
553// Tests
554// ---------------------------------------------------------------------------
555
556#[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(); // should be no-op
649        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        // These should all succeed with no user data
675        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        // Check log has entries
691        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}