# Mgmt
# Copyright (C) James Shubin and the project contributors
# Written by James Shubin <james@shubin.ca> and the project contributors
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <https://www.gnu.org/licenses/>.
#
# Additional permission under GNU GPL version 3 section 7
#
# If you modify this program, or any covered work, by linking or combining it
# with embedded mcl code and modules (and that the embedded mcl code and
# modules which link with this program, contain a copy of their source code in
# the authoritative form) containing parts covered by the terms of any other
# license, the licensors of this program grant you additional permission to
# convey the resulting work. Furthermore, the licensors of this program grant
# the original author, James Shubin, additional permission to update this
# additional permission if he deems it necessary to achieve the goals of this
# additional permission.

# Run `sudo setcap CAP_NET_BIND_SERVICE=+eip mgmt` first to avoid running as root.
# based on: https://docs.fedoraproject.org/en-US/fedora/f36/install-guide/advanced/Network_based_Installations/

import "convert"
import "deploy"
import "encoding"
import "fmt"
import "golang"
import "golang/path/filepath" as golang_path_filepath
import "golang/strings" as golang_strings
import "list"
import "local"
import "net"
import "os"
import "sys"
import "value"
import "world"

$http_suffix = "http/"
$tftp_suffix = "tftp/"
$uefi_suffix = "uefi/"
$kickstart_suffix = "kickstart/"
$ipxe_iso_filename = "ipxe.iso"
$ipxe_iso_default = "https://boot.ipxe.org/${ipxe_iso_filename}" # default download source
# TODO: add more architectures
$ipxe_efi_x86_64_filename = "ipxe.x86_64.efi" # .efi must be last for it to work!
$ipxe_efi_x86_64_default = "https://boot.ipxe.org/x86_64-efi/ipxe.efi"
$ipxe_autoexec_filename = "autoexec.ipxe"

# The base class is the core provisioner which can also spawn child classes.
class base($config) {
	#
	#	variables
	#
	$interface_env = sys.getenv("MGMT_PROVISIONER_INTERFACE")
	$interface_def = if $interface_env == "" {
		net.wired_interface() # XXX: add <|> "" when it exists!
	} else {
		$interface_env
	}
	$interface_config = $config->interface || ""
	$interface = if $interface_config == "" {
		$interface_def # if no interface exists, we error
	} else {
		$interface_config
	}

	$http_port = $config->http_port || 4280 # using :4280 avoids needing root and isn't in /etc/services
	$http_port_str = convert.int_to_str($http_port)

	# if $network is the empty string, then we don't do any dhcp ourselves!
	# if $network is the empty string, then we don't bring up the interface!
	# an unspecified network uses the private default, while an explicitly
	# empty network uses the address already assigned to the interface.
	$network_def = if sys.hasenv("MGMT_PROVISIONER_NETWORK") {
		sys.getenv("MGMT_PROVISIONER_NETWORK")
	} else {
		"192.168.42.0/24"
	}
	$network = $config->network || "${network_def}"

	$router_config = $config->router || ""
	$router = if $router_config == "" {
		if $network == "" {
			net.interface_cidr($interface)
		} else {
			if sys.hasenv("MGMT_PROVISIONER_ROUTER") {
				sys.getenv("MGMT_PROVISIONER_ROUTER")
			} else {
				"192.168.42.1/24"
			}
		}
	} else {
		$router_config
	}
	$router_ip = net.cidr_to_ip($router) # removes cidr suffix

	$dns = $config->dns || ["8.8.8.8", "1.1.1.1"] # maybe google/cloudflare will sponsor!

	$prefix = $config->prefix || ""
	panic($prefix == "", "provisioner:base $prefix is empty") # panic if prefix is empty
	panic(not golang_strings.has_suffix($prefix, "/"), "provisioner:base $prefix is not absolute")

	file "${prefix}" { # dir
		state => $const.res.file.state.exists,
	}
	$tftp_prefix = "${prefix}${tftp_suffix}"
	$http_prefix = "${prefix}${http_suffix}"
	$uefi_prefix = "${prefix}${uefi_suffix}"

	$firewalld = $config->firewalld || true

	# eg: equivalent of: https://download.fedoraproject.org/pub/fedora/linux/
	$inst_repo_base = "http://${router_ip}:${http_port_str}/fedora/" # private lan online, no https!
	$server_base = "http://${router_ip}:${http_port_str}/" # private lan online, no https!

	$syslinux_root = "/usr/share/syslinux/"

	$nbp_bios = "tftp://${router_ip}/pxelinux.0" # for bios clients
	$nbp_uefi = "tftp://${router_ip}/uefi/shim.efi" # for uefi clients

	# Fetch an iPXE ISO once and serve the cached copy from the provisioner.
	# An explicit empty source disables it. The exported $ipxe_iso is then
	# empty too.
	$ipxe_iso_source = $config->ipxe_iso || $ipxe_iso_default
	$ipxe_iso_file = "${http_prefix}${ipxe_iso_filename}"
	$ipxe_iso = if $ipxe_iso_source == "" {
		""
	} else {
		"${server_base}${ipxe_iso_filename}"
	}

	# Fetch architecture-specific iPXE UEFI executables once and serve them
	# forever. Each architecture has an explicit variable and a stable root
	# URL, while every executable shares the root autoexec.ipxe below.
	$ipxe_efi_x86_64_source = $config->ipxe_efi_x86_64 || $ipxe_efi_x86_64_default
	$ipxe_efi_x86_64_file = "${http_prefix}${ipxe_efi_x86_64_filename}"
	$ipxe_efi_x86_64 = if $ipxe_efi_x86_64_source == "" {
		""
	} else {
		"${server_base}${ipxe_efi_x86_64_filename}"
	}
	$ipxe_autoexec_template = struct{
		server_base => $server_base,
	}

