445 lines
11 KiB
Go
445 lines
11 KiB
Go
package plan
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
|
|
"metis/pkg/config"
|
|
"metis/pkg/inject"
|
|
"metis/pkg/inventory"
|
|
"metis/pkg/secrets"
|
|
)
|
|
|
|
// maybeInject writes node-specific config into mounted boot/root paths if the env
|
|
// vars METIS_BOOT_PATH or METIS_ROOT_PATH are set. When unset, injection is skipped.
|
|
func maybeInject(inv *inventory.Inventory, nodeName string) error {
|
|
boot := os.Getenv("METIS_BOOT_PATH")
|
|
root := os.Getenv("METIS_ROOT_PATH")
|
|
if boot == "" && root == "" {
|
|
return nil
|
|
}
|
|
files, err := Files(inv, nodeName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
filtered := make([]inject.FileSpec, 0, len(files))
|
|
for _, f := range files {
|
|
if f.RootFS && root == "" {
|
|
continue
|
|
}
|
|
if !f.RootFS && boot == "" {
|
|
continue
|
|
}
|
|
filtered = append(filtered, f)
|
|
}
|
|
if len(filtered) == 0 {
|
|
return nil
|
|
}
|
|
inj := inject.Injector{BootPath: boot, RootPath: root}
|
|
return inj.Write(filtered)
|
|
}
|
|
|
|
// Files resolves the full set of node-specific files, including overlays.
|
|
func Files(inv *inventory.Inventory, nodeName string) ([]inject.FileSpec, error) {
|
|
node, class, err := inv.FindNode(nodeName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
cfg, err := config.Build(inv, nodeName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sec := fetchSecrets(node.Hostname)
|
|
if sec != nil {
|
|
if sec.K3sToken != "" {
|
|
cfg.K3s.Token = sec.K3sToken
|
|
}
|
|
if len(sec.Extra) > 0 {
|
|
cfg.Secrets = sec.Extra
|
|
}
|
|
}
|
|
files, err := buildFiles(cfg, sec)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
overlayFiles, err := collectOverlays(class)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
files = append(files, overlayFiles...)
|
|
_ = node // reserved for future per-node overlays
|
|
return files, nil
|
|
}
|
|
|
|
// Inject writes node-specific config into caller-supplied boot/root mountpoints.
|
|
func Inject(inv *inventory.Inventory, nodeName, boot, root string) error {
|
|
oldBoot := os.Getenv("METIS_BOOT_PATH")
|
|
oldRoot := os.Getenv("METIS_ROOT_PATH")
|
|
defer func() {
|
|
if oldBoot == "" {
|
|
_ = os.Unsetenv("METIS_BOOT_PATH")
|
|
} else {
|
|
_ = os.Setenv("METIS_BOOT_PATH", oldBoot)
|
|
}
|
|
if oldRoot == "" {
|
|
_ = os.Unsetenv("METIS_ROOT_PATH")
|
|
} else {
|
|
_ = os.Setenv("METIS_ROOT_PATH", oldRoot)
|
|
}
|
|
}()
|
|
if boot == "" {
|
|
_ = os.Unsetenv("METIS_BOOT_PATH")
|
|
} else {
|
|
_ = os.Setenv("METIS_BOOT_PATH", boot)
|
|
}
|
|
if root == "" {
|
|
_ = os.Unsetenv("METIS_ROOT_PATH")
|
|
} else {
|
|
_ = os.Setenv("METIS_ROOT_PATH", root)
|
|
}
|
|
return maybeInject(inv, nodeName)
|
|
}
|
|
|
|
func buildFiles(cfg *config.NodeConfig, sec *secrets.NodeSecrets) ([]inject.FileSpec, error) {
|
|
files := []inject.FileSpec{
|
|
{Path: "etc/hostname", Content: []byte(cfg.Hostname + "\n"), Mode: 0o644, RootFS: true},
|
|
{Path: "etc/hosts", Content: []byte(hostsContent(cfg.Hostname)), Mode: 0o644, RootFS: true},
|
|
{Path: "etc/rancher/k3s/config.yaml", Content: []byte(k3sConfigContent(cfg)), Mode: 0o644, RootFS: true},
|
|
{Path: "etc/metis/firstboot.env", Content: []byte(firstbootEnvContent(cfg)), Mode: 0o600, RootFS: true},
|
|
}
|
|
if cfg.IP != "" {
|
|
files = append(files, inject.FileSpec{
|
|
Path: "etc/NetworkManager/system-connections/end0-static.nmconnection",
|
|
Content: []byte(networkManagerConnectionContent("end0-static", "end0", cfg.IP)),
|
|
Mode: 0o600,
|
|
RootFS: true,
|
|
})
|
|
files = append(files, inject.FileSpec{
|
|
Path: "etc/NetworkManager/system-connections/eth0-static.nmconnection",
|
|
Content: []byte(networkManagerConnectionContent("eth0-static", "eth0", cfg.IP)),
|
|
Mode: 0o600,
|
|
RootFS: true,
|
|
})
|
|
files = append(files, inject.FileSpec{
|
|
Path: "etc/systemd/network/10-end0-static.network",
|
|
Content: []byte(systemdNetworkContent(cfg.IP)),
|
|
Mode: 0o644,
|
|
RootFS: true,
|
|
})
|
|
}
|
|
if len(cfg.SSHKeys) > 0 && cfg.SSHUser != "" {
|
|
auth := strings.Join(cfg.SSHKeys, "\n") + "\n"
|
|
files = append(files, inject.FileSpec{
|
|
Path: fmt.Sprintf("home/%s/.ssh/authorized_keys", cfg.SSHUser),
|
|
Content: []byte(auth),
|
|
Mode: 0o600,
|
|
RootFS: true,
|
|
})
|
|
files = append(files, inject.FileSpec{
|
|
Path: "etc/metis/authorized_keys",
|
|
Content: []byte(auth),
|
|
Mode: 0o600,
|
|
RootFS: true,
|
|
})
|
|
}
|
|
if cfg.SSHUser == "atlas" {
|
|
sudoers := hecateSudoersContent(cfg.SSHUser)
|
|
files = append(files, inject.FileSpec{
|
|
Path: "etc/sudoers.d/90-hecate-atlas",
|
|
Content: []byte(sudoers),
|
|
Mode: 0o440,
|
|
RootFS: true,
|
|
})
|
|
files = append(files, inject.FileSpec{
|
|
Path: "etc/metis/sudoers-hecate",
|
|
Content: []byte(sudoers),
|
|
Mode: 0o440,
|
|
RootFS: true,
|
|
})
|
|
}
|
|
if len(cfg.Fstab) > 0 {
|
|
files = append(files, inject.FileSpec{
|
|
Path: "etc/metis/fstab.append",
|
|
Content: []byte(fstabAppendContent(cfg)),
|
|
Mode: 0o644,
|
|
RootFS: true,
|
|
})
|
|
}
|
|
|
|
// Store the raw config for debugging/ops.
|
|
raw, err := json.MarshalIndent(cfg, "", " ")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
files = append(files, inject.FileSpec{
|
|
Path: "etc/metis/node.json",
|
|
Content: raw,
|
|
Mode: 0o644,
|
|
RootFS: true,
|
|
})
|
|
if sec != nil {
|
|
secRaw, err := json.MarshalIndent(sec, "", " ")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
files = append(files, inject.FileSpec{
|
|
Path: "etc/metis/secrets.json",
|
|
Content: secRaw,
|
|
Mode: 0o600,
|
|
RootFS: true,
|
|
})
|
|
}
|
|
|
|
// Optional cloud-init for images that honor NoCloud.
|
|
userData := cloudInitUserData(cfg, sec)
|
|
if userData != "" {
|
|
files = append(files, inject.FileSpec{
|
|
Path: "user-data",
|
|
Content: []byte(userData),
|
|
Mode: 0o644,
|
|
RootFS: false,
|
|
})
|
|
files = append(files, inject.FileSpec{
|
|
Path: "meta-data",
|
|
Content: []byte(fmt.Sprintf("instance-id: %s\nlocal-hostname: %s\n", cfg.Hostname, cfg.Hostname)),
|
|
Mode: 0o644,
|
|
RootFS: false,
|
|
})
|
|
}
|
|
return files, nil
|
|
}
|
|
|
|
func hostsContent(hostname string) string {
|
|
return fmt.Sprintf("127.0.0.1\tlocalhost\n127.0.1.1\t%s\n\n# Injected by metis\n", hostname)
|
|
}
|
|
|
|
func k3sConfigContent(cfg *config.NodeConfig) string {
|
|
var labelList []string
|
|
for k, v := range cfg.Labels {
|
|
if !allowK3sNodeLabel(cfg.K3s.Role, k) {
|
|
continue
|
|
}
|
|
labelList = append(labelList, fmt.Sprintf("%s=%s", k, v))
|
|
}
|
|
sort.Strings(labelList)
|
|
taints := append([]string{}, cfg.Taints...)
|
|
sort.Strings(taints)
|
|
|
|
var b bytes.Buffer
|
|
if cfg.K3s.Role != "agent" {
|
|
b.WriteString("write-kubeconfig-mode: \"0644\"\n")
|
|
}
|
|
if cfg.K3s.URL != "" {
|
|
b.WriteString(fmt.Sprintf("server: %s\n", cfg.K3s.URL))
|
|
}
|
|
if cfg.K3s.Token != "" {
|
|
b.WriteString(fmt.Sprintf("token: %s\n", cfg.K3s.Token))
|
|
}
|
|
b.WriteString(fmt.Sprintf("node-name: %s\n", cfg.Hostname))
|
|
if cfg.IP != "" {
|
|
b.WriteString(fmt.Sprintf("node-ip: %s\n", cfg.IP))
|
|
}
|
|
if len(labelList) > 0 {
|
|
b.WriteString("node-label:\n")
|
|
for _, l := range labelList {
|
|
b.WriteString(fmt.Sprintf(" - %s\n", l))
|
|
}
|
|
}
|
|
if len(taints) > 0 {
|
|
b.WriteString("node-taint:\n")
|
|
for _, t := range taints {
|
|
b.WriteString(fmt.Sprintf(" - %s\n", t))
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func allowK3sNodeLabel(role, key string) bool {
|
|
if role != "agent" {
|
|
return true
|
|
}
|
|
return !strings.HasPrefix(key, "node-role.kubernetes.io/")
|
|
}
|
|
|
|
func cloudInitUserData(cfg *config.NodeConfig, sec *secrets.NodeSecrets) string {
|
|
if cfg == nil {
|
|
return ""
|
|
}
|
|
if sec != nil && sec.CloudInit != "" {
|
|
return sec.CloudInit
|
|
}
|
|
var b bytes.Buffer
|
|
b.WriteString("#cloud-config\n")
|
|
b.WriteString(fmt.Sprintf("hostname: %s\n", cfg.Hostname))
|
|
if len(cfg.SSHKeys) > 0 {
|
|
b.WriteString("ssh_authorized_keys:\n")
|
|
for _, k := range cfg.SSHKeys {
|
|
b.WriteString(fmt.Sprintf(" - %s\n", k))
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func firstbootEnvContent(cfg *config.NodeConfig) string {
|
|
var b bytes.Buffer
|
|
b.WriteString(fmt.Sprintf("METIS_HOSTNAME=%s\n", shellQuote(cfg.Hostname)))
|
|
b.WriteString(fmt.Sprintf("METIS_SSH_USER=%s\n", shellQuote(cfg.SSHUser)))
|
|
b.WriteString(fmt.Sprintf("METIS_K3S_VERSION=%s\n", shellQuote(cfg.K3s.Version)))
|
|
return b.String()
|
|
}
|
|
|
|
func networkManagerConnectionContent(id, iface, ip string) string {
|
|
gateway := ip
|
|
if lastDot := strings.LastIndex(gateway, "."); lastDot >= 0 {
|
|
gateway = gateway[:lastDot+1] + "1"
|
|
}
|
|
return fmt.Sprintf(`[connection]
|
|
id=%s
|
|
type=ethernet
|
|
interface-name=%s
|
|
autoconnect=true
|
|
autoconnect-priority=100
|
|
|
|
[ethernet]
|
|
|
|
[ipv4]
|
|
method=manual
|
|
address1=%s/24,%s
|
|
dns=%s;
|
|
dns-search=titan;
|
|
may-fail=false
|
|
|
|
[ipv6]
|
|
method=ignore
|
|
|
|
[proxy]
|
|
`, id, iface, ip, gateway, gateway)
|
|
}
|
|
|
|
func systemdNetworkContent(ip string) string {
|
|
gateway := ip
|
|
if lastDot := strings.LastIndex(gateway, "."); lastDot >= 0 {
|
|
gateway = gateway[:lastDot+1] + "1"
|
|
}
|
|
return fmt.Sprintf(`[Match]
|
|
Name=end0 eth0
|
|
|
|
[Network]
|
|
Address=%s/24
|
|
Gateway=%s
|
|
DNS=%s
|
|
Domains=titan
|
|
DHCP=no
|
|
IPv6AcceptRA=no
|
|
LinkLocalAddressing=no
|
|
`, ip, gateway, gateway)
|
|
}
|
|
|
|
func fstabAppendContent(cfg *config.NodeConfig) string {
|
|
var lines []string
|
|
for _, entry := range cfg.Fstab {
|
|
source := entry.Source
|
|
switch {
|
|
case source != "":
|
|
// Use the explicit source path for bind mounts.
|
|
case entry.UUID != "":
|
|
source = "UUID=" + entry.UUID
|
|
case entry.Label != "":
|
|
source = "LABEL=" + entry.Label
|
|
default:
|
|
source = "none"
|
|
}
|
|
lines = append(lines, fmt.Sprintf(
|
|
"%s %s %s %s 0 0",
|
|
source,
|
|
entry.Mountpoint,
|
|
entry.FS,
|
|
entry.Options,
|
|
))
|
|
}
|
|
sort.Strings(lines)
|
|
return strings.Join(lines, "\n") + "\n"
|
|
}
|
|
|
|
func hecateSudoersContent(user string) string {
|
|
return fmt.Sprintf(
|
|
"%s ALL=(ALL) NOPASSWD: /usr/bin/systemctl, /usr/sbin/poweroff, /sbin/poweroff, /usr/local/bin/hecate, /usr/local/bin/k3s, /usr/bin/k3s\n",
|
|
user,
|
|
)
|
|
}
|
|
|
|
func shellQuote(value string) string {
|
|
if value == "" {
|
|
return "''"
|
|
}
|
|
return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'"
|
|
}
|
|
|
|
func fetchSecrets(hostname string) *secrets.NodeSecrets {
|
|
if os.Getenv("VAULT_ADDR") == "" {
|
|
return nil
|
|
}
|
|
cli := secrets.NewFromEnv()
|
|
sec, err := cli.FetchNode(context.Background(), hostname)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return sec
|
|
}
|
|
|
|
func collectOverlays(class *inventory.NodeClass) ([]inject.FileSpec, error) {
|
|
var files []inject.FileSpec
|
|
if class == nil {
|
|
return files, nil
|
|
}
|
|
if class.BootOverlay != "" {
|
|
more, err := overlayFiles(class.BootOverlay, false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
files = append(files, more...)
|
|
}
|
|
if class.RootOverlay != "" {
|
|
more, err := overlayFiles(class.RootOverlay, true)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
files = append(files, more...)
|
|
}
|
|
return files, nil
|
|
}
|
|
|
|
func overlayFiles(dir string, rootfs bool) ([]inject.FileSpec, error) {
|
|
var specs []inject.FileSpec
|
|
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if info.IsDir() {
|
|
return nil
|
|
}
|
|
rel, err := filepath.Rel(dir, path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
content, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
specs = append(specs, inject.FileSpec{
|
|
Path: rel,
|
|
Content: content,
|
|
Mode: info.Mode(),
|
|
RootFS: rootfs,
|
|
})
|
|
return nil
|
|
})
|
|
return specs, err
|
|
}
|