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, /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 }