	#
	#	network
	#
	if $network != "" {
		net "${interface}" {
			state => $const.res.net.state.up,
			addrs => [$router], # has cidr suffix
			#gateway => "192.168.42.1", # TODO: get upstream public gateway with new function

			ip_forward => true, # XXX: does this work?

			Meta:reverse => true, # XXX: ^C doesn't reverse atm. Fix that!

			Before => Dhcp:Server[":67"], # TODO: add autoedges
		}
	}

	#
	#	packages
	#
	# TODO: do we need "syslinux-nonlinux" ?
	$pkgs_bios = ["syslinux"]
	$pkgs_uefi = ["shim-x64", "grub2-efi-x64"]
	pkg $pkgs_bios {
		state => "installed",
	}
	#pkg $pkgs_uefi {
	#	state => "installed",
	#}

	# TODO: not in F39+ it seems
	#$pkgs_kickstart = ["fedora-kickstarts", "spin-kickstarts"]
	#pkg $pkgs_kickstart {
	#	state => "installed",
	#}

	#
	#	firewalld
	#
	if $firewalld {
		firewalld "provisioner" { # name is irrelevant
			services => [
				"tftp",
				"dhcp",
			],
			ports => ["${http_port_str}/tcp"],

			state => $const.res.firewalld.state.exists,
		}
	}

	file "${tftp_prefix}" { # dir
		state => $const.res.file.state.exists,
	}
	file "${uefi_prefix}" { # dir
		state => $const.res.file.state.exists,
	}

	#
	#	tftp
	#
	tftp:server ":69" {
		timeout => 60, # increase the timeout
		#root => $root, # we're running in memory without needing a root!
		#debug => true, # XXX: print out a `tree` representation in tmp prefix for the user

		Depend => Pkg[$pkgs_bios], # hosted by tftp
		#Depend => Pkg[$pkgs_uefi],
	}

	#
	#	bios bootloader images
	#

	# XXX: should this also be part of repo too?
	class tftp_root_file($f) {
		#tftp:file "${f}" {	# without root slash
		tftp:file "/${f}" { # with root slash
			path => $syslinux_root + $f, # TODO: add autoedges

			Depend => Pkg[$pkgs_bios],
		}
	}
	include tftp_root_file("pxelinux.0")
	include tftp_root_file("vesamenu.c32")
	include tftp_root_file("ldlinux.c32")
	include tftp_root_file("libcom32.c32")
	include tftp_root_file("libutil.c32")

	#
	#	dhcp
	#
	if $network != "" {
		dhcp:server ":67" {
			interface => $interface, # required for now
			leasetime => "10m", # seems some clients refresh half way at 5m
			dns => $dns, # pick your own better ones!
			routers => [$router_ip],
			serverid => $router_ip, # XXX: test automatic mode

			#Depend => Net[$interface], # TODO: add autoedges
		}
	}

	#
	#	http
	#
	file "${http_prefix}" { # dir
		state => $const.res.file.state.exists,
	}

	http:server ":${http_port_str}" {
		#address => ":${http_port_str}", # you can override the name like this
		#timeout => 60, # add a timeout (seconds)
	}

	if $ipxe_iso_source != "" { # download it once so we have a copy
		exec "provisioner-ipxe-iso" {
			cmd => "/usr/bin/wget",
			args => [
				"--no-verbose",
				$ipxe_iso_source,
				"-O",
				$ipxe_iso_file,
			],
			creates => $ipxe_iso_file,

			Depend => File["${http_prefix}"],
		}

		http:server:file "/${ipxe_iso_filename}" {
			path => $ipxe_iso_file,

			Depend => Exec["provisioner-ipxe-iso"],
			Before => Print["ready"],
		}
	}

	if $ipxe_efi_x86_64_source != "" {
		exec "provisioner-ipxe-efi-x86-64" {
			cmd => "/usr/bin/wget",
			args => [
				"--no-verbose",
				$ipxe_efi_x86_64_source,
				"-O",
				$ipxe_efi_x86_64_file,
			],
			creates => $ipxe_efi_x86_64_file,

			Depend => File["${http_prefix}"],
		}

		http:server:file "/${ipxe_efi_x86_64_filename}" {
			path => $ipxe_efi_x86_64_file,

			Depend => Exec["provisioner-ipxe-efi-x86-64"],
			Before => Print["ready"],
		}
	}

	# UEFI HTTP Boot gives iPXE the executable's full URL, so every
	# architecture-specific executable above discovers this shared script.
	http:server:file "/${ipxe_autoexec_filename}" {
		data => golang.template(deploy.readfile("/files/ipxe-autoexec.tmpl"), $ipxe_autoexec_template),

		Before => Print["ready"],
	}

	$kickstart_http_prefix = "${http_prefix}${kickstart_suffix}"
	file "${kickstart_http_prefix}" {
		state => $const.res.file.state.exists,
		#source => "", # this default means empty directory
		recurse => true,
		purge => true, # remove unmanaged files in here

		Depend => File["${http_prefix}"],
	}

	$vardir = local.vardir("provisioner/")
	$binary_path = deploy.binary_path()

	http:server:file "/mgmt/binary" { # TODO: support different architectures
		path => $binary_path, # TODO: As long as binary doesn't contain private data!

		Before => Print["ready"],
	}

	# XXX: don't put anything private in your code this isn't authenticated!
	$abs_tar = "${vardir}deploys/deploy.tar"
	$abs_gz = "${abs_tar}.gz"

	file "${vardir}deploys/" {
		state => $const.res.file.state.exists,
		recurse => true,
		purge => true,
		owner => "root",
		group => "root",
		mode => "u=rwx,g=rx,o=", # dir
	}
	# Tag this so that the folder purge doesn't remove it. (XXX: bug!)
	file "${abs_tar}" {
		owner => "root",
		group => "root",
		mode => "u=rw,g=rw,o=", # file

		Meta:retry => -1, # changing the mode on this file can be racy
	}
	file "${abs_gz}" {
		owner => "root",
		group => "root",
		mode => "u=rw,g=rw,o=", # file

		Meta:retry => -1, # changing the mode on this file can be racy
	}
	deploy:tar "${abs_tar}" {
		Before => Gzip["${abs_gz}"],
		Depend => File["${vardir}deploys/"], # make the dir first!
	}
	gzip "${abs_gz}" {
		input => "${abs_tar}",
	}
	http:server:file "/mgmt/deploy.tar.gz" {
		path => "${abs_gz}",
	}

