lang: core: embedded: Add standalone provisioning tool

This commit adds the ability to build a standalone provisioning tool.
This is the first useful public mcl code base as well. It is not
perfect, but does serve as a rough starting point to show what is
possible. In the future as the language and the engine evolve, this will
likely get more elegant, and also grow new features.

To build this, run `make clean && GOTAGS='embedded_provisioner' make`.
To run this, run `mgmt provisioner`.
This commit is contained in:
James Shubin
2024-03-26 20:33:43 -04:00
parent 375fe19f52
commit 4d9c78003a
8 changed files with 1436 additions and 0 deletions

View File

@@ -0,0 +1,37 @@
// Mgmt
// Copyright (C) 2013-2024+ 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.
//go:build embedded_provisioner
package coreembedded
import (
// import so it registers
_ "github.com/purpleidea/mgmt/lang/core/embedded/provisioner"
)

View File

@@ -0,0 +1,18 @@
default vesamenu.c32
prompt 1
timeout 150
label kickstart
menu label ^Install {{ .distro }} {{ .version }} {{ .arch }} ( kickstart )
menu default
kernel {{ .distro }}{{ .version }}-{{ .arch }}/vmlinuz
append initrd={{ .distro }}{{ .version }}-{{ .arch }}/initrd.img inst.stage2={{ .inst_repo_base }}releases/{{ .version }}/{{ .flavour }}/{{ .arch }}/os/ ip=dhcp inst.ks={{ .ks }}
label manual
menu label ^Install {{ .distro }} {{ .version }} {{ .arch }} ( manual )
kernel {{ .distro }}{{ .version }}-{{ .arch }}/vmlinuz
append initrd={{ .distro }}{{ .version }}-{{ .arch }}/initrd.img inst.stage2={{ .inst_repo_base }}releases/{{ .version }}/{{ .flavour }}/{{ .arch }}/os/ ip=dhcp
label local
menu label Boot from ^local drive
localboot 0xffff

View File

