2026-03-31 14:52:50 -03:00
package plan
import (
"bytes"
"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
}
}
2026-04-24 16:57:34 -03:00
applyNodeMetadataEnv ( cfg )
2026-03-31 14:52:50 -03:00
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 {
2026-10-04 00:27:55 -05:00
{ Path : "etc/metis/node-identity.pending" , Content : [ ] byte ( "Apply the injected node identity before accepting recovery as complete.\n" ) , Mode : 0 o600 , RootFS : true } ,
{ Path : "etc/systemd/system/metis-node-identity.service" , Content : [ ] byte ( nodeIdentityUnitContent ( ) ) , Mode : 0 o644 , RootFS : true } ,
{ Path : "etc/systemd/system/multi-user.target.wants/metis-node-identity.service" , Symlink : "../metis-node-identity.service" , RootFS : true } ,
2026-03-31 14:52:50 -03:00
{ Path : "etc/hostname" , Content : [ ] byte ( cfg . Hostname + "\n" ) , Mode : 0 o644 , RootFS : true } ,
{ Path : "etc/hosts" , Content : [ ] byte ( hostsContent ( cfg . Hostname ) ) , Mode : 0 o644 , RootFS : true } ,
{ Path : "etc/rancher/k3s/config.yaml" , Content : [ ] byte ( k3sConfigContent ( cfg ) ) , Mode : 0 o644 , RootFS : true } ,
2026-04-24 16:57:34 -03:00
{ Path : "etc/metis/firstboot.env" , Content : [ ] byte ( firstbootEnvContent ( cfg , sec ) ) , Mode : 0 o600 , RootFS : true } ,
{ Path : "usr/local/sbin/metis-apply-node-identity.sh" , Content : [ ] byte ( nodeIdentityScriptContent ( ) ) , Mode : 0 o755 , RootFS : true } ,
{ Path : "etc/cloud/cloud.cfg.d/90-metis-recovery.cfg" , Content : [ ] byte ( cloudInitRootFSContent ( sec ) ) , Mode : 0 o644 , RootFS : true } ,
2026-03-31 14:52:50 -03:00
}
if cfg . IP != "" {
files = append ( files , inject . FileSpec {
Path : "etc/NetworkManager/system-connections/end0-static.nmconnection" ,
2026-04-05 02:42:04 -03:00
Content : [ ] byte ( networkManagerConnectionContent ( "end0-static" , "end0" , cfg . IP ) ) ,
Mode : 0 o600 ,
RootFS : true ,
} )
files = append ( files , inject . FileSpec {
Path : "etc/NetworkManager/system-connections/eth0-static.nmconnection" ,
Content : [ ] byte ( networkManagerConnectionContent ( "eth0-static" , "eth0" , cfg . IP ) ) ,
2026-03-31 14:52:50 -03:00
Mode : 0 o600 ,
RootFS : true ,
} )
files = append ( files , inject . FileSpec {
Path : "etc/systemd/network/10-end0-static.network" ,
Content : [ ] byte ( systemdNetworkContent ( cfg . IP ) ) ,
Mode : 0 o644 ,
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 : 0 o600 ,
RootFS : true ,
} )
files = append ( files , inject . FileSpec {
Path : "etc/metis/authorized_keys" ,
Content : [ ] byte ( auth ) ,
Mode : 0 o600 ,
RootFS : true ,
} )
}
2026-04-24 16:57:34 -03:00
if passwordAuth := sshPasswordConfigContent ( sec ) ; passwordAuth != "" {
files = append ( files , inject . FileSpec {
Path : "etc/ssh/sshd_config.d/90-metis-password-auth.conf" ,
Content : [ ] byte ( passwordAuth ) ,
Mode : 0 o644 ,
RootFS : true ,
} )
}
2026-03-31 14:52:50 -03:00
if len ( cfg . Fstab ) > 0 {
files = append ( files , inject . FileSpec {
Path : "etc/metis/fstab.append" ,
Content : [ ] byte ( fstabAppendContent ( cfg ) ) ,
Mode : 0 o644 ,
RootFS : true ,
} )
}
2026-04-24 16:57:34 -03:00
raw , err := jsonMarshalIndent ( cfg )
2026-03-31 14:52:50 -03:00
if err != nil {
return nil , err
}
files = append ( files , inject . FileSpec {
Path : "etc/metis/node.json" ,
Content : raw ,
Mode : 0 o644 ,
RootFS : true ,
} )
if sec != nil {
2026-04-24 16:57:34 -03:00
secRaw , err := jsonMarshalIndent ( redactedSecretsForImage ( sec ) )
2026-03-31 14:52:50 -03:00
if err != nil {
return nil , err
}
files = append ( files , inject . FileSpec {
Path : "etc/metis/secrets.json" ,
Content : secRaw ,
Mode : 0 o600 ,
RootFS : true ,
} )
}
userData := cloudInitUserData ( cfg , sec )
if userData != "" {
files = append ( files , inject . FileSpec {
Path : "user-data" ,
Content : [ ] byte ( userData ) ,
Mode : 0 o644 ,
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 : 0 o644 ,
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 {
2026-04-05 02:42:04 -03:00
if ! allowK3sNodeLabel ( cfg . K3s . Role , k ) {
continue
}
2026-03-31 14:52:50 -03:00
labelList = append ( labelList , fmt . Sprintf ( "%s=%s" , k , v ) )
}
sort . Strings ( labelList )
taints := append ( [ ] string { } , cfg . Taints ... )
sort . Strings ( taints )
var b bytes . Buffer
2026-04-05 02:42:04 -03:00
if cfg . K3s . Role != "agent" {
b . WriteString ( "write-kubeconfig-mode: \"0644\"\n" )
}
2026-03-31 14:52:50 -03:00
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 ( )
}
2026-04-05 02:42:04 -03:00
func allowK3sNodeLabel ( role , key string ) bool {
if role != "agent" {
return true
}
return ! strings . HasPrefix ( key , "node-role.kubernetes.io/" )
}
func networkManagerConnectionContent ( id , iface , ip string ) string {
2026-03-31 14:52:50 -03:00
gateway := ip
if lastDot := strings . LastIndex ( gateway , "." ) ; lastDot >= 0 {
gateway = gateway [ : lastDot + 1 ] + "1"
}
return fmt . Sprintf ( ` [ connection ]
2026-04-05 02:42:04 -03:00
id = % s
2026-03-31 14:52:50 -03:00
type = ethernet
2026-04-05 02:42:04 -03:00
interface - name = % s
2026-03-31 14:52:50 -03:00
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 ]
2026-04-05 02:42:04 -03:00
` , id , iface , ip , gateway , gateway )
2026-03-31 14:52:50 -03:00
}
func systemdNetworkContent ( ip string ) string {
gateway := ip
if lastDot := strings . LastIndex ( gateway , "." ) ; lastDot >= 0 {
gateway = gateway [ : lastDot + 1 ] + "1"
}
return fmt . Sprintf ( ` [ Match ]
2026-04-05 02:42:04 -03:00
Name = end0 eth0
2026-03-31 14:52:50 -03:00
[ 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 {
2026-04-11 01:08:08 -03:00
source := entry . Source
switch {
case source != "" :
case entry . UUID != "" :
source = "UUID=" + entry . UUID
case entry . Label != "" :
source = "LABEL=" + entry . Label
default :
source = "none"
}
2026-03-31 14:52:50 -03:00
lines = append ( lines , fmt . Sprintf (
2026-04-11 01:08:08 -03:00
"%s %s %s %s 0 0" ,
source ,
2026-03-31 14:52:50 -03:00
entry . Mountpoint ,
entry . FS ,
entry . Options ,
) )
}
sort . Strings ( lines )
return strings . Join ( lines , "\n" ) + "\n"
}
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
}