	print "ready" {
		msg => "ready to provision!",

		Meta:autogroup => false,

		Depend => Tftp:Server[":69"],
		Depend => $network != "" ?: Dhcp:Server[":67"],
		Depend => Http:Server[":${http_port_str}"],
	}

	# we're effectively returning a new class definition...
}

# The repo class which is a child of base, defines the distro repo to use.
class base:repo($config) {

	$distro = $config->distro || "fedora"
	$version = $config->version || "39" # not an int!
	$arch = $config->arch || "x86_64"
	#$flavour = $config->flavour || "" # is flavour needed for repo sync?

	# export this value to parent scope for base:host to consume
	$uid = "${distro}${version}-${arch}" # eg: fedora39-x86_64

	# TODO: We need a way to pick a good default because if a lot of people
	# use this, then most won't change it to one in their country...
	$mirror = $config->mirror || "" # TODO: how do we pick a default?
	$rsync = $config->rsync || ""

	$is_fedora = $distro == "fedora"

	$distroarch_tftp_prefix = "${tftp_prefix}${uid}/"
	$distroarch_uefi_prefix = "${uefi_prefix}${uid}/"
	$distroarch_http_prefix = "${http_prefix}${uid}/"
	$distroarch_release_http_prefix = "${distroarch_http_prefix}release/"
	$distroarch_updates_http_prefix = "${distroarch_http_prefix}updates/"

	file "${distroarch_tftp_prefix}" { # dir
		state => $const.res.file.state.exists,

		#Meta:quiet => true,	# TODO

		Depend => File["${tftp_prefix}"],
	}
	file "${distroarch_uefi_prefix}" { # dir
		state => $const.res.file.state.exists,

		Depend => File["${uefi_prefix}"],
	}
	file "${distroarch_http_prefix}" { # root http dir
		state => $const.res.file.state.exists,

		Depend => File["${http_prefix}"],
	}
	file "${distroarch_release_http_prefix}" {
		state => $const.res.file.state.exists,

		Depend => File["${distroarch_http_prefix}"],
	}
	file "${distroarch_updates_http_prefix}" {
		state => $const.res.file.state.exists,

		Depend => File["${distroarch_http_prefix}"],
	}

	#
	#	uefi bootloader images
	#
	$uefi_download_dir = "${distroarch_uefi_prefix}download/"
	$uefi_extract_dir = "${distroarch_uefi_prefix}extract/"

	file "${uefi_extract_dir}" { # mkdir
		state => $const.res.file.state.exists,

		Depend => Exec["uefi-download-${uid}"],
		Before => Exec["uefi-extract-${uid}"],
	}

	# Download the shim and grub2-efi packages. If your server is a BIOS
	# system, you must download the packages to a temporary install root.
	# Installing them directly on a BIOS machine will attempt to configure
	# the system for UEFI booting and cause problems.
	$pkgs_uefi_string = golang_strings.join($pkgs_uefi, " ")
	$repoidname = "local"
	# eg: https://mirror.csclub.uwaterloo.ca/fedora/linux/releases/39/Everything/x86_64/os/
	$repo_url = "http://${router_ip}:${http_port_str}/fedora/releases/${version}/Everything/${arch}/os/"

	exec "uefi-download-${uid}" {
		# no inner quotes because it's not bash handling this!
		# the dnf download command makes the download destination dir
		# With DNF5 (provisioning OS Fedora 41+) it's --destdir.
		# Previously it's --downloaddir and no idea why the API break.
		cmd => "/usr/bin/dnf download ${pkgs_uefi_string} --assumeyes --disablerepo=* --repofrompath ${repoidname},${repo_url} --destdir=${uefi_download_dir} --releasever ${version}",

		# TODO: add an optional expiry mtime check that deletes these old files with an || rm * && false
		ifcmd => "! test -s '${uefi_download_dir}shim-x64'*",
		ifshell => "/usr/bin/bash",

		Depend => Http:Server[":${http_port_str}"],
	}

	exec "uefi-extract-${uid}" {
		# we use rpm2archive instead of cpio since the latter is deprecated for big files
		# we do this in a loop for all the rpm files
		cmd => "for i in ${uefi_download_dir}*.rpm; do /usr/bin/rpm2archive \$i | /usr/bin/tar -xvz --directory ${uefi_extract_dir} --exclude ./etc; done",
		shell => "/usr/bin/bash",

		# TODO: add an optional expiry mtime check that deletes these old files with an || rm * && false
		creates => $uefi_shim,

		Depend => Exec["uefi-download-${uid}"],
		Before => Tftp:Server[":69"],
	}

	$uefi_root = "${uefi_extract_dir}/boot/efi/EFI/fedora/"
	$uefi_shim = "${uefi_root}shim.efi"
	$uefi_shimx64 = "${uefi_root}shimx64.efi"
	tftp:file "/uefi/shim.efi" { # needs leading slash
		path => $uefi_shim, # TODO: add autoedges

		Depend => Exec["uefi-extract-${uid}"],
	}
	tftp:file "/uefi/shimx64.efi" { # needs leading slash
		path => $uefi_shimx64, # TODO: add autoedges

		Depend => Exec["uefi-extract-${uid}"],
	}
	tftp:file "/uefi/grubx64.efi" { # sometimes used?
		path => "${uefi_root}grubx64.efi", # TODO: add autoedges

		Depend => Exec["uefi-extract-${uid}"],
	}
	tftp:file "grubx64.efi" { # no leading slash
		path => "${uefi_root}grubx64.efi", # TODO: add autoedges

		Depend => Exec["uefi-extract-${uid}"],
	}

