|
| 1 | +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. |
| 2 | +// SPDX-License-Identifier: Apache-2.0 |
| 3 | + |
| 4 | +//! Services an environment runs alongside `OpenShell`, such as Keycloak. |
| 5 | +//! |
| 6 | +//! The runner starts each service after the VM boots by applying its tmachine |
| 7 | +//! playbook. The playbook starts the service, waits until it's ready, and |
| 8 | +//! publishes what tests need to reach it as `OPENSHELL_TEST_*` variables in |
| 9 | +//! `/etc/openshell-test/env.d/<service>.env`. The runner passes those |
| 10 | +//! variables to the tests; suite fixtures read them. Tests don't depend on |
| 11 | +//! tmachine: anyone running a suite against their own deployment sets the |
| 12 | +//! same variables. |
| 13 | +
|
| 14 | +use std::collections::BTreeMap; |
| 15 | +use std::fmt; |
| 16 | +use std::path::PathBuf; |
| 17 | + |
| 18 | +use anyhow::{Context, Result, bail, ensure}; |
| 19 | +use serde::Deserialize; |
| 20 | +use tmachine::Session; |
| 21 | + |
| 22 | +/// Where service playbooks publish the variables tests read. |
| 23 | +pub const ENV_DIR: &str = "/etc/openshell-test/env.d"; |
| 24 | +/// The prefix every published variable must have, so a service can't |
| 25 | +/// override how tests find the gateway or the CLI. |
| 26 | +pub const ENV_PREFIX: &str = "OPENSHELL_TEST_"; |
| 27 | + |
| 28 | +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, PartialOrd, Ord)] |
| 29 | +#[serde(rename_all = "lowercase")] |
| 30 | +pub enum Service { |
| 31 | + Keycloak, |
| 32 | +} |
| 33 | + |
| 34 | +impl fmt::Display for Service { |
| 35 | + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
| 36 | + f.write_str(match self { |
| 37 | + Self::Keycloak => "keycloak", |
| 38 | + }) |
| 39 | + } |
| 40 | +} |
| 41 | + |
| 42 | +impl Service { |
| 43 | + /// The playbook that starts the service, relative to `tests/tmachine`, |
| 44 | + /// the runner's working directory. |
| 45 | + fn playbook(self) -> PathBuf { |
| 46 | + PathBuf::from(format!("ansible/playbooks/services/{self}.yaml")) |
| 47 | + } |
| 48 | + |
| 49 | + /// Files the playbook needs from the checkout, relative to |
| 50 | + /// `tests/tmachine`. |
| 51 | + fn inputs(self) -> BTreeMap<String, PathBuf> { |
| 52 | + match self { |
| 53 | + Self::Keycloak => BTreeMap::from([( |
| 54 | + "keycloak_realm_file".to_string(), |
| 55 | + PathBuf::from("../../scripts/keycloak-realm.json"), |
| 56 | + )]), |
| 57 | + } |
| 58 | + } |
| 59 | + |
| 60 | + fn env_file(self) -> String { |
| 61 | + format!("{ENV_DIR}/{self}.env") |
| 62 | + } |
| 63 | +} |
| 64 | + |
| 65 | +/// Start `services` in the booted VM and return the variables they publish, |
| 66 | +/// as `KEY=VALUE` strings. |
| 67 | +pub async fn start(session: &Session, services: &[Service]) -> Result<Vec<String>> { |
| 68 | + let mut vars = Vec::new(); |
| 69 | + for &service in services { |
| 70 | + session |
| 71 | + .run_playbooks(&[service.playbook()], &service.inputs()) |
| 72 | + .await |
| 73 | + .with_context(|| format!("start {service}"))?; |
| 74 | + let path = service.env_file(); |
| 75 | + let output = session.exec(&["cat", &path]).await?; |
| 76 | + ensure!( |
| 77 | + output.status.success(), |
| 78 | + "{service} did not publish {path}: {}", |
| 79 | + String::from_utf8_lossy(&output.stderr).trim() |
| 80 | + ); |
| 81 | + let contents = |
| 82 | + String::from_utf8(output.stdout).with_context(|| format!("{path} is not UTF-8"))?; |
| 83 | + vars.extend(parse_env(&path, &contents)?); |
| 84 | + } |
| 85 | + Ok(vars) |
| 86 | +} |
| 87 | + |
| 88 | +/// Parse a published env file: `KEY=VALUE` lines, blank lines and `#` |
| 89 | +/// comments. Values are taken literally; there is no quoting. Errors name |
| 90 | +/// the key, never the value, since values can be credentials. |
| 91 | +pub fn parse_env(path: &str, contents: &str) -> Result<Vec<String>> { |
| 92 | + let mut vars = Vec::new(); |
| 93 | + for (number, line) in contents.lines().enumerate() { |
| 94 | + let line = line.trim_end(); |
| 95 | + if line.trim().is_empty() || line.trim_start().starts_with('#') { |
| 96 | + continue; |
| 97 | + } |
| 98 | + let Some((key, _value)) = line.split_once('=') else { |
| 99 | + bail!("{path}:{}: expected KEY=VALUE", number + 1); |
| 100 | + }; |
| 101 | + ensure!( |
| 102 | + key.starts_with(ENV_PREFIX) |
| 103 | + && key.len() > ENV_PREFIX.len() |
| 104 | + && key |
| 105 | + .bytes() |
| 106 | + .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || b == b'_'), |
| 107 | + "{path}:{}: {key:?} is not an {ENV_PREFIX}* variable name", |
| 108 | + number + 1 |
| 109 | + ); |
| 110 | + vars.push(line.to_string()); |
| 111 | + } |
| 112 | + Ok(vars) |
| 113 | +} |
| 114 | + |
| 115 | +#[cfg(test)] |
| 116 | +mod tests { |
| 117 | + use super::*; |
| 118 | + |
| 119 | + #[test] |
| 120 | + fn parses_published_variables() { |
| 121 | + let vars = parse_env( |
| 122 | + "keycloak.env", |
| 123 | + "# Published by keycloak.yaml\n\nOPENSHELL_TEST_KEYCLOAK_ISSUER=http://127.0.0.1:8180/realms/openshell\nOPENSHELL_TEST_KEYCLOAK_PASSWORD=a=b\n", |
| 124 | + ) |
| 125 | + .unwrap(); |
| 126 | + assert_eq!( |
| 127 | + vars, |
| 128 | + [ |
| 129 | + "OPENSHELL_TEST_KEYCLOAK_ISSUER=http://127.0.0.1:8180/realms/openshell", |
| 130 | + "OPENSHELL_TEST_KEYCLOAK_PASSWORD=a=b", |
| 131 | + ] |
| 132 | + ); |
| 133 | + } |
| 134 | + |
| 135 | + #[test] |
| 136 | + fn refuses_variables_outside_the_prefix() { |
| 137 | + let err = parse_env("x.env", "OPENSHELL_GATEWAY=other\n").unwrap_err(); |
| 138 | + assert!(err.to_string().contains("OPENSHELL_GATEWAY"), "{err}"); |
| 139 | + } |
| 140 | + |
| 141 | + #[test] |
| 142 | + fn errors_do_not_echo_values() { |
| 143 | + let err = parse_env("x.env", "openshell_test_secret=hunter2\n").unwrap_err(); |
| 144 | + assert!(!err.to_string().contains("hunter2"), "{err}"); |
| 145 | + let err = parse_env("x.env", "hunter2\n").unwrap_err(); |
| 146 | + assert!(!err.to_string().contains("hunter2"), "{err}"); |
| 147 | + } |
| 148 | + |
| 149 | + #[test] |
| 150 | + fn services_are_read_from_yaml() { |
| 151 | + let services: Vec<Service> = serde_yml::from_str("[keycloak]").unwrap(); |
| 152 | + assert_eq!(services, [Service::Keycloak]); |
| 153 | + assert_eq!( |
| 154 | + Service::Keycloak.playbook(), |
| 155 | + PathBuf::from("ansible/playbooks/services/keycloak.yaml") |
| 156 | + ); |
| 157 | + } |
| 158 | +} |
0 commit comments