@@ -0,0 +1,186 @@
{{ if .comment -}}
#
# {{ .comment }}
#
{{- end }}
#
# readme
#
# All of this is based on reading docs, experimentation, and the kickstarts repo
# at: https://pagure.io/fedora-kickstarts which I am not an expert at reading.
# If you have recommendations for improvements, please let us know! Thanks.
#
# flavour: {{ .flavour }}
# part: {{ .part }}
# bios: {{ .bios }}
#
# system
#
text # text based install (not graphical)
{{ if .lang -}}
{{- $length_minus1 := math_minus1 (len .lang | printf "%d" | golang_strconv_atoi ) -}}
lang {{ index .lang 0 }}
{{- if gt (len .lang) 1 }} --addsupport=
{{- range $i, $x := .lang -}}
{{ if eq $i 0 }}{{ continue }}{{ end }}
{{- $x -}}
{{- if lt $i $length_minus1 -}},{{- end -}}
{{- end -}}
{{- end -}}
{{- end }}
keyboard us
{{ if .timezone -}}
# System timezone
timezone {{ .timezone }} --isUtc
{{- if .ntp_servers }} --ntpservers={{ golang_strings_join .ntp_servers "," }}{{ end -}}
{{- end }}
#
# security
#
{{ if .password -}}
# password can be crypted with:
# python3 -c 'import crypt; print(crypt.crypt("password", crypt.mksalt(crypt.METHOD_SHA512)))'
# or
# openssl passwd -6 -salt <YOUR_SALT> (salt can be omitted to generate one)
rootpw --iscrypted --allow-ssh {{ .password }}
{{ else }}
rootpw --iscrypted --lock
{{- end }}
selinux --enforcing
services --enabled=sshd,NetworkManager,chronyd
{{ if .sshkeys -}}
# TODO: sort in a deterministic order
{{ range $user, $pubkey := .sshkeys -}}
sshkey --username {{ $user }} "{{ $pubkey }}"
{{- end -}}
{{- end }}
#
# networking
#
# --device=link
# specifies the first interface with its link in the up state
# --activate
# any matching devices beyond the first will also be activated
#
network --bootproto=dhcp --device=link --activate
firewall --enabled --service=mdns
#
# partitioning
#
# TODO: add more magic partitioning schemes
zerombr
clearpart --all --initlabel --disklabel={{ if .bios }}msdos{{ else }}gpt{{ end }}
{{ if eq .part "btrfs" -}}
autopart --type=btrfs --noswap
{{- else if eq .part "plain" -}}
autopart --type=plain
{{- else -}}
autopart --type=plain
{{- end }}
#
# repositories
#
{{ if .url -}}
url --url="{{ .url }}"
{{- end }}
{{ if .repos -}}
# TODO: sort in a deterministic order
{{- range $name, $baseurl := .repos }}
repo --name="{{ $name }}" --baseurl="{{ $baseurl }}"
{{- end }}
{{- end }}
#
# packages
#
%packages
@core
@standard
@hardware-support
{{ if eq .flavour "Workstation" -}}
# Packages for Workstation:
-initial-setup
-initial-setup-gui
gnome-initial-setup
#anaconda-webui
@base-x
@fonts
@input-methods
@multimedia
@printing
-@guest-desktop-agents
#initial-setup-gui
glibc-all-langpacks
-@dial-up
-@input-methods
-@standard
# Install workstation-product-environment to resolve RhBug:1891500
@^workstation-product-environment
# Exclude unwanted packages from @anaconda-tools group
-gfs2-utils
-reiserfs-utils
{{- else if eq .flavour "Server" -}}
# Packages for Server:
fedora-release-server
# install the default groups for the server environment since installing the environment is not working
@server-product
@headless-management
@networkmanager-submodules
@container-management
@domain-client
@guest-agents
@server-hardware-support
-initial-setup-gui
-generic-release*
{{- end }}
# User packages:
{{ range $i, $x := .packages -}}
{{ $x }}
{{ end -}}
%end
#
# misc
#
bootloader --timeout=1
# make sure that initial-setup runs and lets us do all the configuration bits
firstboot --reconfig
#
# flavour
#
{{ if eq .flavour "Workstation" -}}
%post
# Explicitly set graphical.target as default as this is how initial-setup detects which version to run
systemctl set-default graphical.target
%end
{{ else if eq .flavour "Server" -}}
%post
# setup systemd to boot to the right runlevel
echo -n "Setting default runlevel to multiuser text mode"
systemctl set-default multi-user.target
echo .
%end
{{ end -}}
#
# post
#
%post
{{ range $i, $x := .post -}}
{{ $x }}
{{ end -}}
%end
#
# reboot after installation
#
reboot

View File

@@ -0,0 +1,23 @@
function load_video {
insmod efi_gop
insmod efi_uga
insmod video_bochs
insmod video_cirrus
insmod all_video
}
load_video
set gfxpayload=keep
insmod gzio
set default=0
set timeout=15
menuentry 'Install {{ .distro }} {{ .version }} {{ .arch }} ( kickstart )' --class fedora --class gnu-linux --class gnu --class os {
linuxefi /{{ .distro }}{{ .version }}-{{ .arch }}/vmlinuz ip=dhcp inst.repo={{ .inst_repo_base }}releases/{{ .version }}/{{ .flavour }}/{{ .arch }}/os/ inst.ks={{ .ks }}
initrdefi /{{ .distro }}{{ .version }}-{{ .arch }}/initrd.img
}
menuentry 'Install {{ .distro }} {{ .version }} {{ .arch }} ( manual )' --class fedora --class gnu-linux --class gnu --class os {
linuxefi /{{ .distro }}{{ .version }}-{{ .arch }}/vmlinuz ip=dhcp inst.repo={{ .inst_repo_base }}releases/{{ .version }}/{{ .flavour }}/{{ .arch }}/os/
initrdefi /{{ .distro }}{{ .version }}-{{ .arch }}/initrd.img
}