	# XXX: replace with a download resource
	# XXX: allow send->recv to pass this file to tftp:file->data to keep it in mem!
	$vmlinuz_file = "${distroarch_tftp_prefix}vmlinuz"
	exec "vmlinuz-${uid}" {
		cmd => "/usr/bin/wget",
		args => [
			"--no-verbose",
			"${repo_url}images/pxeboot/vmlinuz",
			"-O",
			$vmlinuz_file,
		],
		creates => $vmlinuz_file,

		Depend => File[$distroarch_tftp_prefix],
		Depend => Http:Server[":${http_port_str}"],
		Before => Print["ready"],
	}

	tftp:file "/${uid}/vmlinuz" {
		path => $vmlinuz_file, # TODO: add autoedges

		#Depend => Pkg[$pkgs],
	}
	http:server:file "/${uid}/vmlinuz" { # when using ipxe
		path => $vmlinuz_file, # TODO: add autoedges

		#Depend => Pkg[$pkgs],
	}

	$initrd_file = "${distroarch_tftp_prefix}initrd.img"
	exec "initrd-${uid}" {
		cmd => "/usr/bin/wget",
		args => [
			"--no-verbose",
			"${repo_url}images/pxeboot/initrd.img",
			"-O",
			$initrd_file,
		],
		creates => $initrd_file,

		Depend => File[$distroarch_tftp_prefix],
		Depend => Http:Server[":${http_port_str}"],
		Before => Print["ready"],
	}

	tftp:file "/${uid}/initrd.img" {
		path => $initrd_file, # TODO: add autoedges

		#Depend => Pkg[$pkgs],
	}
	http:server:file "/${uid}/initrd.img" { # when using ipxe
		path => $initrd_file, # TODO: add autoedges

		#Depend => Pkg[$pkgs],
	}

	# this file resource serves the entire rsync directory over http
	if $mirror == "" { # and $rsync != ""
		http:server:file "/fedora/releases/${version}/Everything/${arch}/os/" {
			path => $distroarch_release_http_prefix,
		}
		http:server:file "/fedora/updates/${version}/Everything/${arch}/" {
			path => $distroarch_updates_http_prefix,
		}
	} else {
		# same as the above http:server:file path would have been
		http:server:proxy "/fedora/releases/${version}/Everything/${arch}/os/" {
			sub => "/fedora/", # we remove this from the name!
			head => $mirror,

			cache => $distroarch_release_http_prefix, # $prefix/http/fedora39-x86_64/release/
		}

		# XXX: if we had both of these in the same http_prefix, we could overlap them with an rsync :/ hmm...
		http:server:proxy "/fedora/updates/${version}/Everything/${arch}/" { # no os/ dir at the end
			sub => "/fedora/", # we remove this from the name!
			head => $mirror,

			cache => $distroarch_updates_http_prefix, # $prefix/http/fedora39-x86_64/updates/
		}
	}

	# .repo files
	#$fedora_repo_template = struct{
	#	name => "fedora",
	#	distro => $distro,
	#	version => $version, # 39 for fedora 39
	#	arch => $arch, # could also be aarch64
	#	#flavour => "Everything", # The install repo uses "Everything" even for "Workstation" or "Server"
	#	extra => "releases",
	#
	#	baseurl => "http://${router_ip}:${http_port_str}/fedora/releases/${version}/Everything/${arch}/os/",
	#}
	#$updates_repo_template = struct{
	#	name => "updates",
	#	distro => $distro,
	#	version => $version, # 39 for fedora 39
	#	arch => $arch, # could also be aarch64
	#	#flavour => "Everything", # The install repo uses "Everything" even for "Workstation" or "Server"
	#	extra => "updates",
	#
	#	baseurl => "http://${router_ip}:${http_port_str}/fedora/updates/${version}/Everything/${arch}/",
	#}
	#http:server:file "/fedora/${uid}/fedora.repo" {
	#	data => golang.template(deploy.readfile("/files/repo.tmpl"), $fedora_repo_template),
	#}
	#http:server:file "/fedora/${uid}/updates.repo" {
	#	data => golang.template(deploy.readfile("/files/repo.tmpl"), $updates_repo_template),
	#}

	#
	#	rsync
	#
	#$source_pattern = if $is_fedora {
	#	"${rsync}releases/${version}/Everything/${arch}/os/" # source
	#} else {
	#	"" # XXX: not implemented
	#}
	#panic($source_pattern == "") # distro is not specified
	# TODO: combine release and updates?
	#$is_safe = $distroarch_release_http_prefix != "" and $distroarch_release_http_prefix != "/"
	#if $rsync != "" and $source_pattern != "" and $is_safe {
	#
	#	$mtime_file = "${http_prefix}rsync-${uid}.mtime"
	#	$delta = convert.int_to_str(60 * 60 * 24 * 7) # ~1 week in seconds: 604800
	#	exec "rsync-${uid}" {
	#		cmd => "/usr/bin/rsync",
	#		args => [
	#			"-avSH",
	#			"--progress",
	#			# This flavour must always be Everything to work.
	#			# The Workstation flavour doesn't have an os/ dir.
	#			$source_pattern, # source
	#			$distroarch_release_http_prefix, # dest
	#		],
	#
	#		# run this when cmd completes successfully
	#		donecmd => "/usr/bin/date --utc > ${mtime_file}",
	#		doneshell => "/usr/bin/bash",
	#
	#		# Run if the difference between the current date and the
	#		# saved date (both converted to sec) is greater than the
	#		# delta! (Or if the mtime file does not even exist yet.)
	#		ifcmd => "! /usr/bin/test -e ${mtime_file} || /usr/bin/test \$((`/usr/bin/date +%s` - `/usr/bin/stat -c %Y '${mtime_file}'`)) -gt ${delta}",
	#
	#		ifshell => "/usr/bin/bash",
	#
	#		Before => Http:Server[":${http_port_str}"],
	#		Before => File[$distroarch_release_http_prefix],
	#	}
	#}
}

# The host class is used for each physical host we want to provision.
class base:host($name, $config) {
	#print $name {
	#	msg => "host: ${name}",
	#
	#	Meta:autogroup => false,
	#}
	$repouid = $config->repo || ""
	$uidst = os.parse_distro_uid($repouid)
	$distro str = $uidst->distro
	$version str = $uidst->version # not an int!
	$arch str = $uidst->arch
	panic($distro == "", "provisioner:host $distro is empty")
	panic($version == "", "provisioner:host $version is empty")
	panic($arch == "", "provisioner:host $arch is empty")
	$flavour = $config->flavour || ""

	$mac = $config->mac || ""
	#panic($mac == "") # provision anyone by default
	$ip = $config->ip || "" # XXX: auto-generate it inside of the above network somehow (see below)

	if $network != "" and $mac != "" {
		panic($ip == "", "provisioner:host $ip is empty")
	}
	#$ns = if $config->ip == "" {
	#	""
	#} else {
	#	"" + get_value("network") # XXX: implement some sort of lookup function
	#}
	#$ip = $config->ip || magic.pool($ns, [1,2,3,4], $name) # XXX: if $ns is "", then don't allocate. Otherwise get from list. Reuse based on $name hash.
	$bios = $config->bios || false
	$password = $config->password || "" # empty means disabled
	panic(len($password) != 0 and len($password) != 106, "provisioner:host $password (salted) is wrong length") # length of salted password
	$sshkey = golang_strings.trim_space($config->sshkey || "") # root ssh pub key to add

	$part = $config->part || "" # partitioning scheme
	$luks = $config->luks || true # enable luks encryption

	$empty_list_str []str = [] # need an explicit type on empty list definition
	$packages = $config->packages || $empty_list_str

	# should we provision this host by default?
	$provision_default = $config->provision || false # false is safest!

	$handoff_type = $config->handoff || ""
	$handoff_exec = $config->handoff_exec || ""
	$handoff_code = $config->handoff_code || ""
	$handoff_module_path = $config->handoff_module_path || ""
	$handoff_svc_sshurl = $config->handoff_svc_sshurl || ""
	$handoff_svc_hostkey = $config->handoff_svc_hostkey || ""
	$handoff_svc_sshid = $config->handoff_svc_sshid || ""
	panic(($handoff_svc_sshurl != "") != ($handoff_svc_hostkey != ""), "provisioner:host $handoff_svc needs sshurl (${handoff_svc_sshurl}) and hostkey (${handoff_svc_hostkey})")

	panic($handoff_code != "" and not golang_strings.has_prefix($handoff_code, "/"), "provisioner:host $handoff_code is not absolute")
	$handoff_hostname = $config->handoff_hostname || ""

	# This is a giant driver://user:password@host:port/whatever URL...
	$bmc_uri = $config->bmc_uri || ""

	# unique host key which is usually a mac address unless it's a default
	$hkey = if $mac == "" {
		"default"
	} else {
		$mac
	}
	$provision_key = $hkey # XXX: what unique id should we use for the host? mac? name? hkey?
	$provision_key_done = "${provision_key}-done"
	$provision_key_sshkey = "${provision_key}-sshkey"

	#$ret = world.getval($provision_key_done) # has it previously been provisioned?
	#$val = if $ret->value == "" { # avoid an invalid string killing the parse_bool function
	#	convert.format_bool(false) # "false"
	#} else {
	#	$ret->value
	#}
	#$provision = if not $ret->exists {
	#	$provision_default
	#} else {
	#	not convert.parse_bool($val) # XXX: should an invalid string return false or error here?
	#}
	$provision = true

	$nbp_path = if $bios {
		"/pxelinux.0" # for bios clients
	} else {
		"/uefi/shim.efi" # for uefi clients
	}

	if $network != "" {
		if $mac != "" {
			dhcp:host "${name}" { # the hostname
				mac => $mac,
				ip => $ip, # cidr notation is required

				nbp => $provision ?: if $bios { # XXX: do we want this from the base class?
					$nbp_bios # from base class
				} else {
					$nbp_uefi # from base class
				},
				nbp_path => $provision ?: $nbp_path, # with leading slash

				Depend => Tftp:Server[":69"],
			}
		} else {
			# Handle ANY mac address since we don't have one specified!
			# TODO: Our dhcp:range could send/recv a map from ip => mac address!
			dhcp:range "${name}" {
				network => "${network}", # eg: 192.168.42.0/24
				skip => [$router], # eg: 192.168.42.1/24

				nbp => $provision ?: if $bios { # XXX: do we want this from the base class?
					$nbp_bios # from base class
				} else {
					$nbp_uefi # from base class
				},
				nbp_path => $provision ?: $nbp_path, # with leading slash

				Depend => Tftp:Server[":69"],
			}
		}
	}

	$menu_template = struct{
		distro => $distro,
		version => $version, # 39 for fedora 39
		arch => $arch, # could also be aarch64
		flavour => "Everything", # The install repo uses "Everything" even for "Workstation" or "Server"

		ks => "http://${router_ip}:${http_port_str}/fedora/kickstart/${hkey}.ks", # usually $mac or `default`
		inst_repo_base => $inst_repo_base,
		server_base => $server_base,
	}

	#
	#	default menus
	#
	$safe_mac = if $mac == "" {
		"00:00:00:00:00:00"
	} else {
		$mac
	}
	$old_mac = net.oldmacfmt($safe_mac)
	# no idea why these need a 01- prefix
	$bios_menu = if $mac == "" {
		"/pxelinux.cfg/default"
	} else {
		# /pxelinux.cfg/01-00-11-22-33-44-55-66
		"/pxelinux.cfg/01-${old_mac}"
	}
	$uefi_menu = if $mac == "" {
		# XXX: add the front slash!?
		#"pxelinux/uefi" # TODO: Did some machines use this?
		"/uefi/grub.cfg"
	} else {
		# /uefi/grub.cfg-01-00-11-22-33-44-55-66
		"/uefi/grub.cfg-01-${old_mac}"
	}

	$ipxe_menu = if $mac == "" {
		"menu.ipxe"
	} else {
		"${safe_mac}.ipxe"
	}

	if $bios {
		tftp:file "${bios_menu}" { # for bios
			data => golang.template(deploy.readfile("/files/bios-menu.tmpl"), $menu_template),
		}
	} else {
		tftp:file "${uefi_menu}" { # for uefi
			# XXX: linuxefi & initrdefi VS. kernel & append ?
			data => golang.template(deploy.readfile("/files/uefi-menu.tmpl"), $menu_template),

			#Depend => Pkg[$pkgs_uefi],
			#Depend => Exec["uefi-extract"],
		}
	}

	# https://<ip>:4280/${mac}.ipxe # works for ipxe scripts!
	http:server:file "/${ipxe_menu}" { # for ipxe
		data => golang.template(deploy.readfile("/files/ipxe-menu.tmpl"), $menu_template),
	}
	if $mac != "" { # generate old one but never the menu.ipxe default
		http:server:file "/${old_mac}.ipxe" { # for ipxe
			data => golang.template(deploy.readfile("/files/ipxe-menu.tmpl"), $menu_template),
		}
	}

	# If it's a dir we don't need a suffix, otherwise return the last chunk.
	$handoff_code_chunk = if golang_strings.has_suffix($prefix, "/") {
		""
	} else {
		golang_path_filepath.base($handoff_code)
	}
	if $handoff_code != "" { # it's a file path or dir!
		$abs_tar = "${vardir}deploys/deploy-${provision_key}.tar"
		$abs_gz = "${abs_tar}.gz"

		# Tag this so that the folder purge doesn't remove it. (XXX: bug!)
		file "${abs_tar}" {
			owner => "root",
			group => "root",
			mode => "u=rw,g=rw,o=", # file

			Meta:retry => -1, # changing the mode on this file can be racy
		}
		file "${abs_gz}" {
			owner => "root",
			group => "root",
			mode => "u=rw,g=rw,o=", # file

			Meta:retry => -1, # changing the mode on this file can be racy
		}
		tar "${abs_tar}" {
			inputs => [
				$handoff_code, # code comes in here!
				golang_strings.trim_suffix($handoff_module_path, "/"), # remove trailing slash to include the dir name
			],

			Before => Gzip["${abs_gz}"],
			Depend => File["${vardir}deploys/"], # make the dir first!
		}
		gzip "${abs_gz}" {
			input => "${abs_tar}",
		}
		http:server:file "/mgmt/deploy-${provision_key}.tar.gz" {
			path => "${abs_gz}",
		}
	}

	$handoff_binary_path = "/usr/local/bin/mgmt" # we install it here
	$firstboot_scripts_dir = "/var/lib/mgmt-firstboot/" # TODO: /usr/lib/ instead?
	$firstboot_done_dir = "/var/lib/mgmt-firstboot/done/"
	$deploy_dir = "/root/mgmt-deploy/" # deploy code dir
	$modules_chunk = golang_path_filepath.base(golang_strings.trim_suffix($handoff_module_path, "/")) # "modules" typically
	$deploy_dir_modules = "${deploy_dir}${modules_chunk}/"
	# TODO: we can customize these more precisely based on $handoff_type
	$handoff_packages = deploy.bootstrap_packages($distro) # TODO: catch errors here with || []
	panic($handoff_type != "" and len($handoff_packages) == 0, "provisioner:host $handoff_packages list is empty")
	#$handoff_packages_string = golang_strings.join($handoff_packages, " ")

	$sshkey_flag = "sshkey"
	$sshkey_type = "ed25519" # TODO: support other options
	$sshkey_path = "/root/.ssh/id_${sshkey_type}"
	$setup_sshkey = "mkdir -p '/root/.ssh/' -m 700 && ssh-keygen -N '' -t '${sshkey_type}' -f '${sshkey_path}'" # non-interactive
	# Setting up the known_hosts file isn't needed since that can be done on
	# the first run of mgmt, and the initial key can come in from --ssh-url.

	$handoff_binary = if $handoff_type == "" {
		""
	} else {
		# Copy over the actual mgmt binary. This enables a lot below...
		"/usr/bin/wget -O '${handoff_binary_path}' 'http://${router_ip}:${http_port_str}/mgmt/binary' && /usr/bin/chmod u+x '${handoff_binary_path}'"
	}
	$handoff_cpcode = if $handoff_type == "code" {
		# Download a tar ball of our code.
		# TODO: Alternate mechanisms of getting the code are possible.
		if $mac != "" {
			"/usr/bin/wget -O /root/mgmt-deploy.tar.gz 'http://${router_ip}:${http_port_str}/mgmt/deploy-${provision_key}.tar.gz' && /usr/bin/mkdir '${deploy_dir}' && /usr/bin/tar -xf /root/mgmt-deploy.tar.gz --directory '${deploy_dir}'"
		} else {
			"/usr/bin/wget -O /root/mgmt-deploy.tar.gz 'http://${router_ip}:${http_port_str}/mgmt/deploy.tar.gz' && /usr/bin/mkdir '${deploy_dir}' && /usr/bin/tar -xf /root/mgmt-deploy.tar.gz --directory '${deploy_dir}'"
		}
	} else {
		""
	}
	$handoff_service = if $handoff_type == "svc" { # TODO: maybe add it in other scenarios
		# Setup the remote mgmt service, which starts on firstboot.
		# NOTE: We use the short mgmt path because it's easier to pivot.
		# XXX: read the ssh hostkey from a .known_hosts file instead
		"${handoff_binary_path} setup svc --binary-path='mgmt' --install --enable --ssh-url='${handoff_svc_sshurl}' --ssh-hostkey='${handoff_svc_hostkey}' --ssh-id='${handoff_svc_sshid}' --seeds='http://127.0.0.1:2379'"
	} else {
		if $handoff_type == "code" {
			# Setup the mgmt service, which starts on firstboot.
			"${handoff_binary_path} setup svc --binary-path='${handoff_binary_path}' --install --enable"
		} else {
			""
		}
	}
	$handoff_firstboot = if $handoff_type == "" {
		""
	} else {
		# Setup the firstboot service itself.
		"${handoff_binary_path} setup firstboot --binary-path='${handoff_binary_path}' --mkdir --install --enable --scripts-dir='${firstboot_scripts_dir}' --done-dir='${firstboot_done_dir}'"
	}

	$handoff_firstboot_exec = if $handoff_type == "exec" and $handoff_exec != "" {
		# Add a script that will run by our firstboot service on boot.
		# This usually just runs mgmt over the network to etcd.
		"echo '#!/usr/bin/env bash' > ${firstboot_scripts_dir}mgmt-exec.sh && echo 'ulimit -n 16384' >> ${firstboot_scripts_dir}mgmt-exec.sh && echo '${handoff_exec}' >> ${firstboot_scripts_dir}mgmt-exec.sh && chmod u+x ${firstboot_scripts_dir}mgmt-exec.sh"
	} else {
		""
	}

	$handoff_firstboot_code = if $handoff_type == "code" {
		# Add a script that will run by our firstboot service on boot.
		# It seems that the deploy will hang until mgmt is started...
		# NOTE: We need to add the $handoff_code arg the same way it was
		# passed into the provisioner. It's just now in a deploy subdir.
		# If it's a dir, then this becomes the empty strings.
		"echo '#!/usr/bin/env bash' > ${firstboot_scripts_dir}mgmt-code.sh && echo '${handoff_binary_path} deploy lang --seeds=http://127.0.0.1:2379 --no-git --module-path=${deploy_dir_modules} ${deploy_dir}${handoff_code_chunk}' >> ${firstboot_scripts_dir}mgmt-code.sh && chmod u+x ${firstboot_scripts_dir}mgmt-code.sh"
	} else {
		""
	}

	# TODO: Do we want to signal an http:server:flag if we're a "default" host?
	$provisioning_done = "/usr/bin/curl --data-urlencode 'done=true' --data-urlencode 'password=sha1TODO' --data-urlencode 'sshtype=ssh-${sshkey_type}' --data-urlencode \"${sshkey_flag}=\$(cut -d ' ' -f 2 '${sshkey_path}.pub')\" --data-urlencode 'sshcomment=root@${handoff_hostname}' -o - 'http://${router_ip}:${http_port_str}/action/done/mac=${provision_key}'"

	$http_kickstart_template = struct{
		comment => "hello!",
		lang => [
			"en_CA.UTF-8",
			"fr_CA.UTF-8",
			"en_US.UTF-8",
		],
		timezone => "UTC", # real servers all run in UTC
		ntp_servers => ["0.pool.ntp.org", "1.pool.ntp.org"], # TODO: set your own?
		password => $password, # salted
		bios => $bios,
		part => $part,
		luks => $luks, # set empty password
		flavour => $flavour,
		url => "http://${router_ip}:${http_port_str}/fedora/releases/${version}/Everything/${arch}/os/",
		repos => {
			#"fedora" => "http://${router_ip}:${http_port_str}/fedora/releases/${version}/Everything/${arch}/os/", # TODO: this vs url ?
			"updates" => "http://${router_ip}:${http_port_str}/fedora/updates/${version}/Everything/${arch}/",
		},
		#repos => { # needs internet or blocks at storage https://bugzilla.redhat.com/show_bug.cgi?id=2269752
		#	"fedora" => "https://mirrors.fedoraproject.org/mirrorlist?repo=fedora-\$releasever&arch=\$basearch",
		#	"updates" => "https://mirrors.fedoraproject.org/mirrorlist?repo=updates-released-f\$releasever&arch=\$basearch",
		#},
		# We need $handoff_packages installed in the _KICKSTART_ environ
		# so that we can actually run mgmt to do our work for us below!
		packages => list.concat($packages, $handoff_packages),
		# XXX: is wget segfaulting in the kickstart environment? curl is OK.
		#pre => [
		#	"/usr/bin/mkdir /etc/yum.repos.d/",
		#	"/usr/bin/wget 'http://${router_ip}:${http_port_str}/fedora/${repouid}/fedora.repo' -O /etc/yum.repos.d/fedora.repo",
		#	"/usr/bin/wget 'http://${router_ip}:${http_port_str}/fedora/${repouid}/updates.repo' -O /etc/yum.repos.d/updates.repo",
		#	"/usr/bin/dnf --enablerepo=fedora,updates install --assumeyes ${handoff_packages_string}",
		#],
		hostname => $handoff_hostname,
		sshkeys => {
			"root" => "${sshkey}", # root ssh pub key to add
		},
		post => [
			$setup_sshkey, # setup the ssh key
			$handoff_binary, # copy over the binary
			$handoff_cpcode, # copy over a bundle of code
			$handoff_service, # install a service for mgmt
			$handoff_firstboot, # install firstboot service
			$handoff_firstboot_exec, # install a firstboot script to exec
			$handoff_firstboot_code, # install a firstboot script to deploy
			$provisioning_done, # send a done signal back here
		],
	}

	$kickstart_file = "${kickstart_http_prefix}${hkey}.ks"
	file "${kickstart_file}" {
		state => $const.res.file.state.exists,
		content => golang.template(deploy.readfile("/files/kickstart.ks.tmpl"), $http_kickstart_template),

		Depend => File["${kickstart_http_prefix}"],
	}

	http:server:file "/fedora/kickstart/${hkey}.ks" { # usually $mac or `default`
		#data => golang.template(deploy.readfile("/files/kickstart.ks.tmpl"), $http_kickstart_template),
		path => $kickstart_file,

		Before => Print["ready"],
	}

	if "${bmc_uri}" != "" {
		bmc:power "${bmc_uri}" { # TODO: Name() API is not yet stable
			#password => "hunter2",
			insecure_password => true, # XXX: get from uri for now

			state => "on",

			Meta:poll => 60, # required until BMC's support real events!
		}
	}

