Begin working on porting much of the opentofu related code
Some checks failed
/ rust-packages (push) Successful in 13m31s
/ dev-shell (push) Successful in 4m18s
/ check (push) Failing after 4m18s
/ terraform-providers (push) Successful in 13m19s
/ systems (push) Successful in 50m43s

into rust. Mainly this should give proper argument parsing and
error handling, and also remove some of all the scattered shell
scripts.
This commit is contained in:
Kaare Hoff Skovgaard 2025-08-05 01:42:57 +02:00
parent 30cf1f407a
commit e6a152e95c
Signed by: khs
GPG key ID: C7D890804F01E9F0
9 changed files with 319 additions and 25 deletions

View file

@ -275,11 +275,14 @@ in
data_json = '' data_json = ''
{ {
"template": "{id}", "template": "{id}",
"mapping": ''${ jsonencode({ ${ "disks": ''${ jsonencode({ ${
lib.strings.concatStringsSep ", " ( lib.strings.concatStringsSep ", " (
lib.lists.map ( lib.lists.map (disk: ''
disk: "${builtins.toJSON disk.name} = data.hcloud_volume.${disk.nameSanitized}.linux_device" ${builtins.toJSON disk.name} = {
) cfg.dataDisks "linuxDevice" = data.hcloud_volume.${disk.nameSanitized}.linux_device,
"size" = ${builtins.toString disk.size}
}
'') cfg.dataDisks
) )
} }) } } }) }
} }

View file

@ -1,10 +0,0 @@
{ lib, ... }:
{
options.khscodes.infrastructure.nixos-install = {
preScript = lib.mkOption {
type = lib.types.anything;
default = "";
description = "Script to run before running nixos-anywhere.";
};
};
}

View file

@ -18,7 +18,7 @@ pkgs.writeShellApplication {
fqdn="$1" fqdn="$1"
config="$2" config="$2"
cmd="''${3:-apply}" cmd="''${3:-apply}"
dir="$(mktemp -dt "$fqdn-compute-provision.XXXXXX")" dir="$(mktemp -dt "$fqdn-provision.XXXXXX")"
mkdir -p "$dir" mkdir -p "$dir"
cat "''${config}" > "$dir/config.tf.json" cat "''${config}" > "$dir/config.tf.json"

View file

@ -19,16 +19,8 @@ pkgs.writeShellApplication {
nix build --no-link '${inputs.self}#nixosConfigurations."'"$hostname"'".config.system.build.toplevel' nix build --no-link '${inputs.self}#nixosConfigurations."'"$hostname"'".config.system.build.toplevel'
fi fi
baseAttr='${inputs.self}#nixosConfigurations."'"$hostname"'".config.khscodes.infrastructure' baseAttr='${inputs.self}#nixosConfigurations."'"$hostname"'".config.khscodes.infrastructure'
config="$(nix build --no-link --print-out-paths "''${baseAttr}.provisioning.compute.config")"
preScript="$(nix eval --raw "''${baseAttr}.nixos-install.preScript")"
username="$(nix eval --raw "''${baseAttr}.provisioning.imageUsername")" username="$(nix eval --raw "''${baseAttr}.provisioning.imageUsername")"
if [[ "$config" == "null" ]]; then
echo "No preprovisioning needed"
exit 0
fi
INSTALL_ARGS=() nixos-anywhere --flake "${inputs.self}#$hostname" --target-host "$username@$host"
eval "$preScript"
nixos-anywhere --flake "${inputs.self}#$hostname" --target-host "$username@$host" "''${INSTALL_ARGS[@]}"
''; '';
} }

View file

@ -4,6 +4,7 @@ pkgs.writeShellApplication {
runtimeInputs = [ pkgs.khscodes.provision ]; runtimeInputs = [ pkgs.khscodes.provision ];
text = '' text = ''
instance="''${1:-}" instance="''${1:-}"
provision "$instance" persistence apply
provision "$instance" combinedPersistenceAttachAndCompute apply provision "$instance" combinedPersistenceAttachAndCompute apply
''; '';
} }

13
rust/Cargo.lock generated
View file

@ -795,6 +795,19 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "provision"
version = "1.0.0"
dependencies = [
"anyhow",
"clap",
"common",
"hakari",
"log",
"serde",
"serde_json",
]
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.40" version = "1.0.40"

View file

@ -141,7 +141,7 @@ pub fn read_to_string(path: &Path) -> anyhow::Result<String> {
#[cfg(target_family = "unix")] #[cfg(target_family = "unix")]
pub fn create_link(from: &Path, to: &Path) -> anyhow::Result<()> { pub fn create_link(from: &Path, to: &Path) -> anyhow::Result<()> {
std::os::unix::fs::symlink(to, from).with_context(|| { std::os::unix::fs::symlink(from, to).with_context(|| {
format!( format!(
"Could not create symbolic link from {from} to {to}", "Could not create symbolic link from {from} to {to}",
from = from.display(), from = from.display(),

View file

@ -0,0 +1,14 @@
[package]
name = "provision"
edition = "2024"
version = "1.0.0"
metadata.crane.name = "provision"
[dependencies]
anyhow = { workspace = true }
clap = { workspace = true }
common = { path = "../../lib/common" }
log = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
hakari = { version = "0.1", path = "../../lib/hakari" }

View file

@ -0,0 +1,281 @@
use serde::Deserialize;
use std::{
collections::BTreeMap,
net::Ipv4Addr,
path::{Path, PathBuf},
};
use clap::{Parser, Subcommand};
fn main() {
common::entrypoint(program);
}
#[derive(Parser, Debug)]
#[command(version, about, long_about = None)]
pub struct Args {
#[command(subcommand)]
pub command: Commands,
}
#[derive(Debug, Subcommand)]
pub enum Commands {
/// Creates the instance.
CreateInstance(CreateInstance),
/// Updates the provisioned resources for the instance.
Update(UpdateInstance),
/// Configures the software installed on he instance.
Configure(ConfigureInstance),
/// Destroys the instance, but leaves behind persistent data, unless forcing deletion.
Destroy(DestroyInstance),
}
#[derive(Debug, Clone, clap::Args)]
pub struct CreateInstance {
/// Name of the instance to create.
instance: String,
#[arg(short = 's')]
skip_sanitity_checks: bool,
}
#[derive(Debug, Clone, clap::Args)]
pub struct UpdateInstance {
/// Name of the instance to update.
instance: String,
}
#[derive(Debug, Clone, clap::Args)]
pub struct ConfigureInstance {
/// Name of the instance to configure.
instance: String,
}
#[derive(Debug, Clone, clap::Args)]
pub struct DestroyInstance {
/// Name of the instance to destroy.
instance: String,
}
fn program() -> anyhow::Result<()> {
let args = Args::parse();
let flake_path = common::env::read_path_env("FLAKE_PATH")?;
match args.command {
Commands::CreateInstance(instance) => create_instance(instance, &flake_path),
_ => Ok(()),
}
}
#[derive(Deserialize, Debug)]
struct ProvisioningData {
persistence: OpenTofuConfig,
compute: OpenTofuConfig,
compute_with_persistence_attached: OpenTofuConfig,
configuration: OpenTofuConfig,
secrets_source: SecretsSource,
image_username: String,
}
#[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<String>,
}
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(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(key.as_ref(), value.as_ref());
}
proc.args([&self.chdir_arg, action]);
proc.try_spawn_to_bytes()?;
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(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]);
let result = proc.stderr_inherit().try_spawn_to_json()?;
Ok(result)
}
}
fn nixos_install(
flake_path: &Path,
instance: &str,
username: &str,
ip_addr: &Ipv4Addr,
) -> anyhow::Result<()> {
let mut proc = common::proc::Command::new("nixos-install");
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-install didn't succeed"));
}
Ok(())
}
fn create_instance(c: CreateInstance, flake_path: &Path) -> anyhow::Result<()> {
let data = ProvisioningData::try_new(&c.instance, flake_path)?;
if data.compute.config.is_none() {
return Err(anyhow::format_err!(
"No compute resources allocated for {}",
c.instance
));
}
if !c.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(),
c.instance
),
]);
proc.stderr_inherit().try_spawn_to_bytes()?;
}
// TODO: Gather all needed endpoints and load the environments as needed from either Bitwarden or
// vault. Can probably get rid of the nix bw implementation and merge the rust helper program into this one
// to remove unneeded stuff.
let env = BTreeMap::<String, String>::new();
if let Some(mut persistence) = data.persistence.init(&c.instance, &env)? {
persistence.run("apply")?;
persistence.cleanup()?;
}
let mut compute = data
.compute
.init(&c.instance, &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_install(
flake_path,
&c.instance,
&data.image_username,
&output.ipv4_address.value,
)?;
if let Some(mut compute_with_persistence) = data
.compute_with_persistence_attached
.init(&c.instance, &env)?
{
compute_with_persistence.run("apply")?;
compute_with_persistence.cleanup()?;
}
Ok(())
}