View File

@@ -0,0 +1,642 @@
# Mgmt
# Copyright (C) 2013-2024+ 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 "fmt"
import "golang/strings"
import "net"
import "os"
import "value"
import "world"
$http_suffix = "http/"
$tftp_suffix = "tftp/"
$uefi_suffix = "uefi/"
$kickstart_suffix = "kickstart/"
# The base class is the core provisioner which can also spawn child classes.
class base($config) {
#
# variables
#
$interface = $config->interface || "eth0" # XXX: what if no interface exists?
#$interface = _struct_lookup_optional($config, "interface", "eth0")
$http_port = $config->http_port || 4280 # using :4280 avoids needing root and isn't in /etc/services
$http_port_str = fmt.printf("%d", $http_port)
$network = $config->network || "192.168.42.0/24"
$router = $config->router || "192.168.42.1/24"
$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 == "") # panic if prefix is empty
panic(not strings.has_suffix($prefix, "/"))
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!
$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
#
# 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", "syslinux-nonlinux",]
$pkgs_uefi = ["shim-x64", "grub2-efi-x64",]
pkg $pkgs_bios {
state => "installed",
}
#pkg $pkgs_uefi {
# state => "installed",
#}
$pkgs_kickstart = ["fedora-kickstarts", "spin-kickstarts",]
pkg $pkgs_kickstart {
state => "installed",
}
#
# firewalld
#
if $firewalld {
firewalld "tftp" { # 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
#
dhcp:server ":67" {
interface => $interface, # required for now
leasetime => "60s",
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)
}
$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
}
print "ready" {
msg => "ready to provision!",
Depend => Tftp:Server[":69"],
Depend => 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
}
file $distroarch_uefi_prefix { # dir
state => $const.res.file.state.exists,
}
file $distroarch_http_prefix { # root http dir
state => $const.res.file.state.exists,
}
file $distroarch_release_http_prefix {
state => $const.res.file.state.exists,
}
file $distroarch_updates_http_prefix {
state => $const.res.file.state.exists,
}
#
# 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 = 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
cmd => "/usr/bin/dnf download ${pkgs_uefi_string} --assumeyes --disablerepo=* --repofrompath ${repoidname},${repo_url} --downloaddir=${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"
tftp:file "/uefi/shim.efi" { # needs leading slash
path => $uefi_shim, # TODO: add autoedges
Depend => Exec["uefi-extract-${uid}"],
}
#tftp:file "/uefi/grubx64.efi" { # not 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],
}
$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],
}
# this file resource serves the entire rsync directory over http
if $mirror == "" { # and $rsync != ""
http:file "/fedora/releases/${version}/Everything/${arch}/os/" {
path => $distroarch_release_http_prefix,
}
http:file "/fedora/updates/${version}/Everything/${arch}/" {
path => $distroarch_updates_http_prefix,
}
} else {
# same as the above http:file path would have been
http: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: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/
}
}
#
# 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 = $uidst->distro
$version = $uidst->version # not an int!
$arch = $uidst->arch
panic($distro == "")
panic($version == "")
panic($arch == "")
$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)
panic($ip == "")
#$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. Re-use based on $name hash.
$bios = $config->bios || false
$password = $config->password || "" # empty means disabled
panic(len($password) != 0 and len($password) != 106) # length of salted password
$part = $config->part || "" # partitioning scheme
$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!
# 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?
#$ret = world.getval($provision_key) # 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 $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"],
}
}
$tftp_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,
}
#
# 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}"
}
if $bios {
tftp:file "${bios_menu}" { # for bios
data => template(deploy.readfile("/files/bios-menu.tmpl"), $tftp_menu_template),
}
} else {
tftp:file "${uefi_menu}" { # for uefi
# XXX: linuxefi & initrdefi VS. kernel & append ?
data => template(deploy.readfile("/files/uefi-menu.tmpl"), $tftp_menu_template),
#Depend => Pkg[$pkgs_uefi],
#Depend => Exec["uefi-extract"],
}
}
$http_kickstart_template = struct{
comment => "hello!",
lang => [
"en_CA.UTF-8",
"fr_CA.UTF-8",
"en_US.UTF-8",
],
password => $password, # salted
bios => $bios,
part => $part,
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",
#},
packages => $packages,
post => [
"/usr/bin/wget --post-data 'done=true&password=sha1TODO' -O - 'http://${router_ip}:${http_port_str}/action/done/mac=${provision_key}'",
],
}
$kickstart_file = "${kickstart_http_prefix}${hkey}.ks"
file $kickstart_file {
state => $const.res.file.state.exists,
content => template(deploy.readfile("/files/kickstart.ks.tmpl"), $http_kickstart_template),
}
http:file "/fedora/kickstart/${hkey}.ks" { # usually $mac or `default`
#data => template(deploy.readfile("/files/kickstart.ks.tmpl"), $http_kickstart_template),
path => $kickstart_file,
Before => Print["ready"],
}
##$str_true = convert.format_bool(true)
##$str_false = convert.format_bool(false)
#http:flag "${name}" {
# key => "done",
# path => "/action/done/mac=${provision_key}",
# #mapped => {$str_true => $str_true, $str_false => $str_false,},
#}
#kv "${name}" {
# key => $provision_key,
#}
#value $provision_key {
# #any => true, # bool
#}
#Http:Flag["${name}"].value -> Kv["${name}"].value
#Http:Flag["${name}"].value -> Value[$provision_key].any
##$st_provisioned = value.get_bool($provision_key)
#$st_provisioned = value.get_str($provision_key)
#$provisioned = $st_provisioned->ready and $st_provisioned->value == "true" # export this value to parent scope
}