	##$str_true = convert.format_bool(true)
	##$str_false = convert.format_bool(false)
	http:server:flag "${name}-done" {
		key => "done",
		path => "/action/done/mac=${provision_key}",
		#mapped => {$str_true => $str_true, $str_false => $str_false},
	}
	http:server:flag "${name}-sshkey" {
		key => "${sshkey_flag}",
		path => "/action/done/mac=${provision_key}",
	}

	# Persist each callback field separately for this provisioning identity.
	kv "${name}-done" {
		key => $provision_key_done,
	}
	value "${name}-done" {
		#any => true, # bool
	}
	kv "${name}-sshkey" {
		key => $provision_key_sshkey,
	}
	value "${name}-sshkey" {
		#any => "",
	}

	Http:Server:Flag["${name}-done"].value -> Kv["${name}-done"].value
	Http:Server:Flag["${name}-done"].value -> Value["${name}-done"].any
	Http:Server:Flag["${name}-sshkey"].value -> Kv["${name}-sshkey"].value
	Http:Server:Flag["${name}-sshkey"].value -> Value["${name}-sshkey"].any

	$st_done = value.get_str("${name}-done")
	$provisioned = $st_done->ready and $st_done->value == "true" # export this value to parent scope

	$st_sshkey = value.get_str("${name}-sshkey")
	$host_sshkey = $st_sshkey->value # export this value to parent scope

	if $provisioned {
		ssh:authorized_key "provisioner:${name}" { # magic name for collect
			#file => "/var/lib/etcd/.ssh/authorized_keys",
			#options => [
			#	"command=\"echo 'Port forwarding only'; exit\"",
			#	"no-agent-forwarding",
			#	"no-pty",
			#	"no-user-rc",
			#	"no-X11-forwarding",
			#	"permitopen=\"127.0.0.1:2379\"",
			#],
			type => "ssh-${sshkey_type}", # TODO: support other options
			key => "${host_sshkey}",
			comment => "root@${name}", # XXX: is this the right comment?
			state => "exists",

			Meta:hidden => true,
			Meta:export => ["*"], # TODO: get the etcd hostname?
		}
	}
}

class base:runner($config) {
	$sshkey = $config->sshkey || "" # pub key installed on new host
	$sshid = $config->sshid || "" # custom private key on new host used for client auth | empty: pass all available as possible signer

	$etcd_server = world.getval("provisioner_etcd_server")->value # struct{value str; exists bool}
	if $etcd_server != "" {
		# all one json str so they all change at the same instant
		$etcd_server_st = encoding.decode_json_flexible($etcd_server, "struct{host str; port int; hostkey str;}")
		$etcd_server_host = $etcd_server_st->host
		$etcd_server_port = convert.int_to_str($etcd_server_st->port)
		$etcd_server_hostkey = golang_strings.trim_space($etcd_server_st->hostkey) # AAAA...
		$etcd_server_sshurl = "ssh://etcd@${etcd_server_host}:${etcd_server_port}" # eg: ssh://etcd@example.com:22

		# use "StrMapSet" with the hostname we provision so it can change itself!
		# etcdctl put /_mgmt/strings/provisioner/hostname1 '{"uid": "fedora43-x86_64", "flavour": "Workstation", "mac": "00:11:22:33:44:55", "luks": true}'
		# etcdctl put /_mgmt/strings/provisioner/hostname2 '{"uid": "fedora43-x86_64", "flavour": "Server", "mac": "aa:bb:cc:dd:ee:ff", "luks": false}'
		$available = world.kvlookup("provisioner") # map{str: str} (val is json)
		# looks like:
		#$available = {
		#	"hostname1" => struct{
		#		mac => "00:11:22:33:44:55",
		#		luks => true,
		#	},
		#	"hostname2" => struct{
		#		mac => "aa:bb:cc:dd:ee:ff",
		#		flavour => "Server",
		#	},
		#}

		forkv $k, $json_str in $available {
			# The "flexible" version here lets us add new struct fields over
			# time, even if they aren't initially present in the data yet...
			$st = encoding.decode_json_flexible($json_str, "struct{uid str; flavour str; mac str; luks bool;}")

			$uid str = $st->uid
			$flavour str = $st->flavour
			$mac str = $st->mac
			$luks bool = $st->luks

			print "host: ${mac}" {
				msg => "uid: ${uid}, flavour: ${flavour}",

				Meta:autogroup => false,
			}
			include host("${k}", struct{ # TODO: do we need a usable name anywhere?
				repo => $uid,
				flavour => $flavour,
				mac => $mac,
				#ip => "",
				#bios => false, # false or absent means use uefi
				#password => "", # openssl passwd -6
				sshkey => "${sshkey}", # root ssh pub key to add
				#part => $st->part,
				luks => $luks,
				#packages => $st->packages,
				#provision => true, # default if unspecified
				handoff => "svc", # sets up mgmt with handoff_svc
				handoff_svc_sshurl => $etcd_server_sshurl,
				handoff_svc_hostkey => $etcd_server_hostkey,
				handoff_svc_sshid => $sshid,
				#handoff => "exec", # sets up mgmt with handoff_exec
				#handoff_exec => "mgmt run --ssh-url='${etcd_server_sshurl}' --ssh-hostkey='${etcd_server_hostkey}' --ssh-id='${sshid}' --seeds=http://127.0.0.1:2379 --no-autoedges --no-autogroup --no-pgp empty",
				#handoff_code => "/etc/mgmt/", # one way to do it
				#handoff_module_path => "/etc/mgmt/modules/",
				handoff_hostname => $k,
				#bmc_uri => $st->bmc_uri,
			}) as host0

			if $host0.provisioned {
				# remove the entry once provisioned
				kv "provisioner" {
					mapped => true,
					#state => "absent",
					#value => # absent to delete key
				}
			}
		}
	}
}
