metis/pkg/plan/inject.go

431 lines
10 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 {
lines = append(lines, fmt.Sprintf(
"UUID=%s %s %s %s 0 0",
entry.UUID,
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\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
}