View File

@@ -0,0 +1 @@
#files: "files/" # these are some extra files we can use (is the default)

View File

@@ -0,0 +1,449 @@
// Mgmt
// Copyright (C) 2013-2024+ 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.
//go:build embedded_provisioner
package coreprovisioner
import (
"context"
"embed"
"fmt"
"log"
"net"
"os"
"os/user"
"strings"
"github.com/purpleidea/mgmt/cli"
"github.com/purpleidea/mgmt/entry"
"github.com/purpleidea/mgmt/lang/embedded"
"github.com/purpleidea/mgmt/lang/funcs/simple"
"github.com/purpleidea/mgmt/lang/types"
"github.com/purpleidea/mgmt/util"
"github.com/purpleidea/mgmt/util/errwrap"
"github.com/purpleidea/mgmt/util/password"
)
const (
// ModuleName is the prefix given to all the functions in this module.
ModuleName = "provisioner"
// Version is the version number of this module.
Version = "v0.0.1"
// Frontend is the name of the GAPI to run.
Frontend = "lang"
)
// NOTE: Grouped like shown is better, but you _can_ do it separately...
// Remember to add more patterns for nested child folders!
//
//go:embed metadata.yaml main.mcl files/*
var fs embed.FS // grouped is better
// NOTE: Separate as it's part of a different API and has a different function.
//
//go:embed top.mcl
var top []byte
// localArgs is our struct which is used to modify the CLI parser.
type localArgs struct {
// Interface is the local ethernet interface to provision from. It will
// be determined automatically if not specified.
Interface *string `arg:"--interface" help:"local ethernet interface to provision from" func:"cli_interface"` // eg: enp0s31f6 or eth0
// Network is the ip network with cidr that we want to use for the
// provisioner.
Network *string `arg:"--network" help:"network with cidr to use" func:"cli_network"` // eg: 192.168.42.0/24
// Router is the ip for this machine with included cidr. It must exist
// in the chosen network.
Router *string `arg:"--router" help:"router ip for this machine with cidr" func:"cli_router"` // eg: 192.168.42.1/24
// DNS are the list of upstream DNS servers to use during this process.
DNS []string `arg:"--dns" help:"upstream dns servers to use" func:"cli_dns"` // eg: ["8.8.8.8", "1.1.1.1"]
// Prefix is a directory to store some provisioner specific state such
// as cached distro packages. It can be safely deleted. If you don't
// specify this value, one will be chosen automatically.
Prefix *string `arg:"--prefix" help:"local XDG_CACHE_HOME path" func:"cli_prefix"` // eg: ~/.cache/mgmt/provisioner/
// Firewalld will automatically open the required ports for being a
// provisioner. By default this is enabled, but it can be disabled if
// you use a different firewall system.
Firewalld bool `arg:"--firewalld" default:"true" help:"should we open firewalld on our provisioner" func:"cli_firewalld"`
// repo
// Distro specifies the distribution to use. Currently only `fedora` is
// supported.
Distro string `arg:"--distro" default:"fedora" help:"distribution to use" func:"cli_distro"`
// Version is the distribution version. This is a string, not an int.
Version string `arg:"--version" help:"distribution version" func:"cli_version"` // eg: "38"
// Arch is the distro architecture to use. Only x86_64 and aarch64 are
// currently supported. Patches welcome.
Arch string `arg:"--arch" default:"x86_64" help:"architecture to use" func:"cli_arch"`
// Flavour describes a flavour of distribution to provision. The value
// and what it does is highly dependent on the distro you specified. The
// default is set automatically depending on your distro variable.
Flavour *string `arg:"--flavour" help:"flavour of distribution" func:"cli_flavour"` // eg: "Workstation" or "Server"
// Mirror is the mirror to provision from. Pick one that supports both
// rsync AND https if you want the most capable provisioner features. A
// list for fedora is at: https://admin.fedoraproject.org/mirrormanager/
// eg: https://mirror.csclub.uwaterloo.ca/fedora/ for example. This
// points to: https://download.fedoraproject.org/pub/fedora/linux/ by
// default if unspecified, because it will automatically translate to a
// local mirror near you.
// TODO: Do we need to do a special step of checking the signature of
// the initrd or vmlinuz or the install.img file we first load?
Mirror string `arg:"--mirror" help:"https mirror for proxy provisioning" func:"cli_mirror"`
// Rsync is the rsync to sync from. Pick one that supports both rsync
// AND https if you want the most capable provisioner features. A list
// for fedora is at: https://admin.fedoraproject.org/mirrormanager/ eg:
// rsync://mirror.csclub.uwaterloo.ca/fedora-enchilada/linux/releases/
// for examples. Be advised that this option will likely pull down over
// 100GiB per os/arch/version combination. Consider only using `mirror`.
Rsync string `arg:"--rsync" help:"rsync mirror for full synchronization" func:"cli_rsync"`
// host
// Mac is the mac address of the host that we'd like to provision. If
// you omit this, than we will attempt to provision any computer which
// asks.
Mac *net.HardwareAddr `arg:"--mac" help:"mac address to provision" func:"cli_mac"`
// IP is the address of the host to provision. It must include the /cidr
// and be contained in the above network that was specified.
IP *string `arg:"--ip" help:"ip address with cidr of the host to provision" func:"cli_ip"` // eg: "192.168.42.114/24"
// Bios should be set true if you want to provision legacy machines.
Bios bool `arg:"--bios" help:"should we use bios or uefi" func:"cli_bios"`
// Password is an `openssl passwd -6` salted password. If you don't
// specify this, you will be prompted to enter the actual unhashed
// password, and it will be salted and hashed for you.
Password *string `arg:"--password" help:"the 'openssl passwd -6' salted password" func:"-"` // skip auto func gen
// Part is the magic partitioning scheme to use. At the moment you can
// either specify `plain` or `btrfs`. The default empty string will
// use the `plain` scheme.
Part string `arg:"--part" help:"partitioning scheme, read manual for details" func:"cli_part"` // eg: empty string for plain
// Packages are a list of additional distro packages to install. It's up
// to the user to make sure they exist and don't conflict with each
// other or the base installation packages.
Packages []string `arg:"--packages" help:"list of additional distro packages to install (comma separated)" func:"cli_packages"`
}
// provisioner is our cli parser translator and general frontend object.
type provisioner struct {
init *entry.Init
// localArgs is a stored reference to the localArgs config struct that
// is used in the API of the command line parsing library. After it
// adds our flags and executes it, the resultant parsed values will be
// made available here where we've stored a copy.
localArgs *localArgs
// salted password
password string
}
// Init implements the Initable interface which lets us collect some data and
// handles from our caller.
func (obj *provisioner) Init(init *entry.Init) error {
obj.init = init // store some data/handles including logf
return nil
}
// Customize implements the Customizable interface which lets us manipulate the
// CLI.
func (obj *provisioner) Customize(a interface{}) (*cli.RunArgs, error) {
//if obj.init.Debug {
// obj.init.Logf("got: %T: %+v\n", a, a) // parent Args
//}
ctx := context.TODO()
runArgs, ok := a.(*cli.RunArgs)
if !ok {
// programming error?
return nil, fmt.Errorf("received invalid struct of type: %T", a)
}
libConfig := runArgs.Config
//var name string
var args interface{}
if cmd := runArgs.RunLang; cmd != nil {
//name = cliUtil.LookupSubcommand(obj, cmd) // "lang" // reflect.Value.Interface: cannot return value obtained from unexported field or method
args = cmd
}
//if name == "" {
// return nil, fmt.Errorf("no frontend activated")
//}
if args == nil {
return nil, fmt.Errorf("no frontend activated")
}
//if obj.init.Debug {
// obj.init.Logf("got: %T: %+v\n", args, args) // parent Args
//}
if obj.localArgs == nil {
// programming error
return nil, fmt.Errorf("could not convert/access our struct")
}
//localArgs := *obj.localArgs // optional
// Add custom defaults, and improve some as well.
if s := obj.localArgs.Interface; s == nil {
devices, err := util.GetPhysicalEthernetDevices()
if err != nil {
return nil, err
}
if i := len(devices); i == 0 || i > 1 {
return nil, fmt.Errorf("couldn't guess ethernet device, got %d", i)
}
dev := devices[0]
obj.localArgs.Interface = &dev
}
obj.init.Logf("interface: %+v", *obj.localArgs.Interface)
if s := obj.localArgs.Network; s == nil {
x := "192.168.42.0/24"
obj.localArgs.Network = &x
}
_, netIPnet, err := net.ParseCIDR(*obj.localArgs.Network)
if err != nil {
return nil, err
}
if s := obj.localArgs.Router; s == nil {
x := "192.168.42.1/24"
obj.localArgs.Router = &x
}
routerIP, _, err := net.ParseCIDR(*obj.localArgs.Router)
if err != nil {
return nil, err
}
if !netIPnet.Contains(routerIP) {
return nil, fmt.Errorf("network %s does not contain %s", *obj.localArgs.Network, *obj.localArgs.Router)
}
if s := obj.localArgs.IP; s == nil {
x := "192.168.42.13/24"
obj.localArgs.IP = &x
}
hostIP, _, err := net.ParseCIDR(*obj.localArgs.Router)
if err != nil {
return nil, err
}
if !netIPnet.Contains(hostIP) {
return nil, fmt.Errorf("network %s does not contain %s", *obj.localArgs.Network, *obj.localArgs.IP)
}
// TODO: add more validation
if p := obj.localArgs.Prefix; p != nil {
if strings.HasPrefix(*p, "~") {
expanded, err := util.ExpandHome(*p)
if err != nil {
return nil, err
}
obj.localArgs.Prefix = &expanded
}
}
if obj.localArgs.Prefix == nil { // pick a default
user, err := user.Current()
if err != nil {
return nil, errwrap.Wrapf(err, "can't get current user")
}
xdg := os.Getenv("XDG_CACHE_HOME")
// Ensure there is a / at the end of the directory path.
if xdg != "" && !strings.HasSuffix(xdg, "/") {
xdg = xdg + "/"
}
if xdg == "" && user.HomeDir != "" {
xdg = fmt.Sprintf("%s/.cache/%s/", user.HomeDir, obj.init.Data.Program)
}
xdg += fmt.Sprintf("%s/", ModuleName) // pick a dir for this tool
obj.localArgs.Prefix = &xdg
}
obj.init.Logf("cache prefix: %+v", *obj.localArgs.Prefix)
if obj.localArgs.Mac == nil {
mac := net.HardwareAddr([]byte{}) // will print empty string
obj.localArgs.Mac = &mac
}
if obj.localArgs.Distro == "" {
return nil, fmt.Errorf("distro was not specified")
}
if obj.localArgs.Distro != "fedora" { // TODO: add other distros!
return nil, fmt.Errorf("only fedora is currently supported")
}
if obj.localArgs.Distro == "fedora" && obj.localArgs.Version == "" {
version, err := util.LatestFedoraVersion(ctx, obj.localArgs.Arch) // get a default for fedora
if err != nil {
return nil, err
}
obj.localArgs.Version = version
}
if obj.localArgs.Version == "" {
return nil, fmt.Errorf("distro version was not specified")
}
if obj.localArgs.Arch == "" {
obj.localArgs.Arch = "x86_64"
}
if obj.localArgs.Distro == "fedora" && obj.localArgs.Flavour == nil {
flavour := "Workstation" // set a default for fedora
obj.localArgs.Flavour = &flavour
}
flavour := *obj.localArgs.Flavour
if obj.localArgs.Distro == "fedora" && flavour != strings.Title(flavour) {
return nil, fmt.Errorf("distro flavour should be in Title case")
}
if obj.localArgs.Distro == "fedora" && obj.localArgs.Mirror == "" {
obj.localArgs.Mirror = "https://download.fedoraproject.org/pub/fedora/linux/" // default
// This will auto-resolve once we get going.
m, err := util.GetFedoraDownloadURL(ctx)
if err == nil {
obj.localArgs.Mirror = m
}
}
obj.init.Logf("distro uid: %s%s-%s", obj.localArgs.Distro, obj.localArgs.Version, obj.localArgs.Arch)
obj.init.Logf("flavour: %+v", flavour)
obj.init.Logf("mirror: %+v", obj.localArgs.Mirror)
if len(obj.localArgs.Packages) > 0 {
obj.init.Logf("packages: %+v", strings.Join(obj.localArgs.Packages, ","))
}
// Do this last to let others fail early b/c this has user interaction.
if obj.localArgs.Password == nil {
b, err := password.ReadPasswordCtxPrompt(ctx, "["+ModuleName+"] password: ")
if err != nil {
return nil, err
}
fmt.Printf("\n") // leave space after the prompt
// XXX: I have no idea if I am doing this correctly, and I have
// no idea if the library is doing this correctly. Please check!
// XXX: erase values: https://github.com/golang/go/issues/21865
hash, err := password.SaltedSHA512Password(b) // internally salted
if err != nil {
return nil, err
}
obj.password = hash // store
} else if p := *obj.localArgs.Password; p == "-" {
// XXX: pull from a file or something else if we choose this
return nil, fmt.Errorf("not implemented")
} else if len(p) != 106 { // salted length should be 106 chars AIUI
return nil, fmt.Errorf("password must be salted with openssl passwd -6")
} else {
obj.password = p // salted
}
// Make any changes here that we want to...
runArgs.RunLang.SkipUnify = true // speed things up for known good code
libConfig.TmpPrefix = true
libConfig.NoPgp = true
runArgs.Config = libConfig // store any changes we made
return runArgs, nil
}
// Register generates some functions that expose the output of our local CLI.
func (obj *provisioner) Register(moduleName string) error {
// Build all the functions...
if err := simple.StructRegister(moduleName, obj.localArgs); err != nil {
return err
}
// Build a few separately...
simple.ModuleRegister(moduleName, "cli_password", &types.FuncValue{
T: types.NewType("func() str"),
V: func(input []types.Value) (types.Value, error) {
if obj.localArgs == nil {
// programming error
return nil, fmt.Errorf("could not convert/access our struct")
}
//localArgs := *obj.localArgs // optional
return &types.StrValue{
V: obj.password,
}, nil
},
})
return nil
}
func init() {
fullModuleName := embedded.FullModuleName(ModuleName)
//fs := embedded.MergeFS(metadata, main, files) // To merge filesystems!
embedded.ModuleRegister(fullModuleName, fs)
var a interface{} = &localArgs{} // must use the pointer here
custom := &provisioner{
localArgs: a.(*localArgs), // force the correct type
}
entry.Register(&entry.Data{
Program: ModuleName,
Version: Version, // TODO: get from git?
Debug: false,
Logf: func(format string, v ...interface{}) {
log.Printf(format, v...)
},
Args: a,
Custom: custom,
Frontend: Frontend,
Top: top,
})
if err := custom.Register(fullModuleName); err != nil { // functions from cli
panic(err)
}
}

View File

@@ -0,0 +1,80 @@
# Mgmt
# Copyright (C) 2013-2024+ 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.
# sudo setcap CAP_NET_BIND_SERVICE=+eip mgmt
# ./mgmt provisioner --mac 01:23:45:67:89:ab
import "fmt"
import "os"
import "golang/strings"
import "embedded/provisioner" # embedded import
# TODO: get all of the values first from the cli config file, and then a webui
include provisioner.base(struct{
interface => provisioner.cli_interface(),
network => provisioner.cli_network(),
router => provisioner.cli_router(),
dns => provisioner.cli_dns(),
prefix => provisioner.cli_prefix(),
firewalld => provisioner.cli_firewalld(),
}) as base
include base.repo(struct{
distro => provisioner.cli_distro(),
version => provisioner.cli_version(), # not an int!
arch => provisioner.cli_arch(),
flavour => provisioner.cli_flavour(),
# pick one from: https://admin.fedoraproject.org/mirrormanager/
mirror => provisioner.cli_mirror(), # eg: https://mirror.csclub.uwaterloo.ca/fedora/linux/
rsync => provisioner.cli_rsync(), # eg: rsync://mirror.csclub.uwaterloo.ca/fedora-enchilada/linux/
}) #as repo
$distro = provisioner.cli_distro()
$version = provisioner.cli_version()
$arch = provisioner.cli_arch()
$uid = "${distro}${version}-${arch}" # eg: fedora39-x86_64
include base.host("host0", struct{ # TODO: do we need a usable name anywhere?
#repo => $repo.uid, # type unification performance is very slow here
repo => $uid,
flavour => provisioner.cli_flavour(),
mac => provisioner.cli_mac(),
ip => provisioner.cli_ip(),
bios => provisioner.cli_bios(), # false or absent means use uefi
password => provisioner.cli_password(), # openssl passwd -6
part => provisioner.cli_part(),
packages => provisioner.cli_packages(),
#provision => true, # default if unspecified
}) as host0
#if $host0.provisioned {
# print "provisioned" {
# msg => fmt.printf("%s is provisioned!", $host0.name),
# }
#}