Attempt to implement and test setting static ips from instance metadata
This commit is contained in:
parent
dd1cfa79e7
commit
47dbb7cdd3
16 changed files with 258 additions and 59 deletions
|
@ -1 +1 @@
|
||||||
flake-profile-2-link
|
flake-profile-4-link
|
|
@ -1 +0,0 @@
|
||||||
/nix/store/dqy98711cp1rqz8nj7qpxq6qj6vnyf0j-nix-shell-env
|
|
1
.direnv/flake-profile-4-link
Symbolic link
1
.direnv/flake-profile-4-link
Symbolic link
|
@ -0,0 +1 @@
|
||||||
|
/nix/store/k5vgwymjcra0rv45n3vza2myawy6w48z-nix-shell-env
|
8
flake.lock
generated
8
flake.lock
generated
|
@ -92,11 +92,11 @@
|
||||||
"treefmt-nix": "treefmt-nix"
|
"treefmt-nix": "treefmt-nix"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1751720970,
|
"lastModified": 1751834884,
|
||||||
"narHash": "sha256-Fe8yQfmjlgNSrkBU/5FcYBQVsOFyfxe73C1zfsHhXDU=",
|
"narHash": "sha256-LUggV7UgPbnUkDHpbqZc25jmgTOqQfW/C1JPOLgkQAk=",
|
||||||
"ref": "refs/heads/main",
|
"ref": "refs/heads/main",
|
||||||
"rev": "b3ddb341d8bfe6fb5f618dfee1f720a3deeee47d",
|
"rev": "05c74cc4e6e44913663d72b78222ffc855f0c834",
|
||||||
"revCount": 10,
|
"revCount": 11,
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://khs.codes/nix/flake-base"
|
"url": "https://khs.codes/nix/flake-base"
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,19 +1,48 @@
|
||||||
{ inputs, pkgs, ... }:
|
{ inputs, pkgs, ... }:
|
||||||
|
let
|
||||||
|
sharedModule = {
|
||||||
|
# Since it's common for CI not to have $DISPLAY available, explicitly disable graphics support
|
||||||
|
virtualisation.graphics = false;
|
||||||
|
};
|
||||||
|
in
|
||||||
pkgs.nixosTest {
|
pkgs.nixosTest {
|
||||||
name = "hetzner-will-boot";
|
name = "hetzner-sets-ipv6";
|
||||||
nodes.machine =
|
nodes = {
|
||||||
{ ... }:
|
machine =
|
||||||
{
|
{ ... }:
|
||||||
imports = [ inputs.self.nixosModules.default ];
|
{
|
||||||
khscodes.hetzner = {
|
imports = [
|
||||||
enable = true;
|
inputs.self.nixosModules.default
|
||||||
ipv6-addr = "dead:beef:cafe::1";
|
sharedModule
|
||||||
|
];
|
||||||
|
khscodes.hetzner = {
|
||||||
|
enable = true;
|
||||||
|
metadataApiUri = "http://metadata/metadata.yml";
|
||||||
|
};
|
||||||
|
system.stateVersion = "25.05";
|
||||||
};
|
};
|
||||||
system.stateVersion = "25.05";
|
metadata =
|
||||||
};
|
{ ... }:
|
||||||
|
{
|
||||||
|
imports = [ sharedModule ];
|
||||||
|
services.nginx = {
|
||||||
|
enable = true;
|
||||||
|
virtualHosts = {
|
||||||
|
"metafata" = {
|
||||||
|
root = ./root;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
networking.firewall.allowedTCPPorts = [ 80 ];
|
||||||
|
system.stateVersion = "25.05";
|
||||||
|
};
|
||||||
|
};
|
||||||
testScript = ''
|
testScript = ''
|
||||||
machine.start(allow_reboot = True)
|
metadata.start()
|
||||||
machine.wait_for_unit("multi-user.target")
|
metadata.wait_for_unit("nginx.service")
|
||||||
|
metadata.wait_for_open_port(80)
|
||||||
|
machine.start()
|
||||||
|
machine.wait_for_unit("hetzner-static-ip.service")
|
||||||
ipv6 = machine.succeed("ip addr")
|
ipv6 = machine.succeed("ip addr")
|
||||||
assert "dead:beef:cafe::1" in ipv6
|
assert "dead:beef:cafe::1" in ipv6
|
||||||
'';
|
'';
|
||||||
|
|
12
nix/checks/hetzner-sets-ipv6/root/metadata.yml
Normal file
12
nix/checks/hetzner-sets-ipv6/root/metadata.yml
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
---
|
||||||
|
network-config:
|
||||||
|
config:
|
||||||
|
- name: eth0
|
||||||
|
subnets:
|
||||||
|
- ipv4: true
|
||||||
|
type: dhcp
|
||||||
|
- address: dead:beef:cafe::1/64
|
||||||
|
gateway: fe80::1
|
||||||
|
ipv6: true
|
||||||
|
type: static
|
||||||
|
type: physical
|
|
@ -1,6 +1,7 @@
|
||||||
{
|
{
|
||||||
config,
|
config,
|
||||||
lib,
|
lib,
|
||||||
|
pkgs,
|
||||||
system,
|
system,
|
||||||
...
|
...
|
||||||
}:
|
}:
|
||||||
|
@ -10,16 +11,16 @@ in
|
||||||
{
|
{
|
||||||
options.khscodes.hetzner = {
|
options.khscodes.hetzner = {
|
||||||
enable = lib.mkEnableOption "Enables the machine as a hetzner machine";
|
enable = lib.mkEnableOption "Enables the machine as a hetzner machine";
|
||||||
ipv6-addr = lib.mkOption {
|
|
||||||
type = lib.types.nullOr lib.types.str;
|
|
||||||
description = "IPv6 address of the server, for now detecting this from the server itself is not supported";
|
|
||||||
default = null;
|
|
||||||
};
|
|
||||||
diskName = lib.mkOption {
|
diskName = lib.mkOption {
|
||||||
type = lib.types.str;
|
type = lib.types.str;
|
||||||
default = "nixos";
|
default = "nixos";
|
||||||
description = "Name of the root disk device";
|
description = "Name of the root disk device";
|
||||||
};
|
};
|
||||||
|
metadataApiUri = lib.mkOption {
|
||||||
|
type = lib.types.nullOr lib.types.str;
|
||||||
|
default = null;
|
||||||
|
description = "Sets the metadata API url that the server will contact to gather metadata information from. Should probably only be used for testing";
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
config = lib.mkIf cfg.enable {
|
config = lib.mkIf cfg.enable {
|
||||||
|
@ -48,10 +49,29 @@ in
|
||||||
networkConfig = {
|
networkConfig = {
|
||||||
DHCP = "ipv4";
|
DHCP = "ipv4";
|
||||||
};
|
};
|
||||||
routes = [ { Gateway = "fe80::1"; } ];
|
|
||||||
linkConfig.RequiredForOnline = "routable";
|
linkConfig.RequiredForOnline = "routable";
|
||||||
address = lib.mkIf (cfg.ipv6-addr != null) [ cfg.ipv6-addr ];
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
systemd.services.hetzner-static-ip = {
|
||||||
|
enable = true;
|
||||||
|
wants = [ "network-online.target" ];
|
||||||
|
after = [ "network-online.target" ];
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "oneshot";
|
||||||
|
RemainAfterExit = true;
|
||||||
|
ExecStart = ''
|
||||||
|
${lib.getExe pkgs.khscodes.hetzner-static-ip} configure
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
environment =
|
||||||
|
{
|
||||||
|
PATH = lib.mkForce "";
|
||||||
|
}
|
||||||
|
// lib.attrsets.optionalAttrs (cfg.metadataApiUri != null) {
|
||||||
|
INSTANCE_API_URI = cfg.metadataApiUri;
|
||||||
|
};
|
||||||
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,4 +3,4 @@
|
||||||
pkgs,
|
pkgs,
|
||||||
inputs,
|
inputs,
|
||||||
}:
|
}:
|
||||||
(lib.khscodes.mkRust pkgs "${inputs.self}/rust").buildRustPackage "hetzner-ipv6"
|
(lib.khscodes.mkRust pkgs "${inputs.self}/rust").buildRustPackage "hetzner-static-ip"
|
2
rust/Cargo.lock
generated
2
rust/Cargo.lock
generated
|
@ -187,7 +187,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hetzner-ipv6"
|
name = "hetzner-static-ip"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
|
|
@ -26,7 +26,7 @@ let
|
||||||
fileset = lib.fileset.unions [
|
fileset = lib.fileset.unions [
|
||||||
./Cargo.lock
|
./Cargo.lock
|
||||||
./Cargo.toml
|
./Cargo.toml
|
||||||
(craneLib.fileset.commonCargoSources ./lib/common)
|
(craneLib.fileset.commonCargoSources ./lib)
|
||||||
(craneLib.fileset.commonCargoSources ./program/${crate})
|
(craneLib.fileset.commonCargoSources ./program/${crate})
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
@ -40,6 +40,19 @@ in
|
||||||
pname = crateName;
|
pname = crateName;
|
||||||
cargoExtraArgs = "-p ${crateName}";
|
cargoExtraArgs = "-p ${crateName}";
|
||||||
src = fileSetForCrate crateName;
|
src = fileSetForCrate crateName;
|
||||||
|
nativeBuildInputs = [ pkgs.makeWrapper ];
|
||||||
|
postFixup = ''
|
||||||
|
wrapProgram $out/bin/${crateName} --set PATH "${
|
||||||
|
lib.makeBinPath [
|
||||||
|
pkgs.curl
|
||||||
|
pkgs.uutils-coreutils-noprefix
|
||||||
|
pkgs.iproute2
|
||||||
|
]
|
||||||
|
}"
|
||||||
|
'';
|
||||||
|
meta = {
|
||||||
|
mainProgram = crateName;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
checks = {
|
checks = {
|
||||||
|
|
|
@ -8,3 +8,53 @@ pub fn string(str: &str) -> String {
|
||||||
pub fn to_string<T: ?Sized + Serialize>(value: &T) -> anyhow::Result<String> {
|
pub fn to_string<T: ?Sized + Serialize>(value: &T) -> anyhow::Result<String> {
|
||||||
serde_yml::to_string(value).context("Could not serialize to yaml")
|
serde_yml::to_string(value).context("Could not serialize to yaml")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn from_str<D: for<'de> serde::Deserialize<'de>>(s: &str) -> anyhow::Result<D> {
|
||||||
|
serde_yml::from_str(s).map_err(|e| anyhow::format_err!("{e}:\n{}", extract_context(&e, s)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_context(serde_error: &serde_yml::Error, s: &str) -> String {
|
||||||
|
let Some(location) = serde_error.location() else {
|
||||||
|
return String::from("Error provided no location information, could not extract context");
|
||||||
|
};
|
||||||
|
let lines: Vec<_> = s.lines().collect();
|
||||||
|
if lines.len() == 1 {
|
||||||
|
let (col_begin, highlight) = if location.column() > 30 {
|
||||||
|
(location.column() - 30, 30)
|
||||||
|
} else {
|
||||||
|
(1, location.column())
|
||||||
|
};
|
||||||
|
let col_end = if lines[0].len() + 31 < location.column() {
|
||||||
|
lines[0].len() + 1
|
||||||
|
} else {
|
||||||
|
location.column() + 30
|
||||||
|
};
|
||||||
|
let mut line: String = lines[0]
|
||||||
|
.chars()
|
||||||
|
.skip(col_begin - 1)
|
||||||
|
.take(col_end - col_begin)
|
||||||
|
.collect();
|
||||||
|
line.push('\n');
|
||||||
|
line.extend(std::iter::repeat_n(' ', highlight - 1));
|
||||||
|
line.push('^');
|
||||||
|
line
|
||||||
|
} else {
|
||||||
|
let error_line = location.line();
|
||||||
|
let mut result = String::new();
|
||||||
|
if error_line > 1 {
|
||||||
|
result.push_str(&format!("{}: {}\n", error_line - 1, lines[error_line - 2]));
|
||||||
|
}
|
||||||
|
result.push_str(&format!("{}: {}\n", error_line, lines[error_line - 1]));
|
||||||
|
result.push_str(&format!(
|
||||||
|
"{} {}\n",
|
||||||
|
" ".repeat(error_line.to_string().len()),
|
||||||
|
std::iter::repeat_n(' ', location.column() - 1)
|
||||||
|
.chain(['^'].into_iter())
|
||||||
|
.collect::<String>(),
|
||||||
|
));
|
||||||
|
if lines.len() > error_line {
|
||||||
|
result.push_str(&format!("{}: {}\n", error_line + 1, lines[error_line]));
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -1,28 +0,0 @@
|
||||||
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 {
|
|
||||||
/// Configures the ipv6 address using instance metadata and iproute2
|
|
||||||
Configure,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn program() -> anyhow::Result<()> {
|
|
||||||
let args = Args::parse();
|
|
||||||
match args.command {
|
|
||||||
Commands::Configure => configure(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn configure() -> anyhow::Result<()> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
|
@ -1,8 +1,8 @@
|
||||||
[package]
|
[package]
|
||||||
name = "hetzner-ipv6"
|
name = "hetzner-static-ip"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
metadata.crane.name = "hetzner-ipv6"
|
metadata.crane.name = "hetzner-static-ip"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
71
rust/program/hetzner-static-ip/src/main.rs
Normal file
71
rust/program/hetzner-static-ip/src/main.rs
Normal file
|
@ -0,0 +1,71 @@
|
||||||
|
use anyhow::Context as _;
|
||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
|
||||||
|
use crate::metadata::Instance;
|
||||||
|
|
||||||
|
mod metadata;
|
||||||
|
|
||||||
|
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 {
|
||||||
|
/// Configures the ipv6 address using instance metadata and iproute2
|
||||||
|
Configure,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn program() -> anyhow::Result<()> {
|
||||||
|
let args = Args::parse();
|
||||||
|
match args.command {
|
||||||
|
Commands::Configure => configure(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn configure() -> anyhow::Result<()> {
|
||||||
|
let metadata_api = common::env::read_env("INSTANCE_API_URI")
|
||||||
|
.unwrap_or(String::from("http://169.254.169.254/hetzner/v1/metadata"));
|
||||||
|
let metadata = common::curl::read_text_as_string(&metadata_api)?;
|
||||||
|
let metadata: Instance = common::yaml::from_str(&metadata)
|
||||||
|
.context("Could not parse instance metadata into expected format")?;
|
||||||
|
for m in metadata.network_config.config {
|
||||||
|
for subnet in m.subnets {
|
||||||
|
match subnet {
|
||||||
|
metadata::InstanceNetworkConfigConfigSubnet::Static {
|
||||||
|
ipv6,
|
||||||
|
ipv4,
|
||||||
|
address,
|
||||||
|
gateway,
|
||||||
|
} => {
|
||||||
|
let mut cmd = common::proc::Command::new("ip");
|
||||||
|
if ipv6.is_some_and(|v| v) {
|
||||||
|
cmd.arg("-6");
|
||||||
|
}
|
||||||
|
if ipv4.is_some_and(|v| v) {
|
||||||
|
cmd.arg("-4");
|
||||||
|
}
|
||||||
|
cmd.args(["addr", "add", &address, "dev", &m.name]);
|
||||||
|
cmd.try_spawn_to_string()?;
|
||||||
|
let mut cmd = common::proc::Command::new("ip");
|
||||||
|
if ipv6.is_some_and(|v| v) {
|
||||||
|
cmd.arg("-6");
|
||||||
|
}
|
||||||
|
if ipv4.is_some_and(|v| v) {
|
||||||
|
cmd.arg("-4");
|
||||||
|
}
|
||||||
|
cmd.args(["route", "add", "default", "via", &gateway, "dev", &m.name]);
|
||||||
|
cmd.try_spawn_to_string()?;
|
||||||
|
}
|
||||||
|
metadata::InstanceNetworkConfigConfigSubnet::Dhcp {} => continue,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
32
rust/program/hetzner-static-ip/src/metadata.rs
Normal file
32
rust/program/hetzner-static-ip/src/metadata.rs
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct Instance {
|
||||||
|
#[serde(rename = "network-config")]
|
||||||
|
pub network_config: InstanceNetworkConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct InstanceNetworkConfig {
|
||||||
|
pub config: Vec<InstanceNetworkConfigConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct InstanceNetworkConfigConfig {
|
||||||
|
pub name: String,
|
||||||
|
pub subnets: Vec<InstanceNetworkConfigConfigSubnet>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(tag = "type")]
|
||||||
|
pub enum InstanceNetworkConfigConfigSubnet {
|
||||||
|
#[serde(rename = "static")]
|
||||||
|
Static {
|
||||||
|
ipv6: Option<bool>,
|
||||||
|
ipv4: Option<bool>,
|
||||||
|
address: String,
|
||||||
|
gateway: String,
|
||||||
|
},
|
||||||
|
#[serde(rename = "dhcp")]
|
||||||
|
Dhcp {},
|
||||||
|
}
|
|
@ -1,4 +1,4 @@
|
||||||
[toolchain]
|
[toolchain]
|
||||||
channel = "1.88.0"
|
channel = "1.88.0"
|
||||||
components = ["rustfmt", "clippy", "cargo"]
|
components = ["rustfmt", "clippy", "cargo", "rust-src"]
|
||||||
profile = "minimal"
|
profile = "minimal"
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue