Remove openbao helper and replace it with more general program
This gets rid of the messy nix code for handling bitwarden secrets, and unifies it all into a nice single program in rust. Ensuring that only the needed secrets are loaded.
This commit is contained in:
parent
e6a152e95c
commit
8640dce7bc
31 changed files with 1159 additions and 958 deletions
|
@ -0,0 +1,30 @@
|
|||
use std::path::Path;
|
||||
|
||||
use super::Command;
|
||||
use crate::{EndpointReader, ProvisioningData};
|
||||
|
||||
#[derive(Debug, Clone, clap::Args)]
|
||||
pub struct Configure {
|
||||
/// Name of the instance to configure.
|
||||
instance: String,
|
||||
}
|
||||
|
||||
impl Command for Configure {
|
||||
fn run(&self, _flake_path: &Path, data: ProvisioningData) -> anyhow::Result<()> {
|
||||
let mut endpoint_reader = EndpointReader::new();
|
||||
endpoint_reader.read_endpoints(data.secrets_source, &data.configuration.endpoints)?;
|
||||
let Some(mut configure) = data
|
||||
.configuration
|
||||
.init(&self.instance, endpoint_reader.env())?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
configure.run("apply")?;
|
||||
configure.cleanup()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn instance_name(&self) -> &str {
|
||||
&self.instance
|
||||
}
|
||||
}
|
110
rust/program/infrastructure/src/command/instance/create.rs
Normal file
110
rust/program/infrastructure/src/command/instance/create.rs
Normal file
|
@ -0,0 +1,110 @@
|
|||
use std::{net::Ipv4Addr, path::Path};
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::Command;
|
||||
use crate::{EndpointReader, ProvisioningData};
|
||||
|
||||
#[derive(Debug, Clone, clap::Args)]
|
||||
pub struct Create {
|
||||
/// Name of the instance to create.
|
||||
instance: String,
|
||||
|
||||
#[arg(short = 's')]
|
||||
skip_sanitity_checks: bool,
|
||||
}
|
||||
|
||||
impl Command for Create {
|
||||
fn run(&self, flake_path: &Path, data: ProvisioningData) -> anyhow::Result<()> {
|
||||
if data.compute.config.is_none() {
|
||||
return Err(anyhow::format_err!(
|
||||
"No compute resources allocated for {}",
|
||||
self.instance
|
||||
));
|
||||
}
|
||||
if !self.skip_sanitity_checks {
|
||||
log::info!("Building system configuration to ensure it is installable");
|
||||
// First lets make sure we can build what we're trying to create
|
||||
let mut proc = common::proc::Command::new("nix");
|
||||
proc.args([
|
||||
"build",
|
||||
"--no-link",
|
||||
&format!(
|
||||
"{}#nixosConfigurations.\"{}\".config.system.build.toplevel",
|
||||
flake_path.display(),
|
||||
self.instance
|
||||
),
|
||||
]);
|
||||
proc.stderr_inherit().try_spawn_to_bytes()?;
|
||||
}
|
||||
let mut endpoint_reader = EndpointReader::new();
|
||||
endpoint_reader.read_endpoints(data.secrets_source, &data.persistence.endpoints)?;
|
||||
if let Some(mut persistence) = data
|
||||
.persistence
|
||||
.init(&self.instance, endpoint_reader.env())?
|
||||
{
|
||||
persistence.run("apply")?;
|
||||
persistence.cleanup()?;
|
||||
}
|
||||
endpoint_reader.read_endpoints(data.secrets_source, &data.compute.endpoints)?;
|
||||
let mut compute = data
|
||||
.compute
|
||||
.init(&self.instance, endpoint_reader.env())?
|
||||
.expect("Verified earlier that config is not none");
|
||||
compute.run("apply")?;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Output {
|
||||
ipv4_address: Ipv4AddrValue,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Ipv4AddrValue {
|
||||
value: Ipv4Addr,
|
||||
}
|
||||
|
||||
let output: Output = compute.output()?;
|
||||
compute.cleanup()?;
|
||||
|
||||
nixos_anywhere(
|
||||
flake_path,
|
||||
&self.instance,
|
||||
&data.image_username,
|
||||
&output.ipv4_address.value,
|
||||
)?;
|
||||
|
||||
endpoint_reader.read_endpoints(
|
||||
data.secrets_source,
|
||||
&data.compute_with_persistence_attached.endpoints,
|
||||
)?;
|
||||
if let Some(mut compute_with_persistence) = data
|
||||
.compute_with_persistence_attached
|
||||
.init(&self.instance, endpoint_reader.env())?
|
||||
{
|
||||
compute_with_persistence.run("apply")?;
|
||||
compute_with_persistence.cleanup()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn instance_name(&self) -> &str {
|
||||
&self.instance
|
||||
}
|
||||
}
|
||||
|
||||
fn nixos_anywhere(
|
||||
flake_path: &Path,
|
||||
instance: &str,
|
||||
username: &str,
|
||||
ip_addr: &Ipv4Addr,
|
||||
) -> anyhow::Result<()> {
|
||||
let mut proc = common::proc::Command::new("nixos-anywhere");
|
||||
let flake_arg = format!("{}#{instance}", flake_path.display());
|
||||
let host_arg = format!("{username}@{ip_addr}");
|
||||
proc.args(["--flake", &flake_arg, "--target-host", &host_arg]);
|
||||
|
||||
if !proc.stderr_inherit().try_spawn_stdout_inherit()?.success() {
|
||||
return Err(anyhow::format_err!("nixos-anywhere didn't succeed"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
67
rust/program/infrastructure/src/command/instance/destroy.rs
Normal file
67
rust/program/infrastructure/src/command/instance/destroy.rs
Normal file
|
@ -0,0 +1,67 @@
|
|||
use std::path::Path;
|
||||
|
||||
use super::Command;
|
||||
use crate::{EndpointReader, ProvisioningData};
|
||||
|
||||
#[derive(Debug, Clone, clap::Args)]
|
||||
pub struct Destroy {
|
||||
/// Name of the instance to destroy.
|
||||
instance: String,
|
||||
|
||||
/// Also destroy the persistence, ie. disks
|
||||
#[arg(short = 'p', long = "delete-persistence")]
|
||||
persistence: bool,
|
||||
}
|
||||
|
||||
impl Command for Destroy {
|
||||
fn run(&self, _flake_path: &Path, data: ProvisioningData) -> anyhow::Result<()> {
|
||||
let mut endpoint_reader = EndpointReader::new();
|
||||
endpoint_reader.read_endpoints(data.secrets_source, &data.configuration.endpoints)?;
|
||||
log::trace!("Testing if configuration needs destroyed");
|
||||
if let Some(mut configure) = data
|
||||
.configuration
|
||||
.init(&self.instance, endpoint_reader.env())?
|
||||
{
|
||||
log::info!("Destroying configuration...");
|
||||
configure.run("destroy")?;
|
||||
configure.cleanup()?;
|
||||
log::info!("Configuration destroyed.");
|
||||
} else {
|
||||
log::trace!("Configuration does not need to be destroyed");
|
||||
}
|
||||
|
||||
endpoint_reader.read_endpoints(
|
||||
data.secrets_source,
|
||||
&data.compute_with_persistence_attached.endpoints,
|
||||
)?;
|
||||
let Some(mut destroy) = data
|
||||
.compute_with_persistence_attached
|
||||
.init(&self.instance, endpoint_reader.env())?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
log::info!("Destroying compute resources...");
|
||||
destroy.run("destroy")?;
|
||||
destroy.cleanup()?;
|
||||
log::info!("Compute resources destroyed.");
|
||||
|
||||
if self.persistence {
|
||||
endpoint_reader.read_endpoints(data.secrets_source, &data.persistence.endpoints)?;
|
||||
let Some(mut persistence) = data
|
||||
.persistence
|
||||
.init(&self.instance, endpoint_reader.env())?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
log::info!("Destroying persistence resources...");
|
||||
persistence.run("destroy")?;
|
||||
persistence.cleanup()?;
|
||||
log::info!("Persistence resources destroyed.");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn instance_name(&self) -> &str {
|
||||
&self.instance
|
||||
}
|
||||
}
|
54
rust/program/infrastructure/src/command/instance/mod.rs
Normal file
54
rust/program/infrastructure/src/command/instance/mod.rs
Normal file
|
@ -0,0 +1,54 @@
|
|||
use std::path::Path;
|
||||
|
||||
use clap::Subcommand;
|
||||
|
||||
use crate::ProvisioningData;
|
||||
|
||||
mod configure;
|
||||
mod create;
|
||||
mod destroy;
|
||||
mod update;
|
||||
|
||||
use configure::Configure;
|
||||
use create::Create;
|
||||
use destroy::Destroy;
|
||||
use update::Update;
|
||||
|
||||
pub trait Command {
|
||||
fn run(&self, flake_path: &Path, data: ProvisioningData) -> anyhow::Result<()>;
|
||||
|
||||
fn instance_name(&self) -> &str;
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum InstanceCommand {
|
||||
/// Creates the instance.
|
||||
Create(Create),
|
||||
/// Updates the provisioned resources for the instance.
|
||||
Update(Update),
|
||||
/// Configures the software installed on he instance.
|
||||
Configure(Configure),
|
||||
/// Destroys the instance, but leaves behind persistent data, unless forcing deletion.
|
||||
Destroy(Destroy),
|
||||
}
|
||||
|
||||
impl InstanceCommand {
|
||||
fn instance_name(&self) -> &str {
|
||||
match self {
|
||||
Self::Configure(c) => c.instance_name(),
|
||||
Self::Create(c) => c.instance_name(),
|
||||
Self::Update(u) => u.instance_name(),
|
||||
Self::Destroy(d) => d.instance_name(),
|
||||
}
|
||||
}
|
||||
pub fn run(&self) -> anyhow::Result<()> {
|
||||
let flake_path = common::env::read_path_env("FLAKE_PATH")?;
|
||||
let data = ProvisioningData::try_new(self.instance_name(), &flake_path)?;
|
||||
match self {
|
||||
Self::Create(create) => create.run(&flake_path, data),
|
||||
Self::Destroy(destroy) => destroy.run(&flake_path, data),
|
||||
Self::Configure(configure) => configure.run(&flake_path, data),
|
||||
Self::Update(update) => update.run(&flake_path, data),
|
||||
}
|
||||
}
|
||||
}
|
41
rust/program/infrastructure/src/command/instance/update.rs
Normal file
41
rust/program/infrastructure/src/command/instance/update.rs
Normal file
|
@ -0,0 +1,41 @@
|
|||
use std::path::Path;
|
||||
|
||||
use super::Command;
|
||||
use crate::{EndpointReader, ProvisioningData};
|
||||
|
||||
#[derive(Debug, Clone, clap::Args)]
|
||||
pub struct Update {
|
||||
/// Name of the instance to update.
|
||||
instance: String,
|
||||
}
|
||||
|
||||
impl Command for Update {
|
||||
fn run(&self, _flake_path: &Path, data: ProvisioningData) -> anyhow::Result<()> {
|
||||
let mut endpoint_reader = EndpointReader::new();
|
||||
endpoint_reader.read_endpoints(data.secrets_source, &data.persistence.endpoints)?;
|
||||
if let Some(mut persistence) = data
|
||||
.persistence
|
||||
.init(&self.instance, endpoint_reader.env())?
|
||||
{
|
||||
persistence.run("apply")?;
|
||||
persistence.cleanup()?;
|
||||
}
|
||||
endpoint_reader.read_endpoints(
|
||||
data.secrets_source,
|
||||
&data.compute_with_persistence_attached.endpoints,
|
||||
)?;
|
||||
let Some(mut configure) = data
|
||||
.compute_with_persistence_attached
|
||||
.init(&self.instance, endpoint_reader.env())?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
configure.run("apply")?;
|
||||
configure.cleanup()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn instance_name(&self) -> &str {
|
||||
&self.instance
|
||||
}
|
||||
}
|
120
rust/program/infrastructure/src/command/mod.rs
Normal file
120
rust/program/infrastructure/src/command/mod.rs
Normal file
|
@ -0,0 +1,120 @@
|
|||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
ffi::{CString, OsString},
|
||||
};
|
||||
#[cfg(target_os = "macos")]
|
||||
use std::{
|
||||
convert::Infallible,
|
||||
ffi::{CStr, OsStr},
|
||||
};
|
||||
|
||||
use anyhow::Context as _;
|
||||
use clap::Subcommand;
|
||||
use common::bitwarden::BitwardenSession;
|
||||
|
||||
use crate::secrets::{CliEndpoint, transfer_from_bitwarden_to_vault};
|
||||
|
||||
mod instance;
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum Command {
|
||||
/// Runs commands against a single instance
|
||||
Instance {
|
||||
#[command(subcommand)]
|
||||
command: instance::InstanceCommand,
|
||||
},
|
||||
/// Transfers secrets from bitwarden to openbao
|
||||
#[command(name = "secrets-to-openbao")]
|
||||
SecretsToOpenBao,
|
||||
|
||||
/// Wraps a program with environment variables fetched from openbao
|
||||
#[command(name = "wrap-bao-program")]
|
||||
WrapBaoProgram(WrapProgram),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, clap::Args)]
|
||||
pub struct WrapProgram {
|
||||
/// The endpoints to fetch
|
||||
#[arg(short = 'e', long = "endpoint", number_of_values = 1)]
|
||||
pub endpoint: Vec<CliEndpoint>,
|
||||
/// Command to wrap
|
||||
#[arg(allow_hyphen_values = true, last = true)]
|
||||
pub cmd: Vec<String>,
|
||||
}
|
||||
|
||||
impl Command {
|
||||
pub fn run(self) -> anyhow::Result<()> {
|
||||
match self {
|
||||
Command::Instance { command } => command.run(),
|
||||
Command::SecretsToOpenBao => {
|
||||
let mut bw_session = BitwardenSession::unlock()?;
|
||||
transfer_from_bitwarden_to_vault(&mut bw_session)
|
||||
}
|
||||
Command::WrapBaoProgram(wp) => wrap_program(wp),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn wrap_program(wrap_program: WrapProgram) -> anyhow::Result<()> {
|
||||
let (args, env) = {
|
||||
let WrapProgram { cmd, endpoint } = wrap_program;
|
||||
if endpoint.is_empty() {
|
||||
return Err(anyhow::format_err!("Must specify at least one endpoint"));
|
||||
}
|
||||
if cmd.is_empty() {
|
||||
return Err(anyhow::format_err!("No command to execute was specified"));
|
||||
}
|
||||
let unique: BTreeSet<_> = BTreeSet::from_iter(endpoint);
|
||||
let mut env = Vec::<(OsString, OsString)>::new();
|
||||
for (key, value) in std::env::vars() {
|
||||
env.push((OsString::from(key), OsString::from(value)));
|
||||
}
|
||||
for env_set in unique {
|
||||
let mut env_map = BTreeMap::new();
|
||||
env_set.read_from_openbao(&mut env_map)?;
|
||||
for (key, value) in env_map {
|
||||
env.push((OsString::from(key.into_owned()), OsString::from(value)));
|
||||
}
|
||||
}
|
||||
let mut args = Vec::new();
|
||||
for arg in cmd {
|
||||
let arg = CString::new(arg)
|
||||
.context("Argument to program to wrap cannot contain null bytes")?;
|
||||
args.push(arg);
|
||||
}
|
||||
(args, env)
|
||||
};
|
||||
unsafe {
|
||||
execvpe(&args[0], args.as_slice(), env.as_slice())?;
|
||||
}
|
||||
// This will never get executed
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
/// Safety: No other threads may read or write environment variables when this function is called.
|
||||
/// The easiest way to ensure this is using a single threaded program.
|
||||
// Simple "bad" version of execvpe that also works on OSX
|
||||
unsafe fn execvpe<SA: AsRef<CStr>, SEK: AsRef<OsStr>, SEV: AsRef<OsStr>>(
|
||||
filename: &CStr,
|
||||
args: &[SA],
|
||||
environ: &[(SEK, SEV)],
|
||||
) -> anyhow::Result<Infallible> {
|
||||
let current_env = std::env::vars_os();
|
||||
// Safety: Same as this function
|
||||
unsafe { nix::env::clearenv()? };
|
||||
for (key, val) in environ {
|
||||
// Safety: Same as this function
|
||||
unsafe { std::env::set_var(key.as_ref(), val.as_ref()) };
|
||||
}
|
||||
match nix::unistd::execvp(filename, args) {
|
||||
Err(err) => {
|
||||
unsafe { nix::env::clearenv()? };
|
||||
for (key, val) in current_env {
|
||||
unsafe { std::env::set_var(key.as_os_str(), val.as_os_str()) };
|
||||
}
|
||||
Err(err.into())
|
||||
}
|
||||
_ => unreachable!("execvp doesn't return on success"),
|
||||
}
|
||||
}
|
225
rust/program/infrastructure/src/main.rs
Normal file
225
rust/program/infrastructure/src/main.rs
Normal file
|
@ -0,0 +1,225 @@
|
|||
use common::bitwarden::BitwardenSession;
|
||||
use serde::Deserialize;
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use clap::Parser;
|
||||
|
||||
mod command;
|
||||
mod secrets;
|
||||
|
||||
use crate::{command::Command, secrets::CliEndpoint};
|
||||
|
||||
fn main() {
|
||||
common::entrypoint(program);
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(version, about, long_about = None)]
|
||||
pub struct Args {
|
||||
#[command(subcommand)]
|
||||
pub command: Command,
|
||||
}
|
||||
|
||||
fn program() -> anyhow::Result<()> {
|
||||
let args = Args::parse();
|
||||
args.command.run()
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct ProvisioningData {
|
||||
persistence: OpenTofuConfig,
|
||||
compute: OpenTofuConfig,
|
||||
compute_with_persistence_attached: OpenTofuConfig,
|
||||
configuration: OpenTofuConfig,
|
||||
secrets_source: SecretsSource,
|
||||
|
||||
image_username: String,
|
||||
}
|
||||
|
||||
struct EndpointReader {
|
||||
bw_session: Option<BitwardenSession>,
|
||||
endpoints_read: BTreeSet<CliEndpoint>,
|
||||
env: BTreeMap<Cow<'static, str>, String>,
|
||||
}
|
||||
|
||||
impl EndpointReader {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
bw_session: None,
|
||||
endpoints_read: BTreeSet::new(),
|
||||
env: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
pub fn env(&self) -> &BTreeMap<Cow<'static, str>, String> {
|
||||
&self.env
|
||||
}
|
||||
|
||||
pub fn read_endpoints(
|
||||
&mut self,
|
||||
secrets_source: SecretsSource,
|
||||
endpoints: &[CliEndpoint],
|
||||
) -> anyhow::Result<()> {
|
||||
let endpoints_to_read: Vec<_> = endpoints
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|e| !self.endpoints_read.contains(e))
|
||||
.collect();
|
||||
|
||||
if endpoints_to_read.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match secrets_source {
|
||||
SecretsSource::Bitwarden => {
|
||||
if self.bw_session.is_none() {
|
||||
let bw_session = BitwardenSession::unlock()?;
|
||||
let _ = self.bw_session.insert(bw_session);
|
||||
}
|
||||
let session = self
|
||||
.bw_session
|
||||
.as_mut()
|
||||
.expect("Should have bitwarden session");
|
||||
|
||||
for endpoint in endpoints_to_read {
|
||||
endpoint.read_from_bitwarden(session, &mut self.env)?;
|
||||
self.endpoints_read.insert(endpoint);
|
||||
}
|
||||
}
|
||||
SecretsSource::Vault => {
|
||||
for endpoint in endpoints_to_read {
|
||||
endpoint.read_from_openbao(&mut self.env)?;
|
||||
self.endpoints_read.insert(endpoint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone, Copy, PartialEq)]
|
||||
enum SecretsSource {
|
||||
#[serde(rename = "bitwarden")]
|
||||
Bitwarden,
|
||||
#[serde(rename = "vault")]
|
||||
Vault,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct OpenTofuConfig {
|
||||
config: Option<PathBuf>,
|
||||
endpoints: Vec<CliEndpoint>,
|
||||
}
|
||||
|
||||
struct WorkDir {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl WorkDir {
|
||||
pub fn try_new(template: &str) -> anyhow::Result<Self> {
|
||||
let mut proc = common::proc::Command::new("mktemp");
|
||||
proc.args(["-dt", template]);
|
||||
let path: PathBuf = proc.try_spawn_to_string()?.into();
|
||||
common::fs::create_dir_recursive(&path)?;
|
||||
Ok(Self { path })
|
||||
}
|
||||
|
||||
pub fn cleanup(self) -> anyhow::Result<()> {
|
||||
common::fs::remove_dir_recursive(&self.path)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl OpenTofuConfig {
|
||||
pub fn init<'e, K: AsRef<str>, V: AsRef<str>>(
|
||||
&self,
|
||||
instance: &str,
|
||||
env_map: &'e BTreeMap<K, V>,
|
||||
) -> anyhow::Result<Option<OpenTofuInstance<'e, K, V>>> {
|
||||
let Some(config) = self.config.as_ref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let work_dir = WorkDir::try_new(&format!("{instance}-provision.XXXXXX"))?;
|
||||
let config_path: PathBuf = work_dir.path.join("config.tf.json");
|
||||
common::fs::create_link(config, &config_path)?;
|
||||
|
||||
let mut init_proc = common::proc::Command::new("tofu");
|
||||
let chdir_arg = format!("-chdir={}", work_dir.path.display());
|
||||
for (key, value) in env_map {
|
||||
init_proc.env_sensitive(key.as_ref(), value.as_ref());
|
||||
}
|
||||
init_proc.args([&chdir_arg, "init"]);
|
||||
init_proc.try_spawn_to_bytes()?;
|
||||
Ok(Some(OpenTofuInstance {
|
||||
work_dir,
|
||||
env_map,
|
||||
chdir_arg,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
struct OpenTofuInstance<'e, K, V> {
|
||||
work_dir: WorkDir,
|
||||
chdir_arg: String,
|
||||
env_map: &'e BTreeMap<K, V>,
|
||||
}
|
||||
|
||||
impl<'e, K: AsRef<str>, V: AsRef<str>> OpenTofuInstance<'e, K, V> {
|
||||
pub fn run(&mut self, action: &str) -> anyhow::Result<()> {
|
||||
let mut proc = common::proc::Command::new("tofu");
|
||||
for (key, value) in self.env_map {
|
||||
proc.env_sensitive(key.as_ref(), value.as_ref());
|
||||
}
|
||||
proc.args([&self.chdir_arg, action]);
|
||||
proc.stdin_inherit();
|
||||
proc.stderr_inherit();
|
||||
proc.try_spawn_stdout_inherit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn output<D: for<'de> serde::Deserialize<'de>>(&mut self) -> anyhow::Result<D> {
|
||||
let mut proc = common::proc::Command::new("tofu");
|
||||
for (key, value) in self.env_map {
|
||||
proc.env_sensitive(key.as_ref(), value.as_ref());
|
||||
}
|
||||
proc.args([&self.chdir_arg, "output", "-json"]);
|
||||
proc.try_spawn_to_json()
|
||||
}
|
||||
|
||||
pub fn cleanup(self) -> anyhow::Result<()> {
|
||||
self.work_dir.cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
impl ProvisioningData {
|
||||
pub fn try_new(instance: &str, flake_path: &Path) -> anyhow::Result<Self> {
|
||||
let mut proc = common::proc::Command::new("nix");
|
||||
let base_attr = format!(
|
||||
"{}#nixosConfigurations.\"{instance}\".config.khscodes.infrastructure.provisioning",
|
||||
flake_path.display()
|
||||
);
|
||||
let script = r#"
|
||||
let
|
||||
data = prov: { inherit (prov) config endpoints; };
|
||||
in
|
||||
p: {
|
||||
persistence = data p.persistence;
|
||||
compute = data p.compute;
|
||||
compute_with_persistence_attached = data p.combinedPersistenceAttachAndCompute;
|
||||
configuration = data p.configuration;
|
||||
secrets_source = p.secretsSource;
|
||||
image_username = p.imageUsername;
|
||||
}
|
||||
"#;
|
||||
proc.args(["eval", "--json", &base_attr, "--apply", script]);
|
||||
|
||||
log::info!("Building terranix configurations...");
|
||||
let result = proc.stderr_inherit().try_spawn_to_json()?;
|
||||
log::info!("Terranix configurations built.");
|
||||
Ok(result)
|
||||
}
|
||||
}
|
268
rust/program/infrastructure/src/secrets/endpoints.rs
Normal file
268
rust/program/infrastructure/src/secrets/endpoints.rs
Normal file
|
@ -0,0 +1,268 @@
|
|||
use std::{borrow::Cow, collections::BTreeMap};
|
||||
|
||||
use common::bitwarden::BitwardenSession;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::secrets::{BitwardenKey, Endpoint, EndpointReader};
|
||||
|
||||
pub struct Openstack;
|
||||
|
||||
impl Endpoint for Openstack {
|
||||
const NAME: &'static str = "openstack";
|
||||
const BITWARDEN_KEY: &'static str = "KHS Openstack";
|
||||
const ENV_KEYS: &'static [&'static str] = &[
|
||||
"TF_VAR_openstack_username",
|
||||
"TF_VAR_openstack_password",
|
||||
"TF_VAR_openstack_tenant_name",
|
||||
"TF_VAR_openstack_auth_url",
|
||||
"TF_VAR_openstack_endpoint_type",
|
||||
"TF_VAR_openstack_region",
|
||||
];
|
||||
const BITWARDEN_KEYS: &'static [BitwardenKey] = &[
|
||||
BitwardenKey::Username,
|
||||
BitwardenKey::Password,
|
||||
BitwardenKey::Field("Project Name"),
|
||||
BitwardenKey::Field("Auth URL"),
|
||||
BitwardenKey::Field("Interface"),
|
||||
BitwardenKey::Field("Region Name"),
|
||||
];
|
||||
}
|
||||
|
||||
pub struct Aws;
|
||||
|
||||
impl Endpoint for Aws {
|
||||
const NAME: &'static str = "aws";
|
||||
|
||||
const BITWARDEN_KEY: &'static str = "Cloudflare";
|
||||
|
||||
const ENV_KEYS: &'static [&'static str] = &["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"];
|
||||
|
||||
const BITWARDEN_KEYS: &'static [BitwardenKey] = &[
|
||||
BitwardenKey::Field("BW Terraform access key id"),
|
||||
BitwardenKey::Field("BW Terraform secret access key"),
|
||||
];
|
||||
}
|
||||
|
||||
pub struct Cloudflare;
|
||||
|
||||
impl Endpoint for Cloudflare {
|
||||
const NAME: &'static str = "cloudflare";
|
||||
|
||||
const BITWARDEN_KEY: &'static str = "Cloudflare";
|
||||
|
||||
const ENV_KEYS: &'static [&'static str] =
|
||||
&["TF_VAR_cloudflare_token", "TF_VAR_cloudflare_email"];
|
||||
|
||||
const BITWARDEN_KEYS: &'static [BitwardenKey] =
|
||||
&[BitwardenKey::Field("DNS API Token"), BitwardenKey::Username];
|
||||
}
|
||||
|
||||
pub struct Hcloud;
|
||||
|
||||
impl Endpoint for Hcloud {
|
||||
const NAME: &'static str = "hcloud";
|
||||
|
||||
const BITWARDEN_KEY: &'static str = "Hetzner Cloud";
|
||||
|
||||
const ENV_KEYS: &'static [&'static str] = &["TF_VAR_hcloud_api_token"];
|
||||
|
||||
const BITWARDEN_KEYS: &'static [BitwardenKey] = &[BitwardenKey::Field("Terraform API Token")];
|
||||
}
|
||||
|
||||
pub struct Unifi;
|
||||
|
||||
impl Endpoint for Unifi {
|
||||
const NAME: &'static str = "unifi";
|
||||
|
||||
const BITWARDEN_KEY: &'static str = "Ubiquiti";
|
||||
|
||||
const ENV_KEYS: &'static [&'static str] = &["UNIFI_USERNAME", "UNIFI_PASSWORD", "UNIFI_API"];
|
||||
|
||||
const BITWARDEN_KEYS: &'static [BitwardenKey] = &[
|
||||
BitwardenKey::Field("Terraform username"),
|
||||
BitwardenKey::Field("Terraform password"),
|
||||
BitwardenKey::Field("Terraform URL"),
|
||||
];
|
||||
}
|
||||
|
||||
pub struct Vault;
|
||||
|
||||
impl Endpoint for Vault {
|
||||
const NAME: &'static str = "vault";
|
||||
|
||||
const BITWARDEN_KEY: &'static str = "secrets.kaareskovgaard.net";
|
||||
|
||||
const ENV_KEYS: &'static [&'static str] = &["VAULT_TOKEN"];
|
||||
|
||||
const BITWARDEN_KEYS: &'static [BitwardenKey] = &[BitwardenKey::Field("Initial root token")];
|
||||
}
|
||||
|
||||
pub struct MxKaareskovgaardNet;
|
||||
|
||||
impl Endpoint for MxKaareskovgaardNet {
|
||||
const NAME: &'static str = "mx.kaareskovgaard.net";
|
||||
|
||||
const BITWARDEN_KEY: &'static str = "mx.kaareskovgaard.net";
|
||||
|
||||
const ENV_KEYS: &'static [&'static str] = &["MX_KAARESKOVGAARD_NET_ZROOT_ENCRYPTION_KEY"];
|
||||
|
||||
const BITWARDEN_KEYS: &'static [BitwardenKey] = &[BitwardenKey::Field("ZROOT_ENCRYPTION_KEY")];
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, PartialOrd, Ord, clap::ValueEnum)]
|
||||
pub enum CliEndpoint {
|
||||
#[serde(rename = "openstack")]
|
||||
#[value(name = "openstack")]
|
||||
Openstack,
|
||||
#[serde(rename = "cloudflare")]
|
||||
#[value(name = "cloudflare")]
|
||||
Cloudflare,
|
||||
#[serde(rename = "aws")]
|
||||
#[value(name = "aws")]
|
||||
Aws,
|
||||
#[serde(rename = "hcloud")]
|
||||
#[value(name = "hcloud")]
|
||||
Hcloud,
|
||||
#[serde(rename = "unifi")]
|
||||
#[value(name = "unifi")]
|
||||
Unifi,
|
||||
#[serde(rename = "vault")]
|
||||
#[value(name = "vault")]
|
||||
Vault,
|
||||
}
|
||||
|
||||
impl CliEndpoint {
|
||||
pub fn read_from_bitwarden(
|
||||
&self,
|
||||
session: &mut BitwardenSession,
|
||||
map: &mut BTreeMap<Cow<'static, str>, String>,
|
||||
) -> anyhow::Result<()> {
|
||||
match self {
|
||||
Self::Aws => Aws.read_from_bitwarden(session, map),
|
||||
Self::Cloudflare => Cloudflare.read_from_bitwarden(session, map),
|
||||
Self::Hcloud => Hcloud.read_from_bitwarden(session, map),
|
||||
Self::Openstack => Openstack.read_from_bitwarden(session, map),
|
||||
Self::Unifi => Unifi.read_from_bitwarden(session, map),
|
||||
Self::Vault => Vault.read_from_bitwarden(session, map),
|
||||
}
|
||||
}
|
||||
pub fn read_from_openbao(
|
||||
&self,
|
||||
map: &mut BTreeMap<Cow<'static, str>, String>,
|
||||
) -> anyhow::Result<()> {
|
||||
match self {
|
||||
Self::Aws => Aws.read_from_openbao(map),
|
||||
Self::Cloudflare => Cloudflare.read_from_openbao(map),
|
||||
Self::Hcloud => Hcloud.read_from_openbao(map),
|
||||
Self::Openstack => Openstack.read_from_openbao(map),
|
||||
Self::Unifi => Openstack.read_from_openbao(map),
|
||||
// We don't transfer the root token to openbao itself, but relies on the user being authenticated
|
||||
// through oauth.
|
||||
Self::Vault => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn transfer_from_bitwarden_to_vault(session: &mut BitwardenSession) -> anyhow::Result<()> {
|
||||
let mut all_entries_proc = common::proc::Command::new("bao");
|
||||
all_entries_proc.args(["kv", "list", "-format=json", "--mount=opentofu"]);
|
||||
let mut all_entries: Vec<String> = all_entries_proc.try_spawn_to_json()?;
|
||||
transfer_endpoint(Openstack, session, &mut all_entries)?;
|
||||
transfer_endpoint(Hcloud, session, &mut all_entries)?;
|
||||
transfer_endpoint(Unifi, session, &mut all_entries)?;
|
||||
transfer_endpoint(Aws, session, &mut all_entries)?;
|
||||
transfer_endpoint(Cloudflare, session, &mut all_entries)?;
|
||||
transfer_endpoint(MxKaareskovgaardNet, session, &mut all_entries)?;
|
||||
|
||||
for entry in all_entries {
|
||||
let mut delete_entry_proc = common::proc::Command::new("bao");
|
||||
delete_entry_proc.args(["kv", "metadata", "delete", "-mount=opentofu", &entry]);
|
||||
log::info!("Deleting entry {entry}...");
|
||||
delete_entry_proc.try_spawn_to_bytes()?;
|
||||
log::info!("Entry deleted {entry}.");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn transfer_endpoint<E: Endpoint>(
|
||||
endpoint: E,
|
||||
session: &mut BitwardenSession,
|
||||
all_entries: &mut Vec<String>,
|
||||
) -> anyhow::Result<()> {
|
||||
let mut map = BTreeMap::new();
|
||||
endpoint.read_from_bitwarden(session, &mut map)?;
|
||||
|
||||
log::info!("Transferring {}...", E::NAME);
|
||||
let mut write_proc = common::proc::Command::new("bao");
|
||||
write_proc.args(["kv", "put", "-mount=opentofu"]);
|
||||
write_proc.arg(E::NAME);
|
||||
for (key, value) in map {
|
||||
// TODO: This should use some sort of sensitive wrapper to avoid ever logging the value to the console
|
||||
write_proc.arg(format!("{key}={value}"));
|
||||
}
|
||||
write_proc.try_spawn_to_string()?;
|
||||
destroy_openbao_old_versions(E::NAME)?;
|
||||
log::info!("Transferred {}.", E::NAME);
|
||||
if let Some(idx) = all_entries
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find_map(|(idx, e)| if e == E::NAME { Some(idx) } else { None })
|
||||
{
|
||||
let _ = all_entries.swap_remove(idx);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn destroy_openbao_old_versions(name: &str) -> anyhow::Result<()> {
|
||||
let mut proc = common::proc::Command::new("bao");
|
||||
proc.args([
|
||||
"kv",
|
||||
"metadata",
|
||||
"get",
|
||||
"-mount=opentofu",
|
||||
"-format=json",
|
||||
name,
|
||||
]);
|
||||
let metadata: KvItemMetadata = proc.try_spawn_to_json()?;
|
||||
let mut versions: Vec<_> = metadata.data.versions.into_iter().collect();
|
||||
versions.sort_by(|a, b| {
|
||||
let dest = a.1.destroyed.cmp(&b.1.destroyed).reverse();
|
||||
if !dest.is_eq() {
|
||||
return dest;
|
||||
}
|
||||
a.1.created_time.cmp(&b.1.created_time).reverse()
|
||||
});
|
||||
let versions_to_destroy = versions
|
||||
.iter()
|
||||
.filter(|(_, e)| !e.destroyed)
|
||||
.map(|(version, _)| version.as_str())
|
||||
.skip(1)
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
if !versions_to_destroy.is_empty() {
|
||||
let mut delete_proc = common::proc::Command::new("bao");
|
||||
delete_proc.args(["kv", "destroy", "-mount=opentofu"]);
|
||||
delete_proc.arg(format!("-versions={versions_to_destroy}"));
|
||||
delete_proc.arg(name);
|
||||
delete_proc.try_spawn_to_bytes()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct KvItemMetadata {
|
||||
data: KvItemMetadataData,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct KvItemMetadataData {
|
||||
versions: KvItemMetadataVersions,
|
||||
}
|
||||
|
||||
type KvItemMetadataVersions = BTreeMap<String, KvItemMetadataVersion>;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct KvItemMetadataVersion {
|
||||
created_time: String,
|
||||
destroyed: bool,
|
||||
}
|
128
rust/program/infrastructure/src/secrets/mod.rs
Normal file
128
rust/program/infrastructure/src/secrets/mod.rs
Normal file
|
@ -0,0 +1,128 @@
|
|||
use std::{borrow::Cow, collections::BTreeMap};
|
||||
|
||||
use common::bitwarden::{BitwardenEntry, BitwardenEntryTypeData, BitwardenSession};
|
||||
|
||||
use crate::secrets::openbao::read_bao_data;
|
||||
|
||||
mod endpoints;
|
||||
mod openbao;
|
||||
|
||||
pub use endpoints::{CliEndpoint, transfer_from_bitwarden_to_vault};
|
||||
|
||||
pub trait Endpoint {
|
||||
const NAME: &'static str;
|
||||
const BITWARDEN_KEY: &'static str;
|
||||
const ENV_KEYS: &'static [&'static str];
|
||||
const BITWARDEN_KEYS: &'static [BitwardenKey];
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone)]
|
||||
pub enum BitwardenKey {
|
||||
Username,
|
||||
Password,
|
||||
Field(&'static str),
|
||||
}
|
||||
|
||||
impl BitwardenKey {
|
||||
pub fn read_from_entry(self, entry: &BitwardenEntry) -> anyhow::Result<&str> {
|
||||
match self {
|
||||
Self::Username => match &entry.data {
|
||||
BitwardenEntryTypeData::Login(bitwarden_entry_type_login) => {
|
||||
let Some(username) = bitwarden_entry_type_login.username.as_deref() else {
|
||||
return Err(anyhow::format_err!(
|
||||
"Login entry {} has no username set",
|
||||
entry.name()
|
||||
));
|
||||
};
|
||||
Ok(username)
|
||||
}
|
||||
_ => Err(anyhow::format_err!(
|
||||
"Could not read username from entry {}. Entry is not a login entry",
|
||||
entry.name()
|
||||
)),
|
||||
},
|
||||
Self::Password => match &entry.data {
|
||||
BitwardenEntryTypeData::Login(bitwarden_entry_type_login) => {
|
||||
let Some(password) = bitwarden_entry_type_login.password.as_deref() else {
|
||||
return Err(anyhow::format_err!(
|
||||
"Login entry {} has no password set",
|
||||
entry.name()
|
||||
));
|
||||
};
|
||||
Ok(password)
|
||||
}
|
||||
_ => Err(anyhow::format_err!(
|
||||
"Could not read password from entry {}. Entry is not a login entry",
|
||||
entry.name()
|
||||
)),
|
||||
},
|
||||
Self::Field(field_name) => {
|
||||
let Some(field) = entry
|
||||
.fields
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.find(|e| e.name.as_deref() == Some(field_name))
|
||||
else {
|
||||
return Err(anyhow::format_err!(
|
||||
"Entry {} has no field named {field_name}",
|
||||
entry.name()
|
||||
));
|
||||
};
|
||||
let Some(value) = field.value.as_deref() else {
|
||||
return Err(anyhow::format_err!(
|
||||
"Entry {} has no value set for field {}",
|
||||
entry.name(),
|
||||
field_name
|
||||
));
|
||||
};
|
||||
Ok(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait EndpointReader {
|
||||
fn read_from_bitwarden(
|
||||
&self,
|
||||
session: &mut BitwardenSession,
|
||||
map: &mut BTreeMap<Cow<'static, str>, String>,
|
||||
) -> anyhow::Result<()>;
|
||||
|
||||
fn read_from_openbao(
|
||||
&self,
|
||||
map: &mut BTreeMap<Cow<'static, str>, String>,
|
||||
) -> anyhow::Result<()>;
|
||||
}
|
||||
|
||||
impl<E: Endpoint> EndpointReader for E {
|
||||
fn read_from_bitwarden(
|
||||
&self,
|
||||
session: &mut BitwardenSession,
|
||||
map: &mut BTreeMap<Cow<'static, str>, String>,
|
||||
) -> anyhow::Result<()> {
|
||||
let Some(item) = session.get_item(Self::BITWARDEN_KEY)? else {
|
||||
return Err(anyhow::format_err!(
|
||||
"Bitwarden key {} does not exist",
|
||||
Self::BITWARDEN_KEY
|
||||
));
|
||||
};
|
||||
|
||||
for (key, value) in Self::BITWARDEN_KEYS.iter().zip(Self::ENV_KEYS.iter()) {
|
||||
let field_value = key.read_from_entry(item)?;
|
||||
map.insert((*value).into(), field_value.to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_from_openbao(
|
||||
&self,
|
||||
map: &mut BTreeMap<Cow<'static, str>, String>,
|
||||
) -> anyhow::Result<()> {
|
||||
let result = read_bao_data::<Self>()?;
|
||||
for (key, value) in result {
|
||||
map.insert(key.into(), value);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
105
rust/program/infrastructure/src/secrets/openbao.rs
Normal file
105
rust/program/infrastructure/src/secrets/openbao.rs
Normal file
|
@ -0,0 +1,105 @@
|
|||
use std::{collections::BTreeMap, marker::PhantomData, vec::IntoIter};
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::secrets::Endpoint;
|
||||
|
||||
pub struct EnvEntry<T>(Vec<(&'static str, String)>, PhantomData<T>);
|
||||
|
||||
impl<T: Endpoint> EnvEntry<T> {
|
||||
pub fn try_new_from_env() -> anyhow::Result<Self> {
|
||||
let mut result = Vec::with_capacity(T::ENV_KEYS.len());
|
||||
for key in T::ENV_KEYS {
|
||||
let value = common::env::read_env(key)?;
|
||||
result.push((*key, value));
|
||||
}
|
||||
Ok(Self(result, PhantomData))
|
||||
}
|
||||
|
||||
fn new_from_values(values: Vec<(&'static str, String)>) -> Self {
|
||||
Self(values, PhantomData)
|
||||
}
|
||||
|
||||
pub fn read_from_bao() -> anyhow::Result<Self> {
|
||||
read_bao_data::<T>()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> From<EnvEntry<T>> for Vec<(&'static str, String)> {
|
||||
fn from(value: EnvEntry<T>) -> Self {
|
||||
value.0
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> IntoIterator for EnvEntry<T> {
|
||||
type Item = (&'static str, String);
|
||||
|
||||
type IntoIter = IntoIter<Self::Item>;
|
||||
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
self.0.into_iter()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de, T: Endpoint> serde::Deserialize<'de> for EnvEntry<T> {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
deserializer.deserialize_map(EnvEntryVisitor(PhantomData))
|
||||
}
|
||||
}
|
||||
struct EnvEntryVisitor<T>(PhantomData<T>);
|
||||
|
||||
impl<'de, T: Endpoint> serde::de::Visitor<'de> for EnvEntryVisitor<T> {
|
||||
type Value = EnvEntry<T>;
|
||||
|
||||
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
formatter.write_fmt(format_args!(
|
||||
"a map with unique keys {} with string values",
|
||||
T::ENV_KEYS.join(", "),
|
||||
))
|
||||
}
|
||||
|
||||
fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
|
||||
where
|
||||
A: serde::de::MapAccess<'de>,
|
||||
{
|
||||
let mut values = BTreeMap::<&'static str, String>::new();
|
||||
while let Some((key, value)) = map.next_entry::<&'de str, String>()? {
|
||||
let mapped_key = T::ENV_KEYS.iter().find(|n| **n == key).copied();
|
||||
let Some(key) = mapped_key else {
|
||||
return Err(serde::de::Error::unknown_field(key, T::ENV_KEYS));
|
||||
};
|
||||
if values.contains_key(key) {
|
||||
return Err(serde::de::Error::duplicate_field(key));
|
||||
}
|
||||
values.insert(key, value);
|
||||
}
|
||||
for key in T::ENV_KEYS {
|
||||
if !values.contains_key(key) {
|
||||
return Err(serde::de::Error::missing_field(key));
|
||||
}
|
||||
}
|
||||
let values = values.into_iter().collect();
|
||||
let entry = EnvEntry::<T>::new_from_values(values);
|
||||
Ok(entry)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OpenBaoKvEntry<T> {
|
||||
data: OpenBaoKvEntryData<T>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OpenBaoKvEntryData<T> {
|
||||
data: T,
|
||||
}
|
||||
|
||||
pub fn read_bao_data<T: Endpoint>() -> anyhow::Result<EnvEntry<T>> {
|
||||
let mut cmd = common::proc::Command::new("bao");
|
||||
cmd.args(["kv", "get", "-format=json", "-mount=opentofu", T::NAME]);
|
||||
let result: OpenBaoKvEntry<EnvEntry<T>> = cmd.try_spawn_to_json()?;
|
||||
Ok(result.data.data)
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue