diff --git a/cmd/control/main.go b/cmd/control/main.go index a79e6d0..7b57998 100644 --- a/cmd/control/main.go +++ b/cmd/control/main.go @@ -4,17 +4,17 @@ import ( "fmt" "os" - "gitea.sunhpc.com/kelvin/sunhpc" + "gitea.sunhpc.com/kelvin/sunhpc/internal/app/control" ) func main() { - root := sunhpc.GetRootCommand() + root := control.GetRootCommand() err := root.Execute() if err != nil { fmt.Fprintf(os.Stderr, "ERROR: %s\n", err) - if sunhpc.DebugFlag { + if control.DebugFlag { fmt.Printf("\nSTACK TRACE: %+v\n", err) } os.Exit(255) diff --git a/go.mod b/go.mod index 7dcc783..b8a0008 100644 --- a/go.mod +++ b/go.mod @@ -1,5 +1,12 @@ -module gitea.sunhpc.com/kelvin/sunhpc.git +module gitea.sunhpc.com/kelvin/sunhpc go 1.25.8 -require gitea.sunhpc.com/kelvin/sunhpc v0.0.0-20260428114043-53d56ec6e2ba // indirect +require github.com/spf13/cobra v1.10.2 + +require ( + github.com/creasty/defaults v1.8.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum index 1afe100..158f419 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,14 @@ -gitea.sunhpc.com/kelvin/sunhpc v0.0.0-20260428114043-53d56ec6e2ba h1:NKGusycgJgYD/ljVamRqkLm8+jqT2h09xVjZf7NBAqc= -gitea.sunhpc.com/kelvin/sunhpc v0.0.0-20260428114043-53d56ec6e2ba/go.mod h1:qQ5hwUVNDDEjeZRE35FzMpsobqly7h0tRcBGCcvzgrc= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creasty/defaults v1.8.0 h1:z27FJxCAa0JKt3utc0sCImAEb+spPucmKoOdLHvHYKk= +github.com/creasty/defaults v1.8.0/go.mod h1:iGzKe6pbEHnpMPtfDXZEr0NVxWnPTjb1bbDy08fPzYM= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/app/control/clean/main.go b/internal/app/control/clean/main.go new file mode 100644 index 0000000..7cf6d37 --- /dev/null +++ b/internal/app/control/clean/main.go @@ -0,0 +1,19 @@ +package clean + +import ( + "gitea.sunhpc.com/kelvin/sunhpc/internal/pkg/clean" + + "github.com/spf13/cobra" +) + +func CobraRunE(vars *variables) func(cmd *cobra.Command, args []string) (err error) { + return func(cmd *cobra.Command, args []string) (err error) { + if err = clean.CleanOciBlobCacheDir(); err != nil { + return err + } else if err = clean.CleanOverlays(); err != nil { + return err + } else { + return nil + } + } +} \ No newline at end of file diff --git a/internal/app/control/clean/root.go b/internal/app/control/clean/root.go new file mode 100644 index 0000000..4634035 --- /dev/null +++ b/internal/app/control/clean/root.go @@ -0,0 +1,23 @@ +package clean + +import ( + "github.com/spf13/cobra" + "gitea.sunhpc.com/kelvin/sunhpc/internal/app/control/completions" +) + +type variables struct { +} + +func GetCommand() *cobra.Command { + vars := variables{} + baseCmd := &cobra.Command{ + DisableFlagsInUseLine: true, + Use: "clean", + Short: "Clean up", + Long: "This command cleans the OCI cache and removes leftovers from deleted nodes", + RunE: CobraRunE(&vars), + Args: cobra.NoArgs, + ValidArgsFunction: completions.None, + } + return baseCmd +} \ No newline at end of file diff --git a/internal/app/control/completions/completions.go b/internal/app/control/completions/completions.go new file mode 100644 index 0000000..10f8093 --- /dev/null +++ b/internal/app/control/completions/completions.go @@ -0,0 +1,9 @@ +package completions + +import ( + "github.com/spf13/cobra" +) + +func None(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return nil, cobra.ShellCompDirectiveNoFileComp +} \ No newline at end of file diff --git a/internal/app/control/configure/dhcp/main.go b/internal/app/control/configure/dhcp/main.go new file mode 100644 index 0000000..748bf86 --- /dev/null +++ b/internal/app/control/configure/dhcp/main.go @@ -0,0 +1,10 @@ +package dhcp + +import ( + "github.com/spf13/cobra" + "gitea.sunhpc.com/kelvin/sunhpc/internal/pkg/configure" +) + +func CobraRunE(cmd *cobra.Command, args []string) error { + return configure.DHCP() +} diff --git a/internal/app/control/configure/dhcp/root.go b/internal/app/control/configure/dhcp/root.go new file mode 100644 index 0000000..53f48c3 --- /dev/null +++ b/internal/app/control/configure/dhcp/root.go @@ -0,0 +1,24 @@ +package dhcp + +import ( + "github.com/spf13/cobra" + "gitea.sunhpc.com/kelvin/sunhpc/internal/app/control/completions" +) + +var ( + baseCmd = &cobra.Command{ + DisableFlagsInUseLine: true, + Use: "dhcp [OPTIONS]", + Short: "Manage and initialize DHCP", + Long: "DHCP is a dependent service to Warewulf. This command will configure DHCP as defined\n" + + "in the warewulf.conf file.", + RunE: CobraRunE, + Args: cobra.NoArgs, + ValidArgsFunction: completions.None, + } +) + +// GetRootCommand returns the root cobra.Command for the application. +func GetCommand() *cobra.Command { + return baseCmd +} diff --git a/internal/app/control/configure/main.go b/internal/app/control/configure/main.go new file mode 100644 index 0000000..170e565 --- /dev/null +++ b/internal/app/control/configure/main.go @@ -0,0 +1,32 @@ +package configure + +import ( + + "github.com/spf13/cobra" + sunhpcconf "gitea.sunhpc.com/kelvin/sunhpc/internal/pkg/config" + "gitea.sunhpc.com/kelvin/sunhpc/internal/pkg/configure" + "gitea.sunhpc.com/kelvin/sunhpc/internal/pkg/splog" +) + +func CobraRunE(cmd *cobra.Command, args []string) error { + var err error + + conf := sunhpcconf.Get() + if conf.Autodetected() && conf.InitializedFromFile() { + if err = conf.PersistToFile(conf.GetSunhpcConf()); err != nil { + splog.Warn("error when persisting auto-detected settings: %s", err) + } + } + + if allFunctions { + if _, err = configure.TLS(false); err != nil { + return err + } + + err = configure.SUNHPCD() + if err != nil { + return err + } + } + return nil +} \ No newline at end of file diff --git a/internal/app/control/configure/root.go b/internal/app/control/configure/root.go new file mode 100644 index 0000000..ee2ea2e --- /dev/null +++ b/internal/app/control/configure/root.go @@ -0,0 +1,30 @@ +package configure + +import ( + "github.com/spf13/cobra" + + "gitea.sunhpc.com/kelvin/sunhpc/internal/app/control/configure/dhcp" +) + +var ( + baseCmd = &cobra.Command{ + DisableFlagsInUseLine: true, + Use: "config [options]", + Short: "Manage sunhpc configuration", + Long: "This application allows you to manage and initialize Sunhpc configuration\n" + + "services based on the configuration in the sunhpc.conf file.", + RunE: CobraRunE, + Args: cobra.NoArgs, + } + allFunctions bool +) + +func init() { + baseCmd.AddCommand(dhcp.GetCommand()) + baseCmd.Flags().BoolVarP(&allFunctions, "all", "a", false, "Configure all services") +} + +// GetRootCommand returns the root cobra.Command for the application. +func GetCommand() *cobra.Command { + return baseCmd +} diff --git a/internal/app/control/root.go b/internal/app/control/root.go index 29092a7..a63db3c 100644 --- a/internal/app/control/root.go +++ b/internal/app/control/root.go @@ -4,7 +4,10 @@ import ( "os" "github.com/spf13/cobra" - "gitea.sunhpc.com/kelvin/sunhpc/internal/app/control/version" + "gitea.sunhpc.com/kelvin/sunhpc/internal/app/control/clean" + "gitea.sunhpc.com/kelvin/sunhpc/internal/app/control/configure" + + sunhpcconf "gitea.sunhpc.com/kelvin/sunhpc/internal/pkg/config" "gitea.sunhpc.com/kelvin/sunhpc/internal/pkg/help" "gitea.sunhpc.com/kelvin/sunhpc/internal/pkg/splog" ) @@ -16,18 +19,19 @@ var ( Short: "Sunhpc Control", Long: `Control interface to the SunHPC Cluster Provisioning System.`, PersistentPreRunE: rootPersistentPreRunE, - SilenceUsage: true, - SilenceErrors: true, - Args: cobra.NoArgs, - } - verboseArg bool - DebugFlag bool - SunhpcConfArg string - AllowEmptyConf bool + SilenceUsage: true, + SilenceErrors: true, + Args: cobra.NoArgs, + } + verboseArg bool + DebugFlag bool + LogLevel int + SunhpcConfArg string + AllowEmptyConf bool ) func init() { - rootCmd.CompletionOptions.HiddenDefaultCmd = true + //rootCmd.CompletionOptions.HiddenDefaultCmd = true rootCmd.PersistentFlags().BoolVarP(&verboseArg, "verbose", "v", false, "verbose output") rootCmd.PersistentFlags().BoolVarP(&DebugFlag, "debug", "d", false, "debug output") rootCmd.PersistentFlags().IntVar(&LogLevel, "loglevel", splog.INFO, "Set log level to given string") @@ -35,6 +39,42 @@ func init() { rootCmd.PersistentFlags().StringVar(&SunhpcConfArg, "sunhpcconf", "", "Set the sunhpc configuration file") rootCmd.PersistentFlags().BoolVar(&AllowEmptyConf, "emptyconf", false, "Allow empty configuration") _ = rootCmd.PersistentFlags().MarkHidden("emptyconf") + rootCmd.SetUsageTemplate(help.UsageTemplate) rootCmd.SetHelpTemplate(help.HelpTemplate) -} \ No newline at end of file + + rootCmd.AddCommand(clean.GetCommand()) + rootCmd.AddCommand(configure.GetCommand()) +} + +func GetRootCommand() *cobra.Command { + return rootCmd +} + +func rootPersistentPreRunE(cmd *cobra.Command, args []string) (err error) { + if DebugFlag { + splog.SetLogLevel(splog.DEBUG) + } else if verboseArg { + splog.SetLogLevel(splog.VERBOSE) + } else { + splog.SetLogLevel(splog.INFO) + } + if LogLevel != splog.INFO { + splog.SetLogLevel(LogLevel) + } + + if SunhpcConfArg != "" { + sunhpcconf.ConfigFile = SunhpcConfArg + } else if os.Getenv("SUNHPC_CONF") != "" { + sunhpcconf.ConfigFile = os.Getenv("SUNHPC_CONF") + } + + conf := sunhpcconf.Get() + if !AllowEmptyConf && !conf.InitializedFromFile() { + if err = conf.Read(sunhpcconf.ConfigFile, true); err != nil { + splog.Error("error reading configuration file: %s", err) + return + } + } + return +} diff --git a/internal/pkg/clean/clean.go b/internal/pkg/clean/clean.go new file mode 100644 index 0000000..651ad94 --- /dev/null +++ b/internal/pkg/clean/clean.go @@ -0,0 +1,9 @@ +package clean + +func CleanOciBlobCacheDir() error { + return nil +} + +func CleanOverlays() error { + return nil +} \ No newline at end of file diff --git a/internal/pkg/config/api.go b/internal/pkg/config/api.go new file mode 100644 index 0000000..5a1f6bb --- /dev/null +++ b/internal/pkg/config/api.go @@ -0,0 +1,56 @@ +package config + +import ( + "net" + + "github.com/creasty/defaults" + "gitea.sunhpc.com/kelvin/sunhpc/internal/pkg/util" +) + +type IPNet net.IPNet + +func (n IPNet) MarshalText() ([]byte, error) { + ipnet := n.IPNet() + return []byte((&ipnet).String()), nil +} + +func (n *IPNet) UnmarshalText(text []byte) error { + _, network, err := net.ParseCIDR(string(text)) + if err != nil { + return err + } + *n = IPNet(*network) + return nil +} + +func (n IPNet) IPNet() net.IPNet { + return net.IPNet(n) +} + +type APIConf struct { + EnabledP *bool `yaml:"enabled,omitempty" default:"false"` + TLSEnabledP *bool `yaml:"tls,omitempty"` + AllowedNets []IPNet `yaml:"allowed subnets,omitempty" default:"[\"127.0.0.0/8\", \"::1/128\"]"` +} + +func (conf *APIConf) AllowedIPNets() (allowedIPNets []net.IPNet) { + for _, allowedIPNet := range conf.AllowedNets { + allowedIPNets = append(allowedIPNets, allowedIPNet.IPNet()) + } + return allowedIPNets +} + +func (conf *APIConf) Unmarshal(unmarshal func(interface{}) error) error { + if err := defaults.Set(conf); err != nil { + return err + } + return nil +} + +func (conf APIConf) Enabled() bool { + return util.BoolP(conf.EnabledP) +} + +func (conf APIConf) TLSEnabled() bool { + return util.BoolP(conf.TLSEnabledP) +} diff --git a/internal/pkg/config/buildconfig.go b/internal/pkg/config/buildconfig.go new file mode 100644 index 0000000..abcda9b --- /dev/null +++ b/internal/pkg/config/buildconfig.go @@ -0,0 +1,97 @@ +package config + +import ( + "path" + "gitea.sunhpc.com/kelvin/sunhpc/internal/pkg/util" +) + +var ConfigFile = "/etc/sunhpc/sunhpc.conf" + +type BuildConfig struct { + Bindir string `yaml:"bindir,omitempty" default:"@BINDIR@"` + Sysconfdir string `yaml:"sysconfdir,omitempty" default:"@SYSCONFDIR@"` + Localstatedir string `yaml:"localstatedir,omitempty" default:"@LOCALSTATEDIR@"` + Cachedir string `yaml:"cachedir,omitempty" default:"@CACHEDIR@"` + Ipxesource string `yaml:"ipxesource,omitempty" default:"@IPXESOURCE@"` + Srvdir string `yaml:"srvdir,omitempty" default:"@SRVDIR@"` + Firewallddir string `yaml:"firewallddir,omitempty" default:"@FIREWALLDDIR@"` + Systemddir string `yaml:"systemddir,omitempty" default:"@SYSTEMDDIR@"` + Datadir string `yaml:"datadir,omitempty" default:"@DATADIR@"` + WWOverlaydir string `yaml:"wwoverlaydir,omitempty" default:"@WWOVERLAYDIR@"` + WWChrootdir string `yaml:"wwchrootdir,omitempty" default:"@WWCHROOTDIR@"` + WWProvisiondir string `yaml:"wwprovisiondir,omitempty" default:"@WWPROVISIONDIR@"` + WWClientdir string `yaml:"wwclientdir,omitempty" default:"@WWCLIENTDIR@"` +} + +const Version = "1.0.0" +const Release = "1" + +type TFTPConf struct { + EnabledP *bool `yaml:"enabled" default:"true"` + TftpRoot string `yaml:"tftproot,omitempty" default:"@TFTPDIR@"` + SystemdName string `yaml:"systemd name,omitempty" default:"tftp"` + + IpxeBinaries map[string]string `yaml:"ipxe,omitempty" default:"{\"00:09\": \"ipxe-snponly-x86_64.efi\",\"00:00\": \"undionly.kpxe\",\"00:0B\": \"arm64-efi/snponly.efi\",\"00:07\": \"ipxe-snponly-x86_64.efi\"}"` +} + +func (conf TFTPConf) Enabled() bool { + return util.BoolP(conf.EnabledP) +} + +// SunhpcConf adds additional Sunhpc-specific configuration to +// BaseConf. +type SunhpcConf struct { + Port int `yaml:"port,omitempty" default:"9873"` + TLSPort int `yaml:"tls port,omitempty" default:"9874"` + SecureP *bool `yaml:"secure,omitempty" default:"true"` + TLSEnabledP *bool `yaml:"tls,omitempty"` + UpdateInterval int `yaml:"update interval,omitempty" default:"60"` + AutobuildOverlaysP *bool `yaml:"autobuild overlays,omitempty" default:"true"` + EnableHostOverlayP *bool `yaml:"host overlay,omitempty" default:"true"` + GrubBootP *bool `yaml:"grubboot,omitempty" default:"false"` + SystemdName string `yaml:"systemd name,omitempty"` +} + +func (conf SunhpcConf) Secure() bool { + return util.BoolP(conf.SecureP) +} + +func (conf SunhpcConf) TLSEnabled() bool { + return util.BoolP(conf.TLSEnabledP) +} + +func (conf SunhpcConf) AutobuildOverlays() bool { + return util.BoolP(conf.AutobuildOverlaysP) +} + +func (conf SunhpcConf) EnableHostOverlay() bool { + return util.BoolP(conf.EnableHostOverlayP) +} + +func (conf SunhpcConf) GrubBoot() bool { + return util.BoolP(conf.GrubBootP) +} + +func (paths BuildConfig) NodesConf() string { + return path.Join(paths.Sysconfdir, "sunhpc", "nodes.conf") +} + +func (paths BuildConfig) AuthenticationConf() string { + return path.Join(paths.Sysconfdir, "sunhpc", "auth.conf") +} + +func (paths BuildConfig) OciBlobCachedir() string { + return path.Join(paths.Cachedir, "sunhpc") +} + +func (paths BuildConfig) SiteOverlaydir() string { + return paths.WWOverlaydir +} + +func (paths BuildConfig) DistributionOverlaydir() string { + return path.Join(paths.Datadir, "sunhpc", "overlays") +} + +func (paths BuildConfig) OverlayProvisiondir() string { + return path.Join(paths.WWProvisiondir, "overlays") +} \ No newline at end of file diff --git a/internal/pkg/config/client.go b/internal/pkg/config/client.go new file mode 100644 index 0000000..69b8431 --- /dev/null +++ b/internal/pkg/config/client.go @@ -0,0 +1,5 @@ +package config + +type SPClientConf struct { + Port uint16 `yaml:"port,omitempty" default:"0"` +} diff --git a/internal/pkg/config/dhcp.go b/internal/pkg/config/dhcp.go new file mode 100644 index 0000000..4f6a12c --- /dev/null +++ b/internal/pkg/config/dhcp.go @@ -0,0 +1,21 @@ +package config + +import ( + "gitea.sunhpc.com/kelvin/sunhpc/internal/pkg/util" +) + +// DHCPConf represents the configuration for the DHCP service that +// Sunhpc will configure. +type DHCPConf struct { + EnabledP *bool `yaml:"enabled,omitempty" default:"true"` + Template string `yaml:"template,omitempty" default:"default"` + RangeStart string `yaml:"range start,omitempty"` + RangeEnd string `yaml:"range end,omitempty"` + Range6Start string `yaml:"range6 start,omitempty"` + Range6End string `yaml:"range6 end,omitempty"` + SystemdName string `yaml:"systemd name,omitempty" default:"dhcpd"` +} + +func (conf DHCPConf) Enabled() bool { + return util.BoolP(conf.EnabledP) +} \ No newline at end of file diff --git a/internal/pkg/config/dnsmasq.go b/internal/pkg/config/dnsmasq.go new file mode 100644 index 0000000..88a99d2 --- /dev/null +++ b/internal/pkg/config/dnsmasq.go @@ -0,0 +1,21 @@ +package config + +import ( + "gitea.sunhpc.com/kelvin/sunhpc/internal/pkg/util" +) + +// DNSmasqConf represents the configuration for the DNSmasq service that +// Sunhpc will configure. +type DNSMASQConf struct { + EnabledP *bool `yaml:"enabled,omitempty" default:"true"` + Template string `yaml:"template,omitempty" default:"default"` + RangeStart string `yaml:"range start,omitempty"` + RangeEnd string `yaml:"range end,omitempty"` + Range6Start string `yaml:"range6 start,omitempty"` + Range6End string `yaml:"range6 end,omitempty"` + SystemdName string `yaml:"systemd name,omitempty" default:"dhcpd"` +} + +func (conf DNSMASQConf) Enabled() bool { + return util.BoolP(conf.EnabledP) +} \ No newline at end of file diff --git a/internal/pkg/config/mounts.go b/internal/pkg/config/mounts.go new file mode 100644 index 0000000..44f1da1 --- /dev/null +++ b/internal/pkg/config/mounts.go @@ -0,0 +1,23 @@ +package config + +import ( + "gitea.sunhpc.com/kelvin/sunhpc/internal/pkg/util" +) + +// A MountEntry represents a bind mount that is applied to an image +// during exec and shell. +type MountEntry struct { + Source string `yaml:"source"` + Dest string `yaml:"dest,omitempty"` + ReadOnlyP *bool `yaml:"readonly,omitempty"` + Options string `yaml:"options,omitempty"` // ignored at the moment + CopyP *bool `yaml:"copy,omitempty"` // temporarily copy the file into the image +} + +func (mount MountEntry) ReadOnly() bool { + return util.BoolP(mount.ReadOnlyP) +} + +func (mount MountEntry) Copy() bool { + return util.BoolP(mount.CopyP) +} diff --git a/internal/pkg/config/nfs.go b/internal/pkg/config/nfs.go new file mode 100644 index 0000000..8d23d6b --- /dev/null +++ b/internal/pkg/config/nfs.go @@ -0,0 +1,34 @@ +package config + +import ( + "github.com/creasty/defaults" + "gitea.sunhpc.com/kelvin/sunhpc/internal/pkg/util" +) + +// NFSConf represents the NFS configuration that will be used by +// Sunhpc to generate exports on the server and mounts on compute +// nodes. +type NFSConf struct { + EnabledP *bool `yaml:"enabled,omitempty" default:"true"` + ExportsExtended []*NFSExportConf `yaml:"export paths,omitempty" default:"[]"` + SystemdName string `yaml:"systemd name,omitempty" default:"nfsd"` +} + +func (conf NFSConf) Enabled() bool { + return util.BoolP(conf.EnabledP) +} + +// An NFSExportConf reprents a single NFS export / mount. +type NFSExportConf struct { + Path string `yaml:"path" default:"/dev/null"` + ExportOptions string `yaml:"export options,omitempty" default:"rw,sync,no_subtree_check"` +} + +// Implements the Unmarshal interface for NFSConf to set default +// values. +func (conf *NFSConf) Unmarshal(unmarshal func(interface{}) error) error { + if err := defaults.Set(conf); err != nil { + return err + } + return nil +} diff --git a/internal/pkg/config/root.go b/internal/pkg/config/root.go new file mode 100644 index 0000000..65b9f96 --- /dev/null +++ b/internal/pkg/config/root.go @@ -0,0 +1,262 @@ +// Package config reads, parses, and represents the sunhpc.conf +// config file. +// +// sunhpc.conf is a yaml-formatted configuration file that includes +// configuration for the sunhpc daemon and commands, as well as the +// DHCP, TFTP and NFS services that sunhpc manages. +package config + +import ( + "bytes" + "fmt" + "net" + "os" + "reflect" + "strconv" + + "github.com/creasty/defaults" + "gitea.sunhpc.com/kelvin/sunhpc/internal/pkg/splog" + "gopkg.in/yaml.v3" +) + +var cachedConf SunhpcYaml + +// SunhpcYaml is the main Sunhpc configuration structure. It stores +// some information about the Sunhpc server locally, and has +// [SunhpcConf], [DHCPConf], [TFTPConf], and [NFSConf] sub-sections. +type SunhpcYaml struct { + Comment string `yaml:"comment,omitempty"` + Ipaddr string `yaml:"ipaddr,omitempty"` + Netmask string `yaml:"netmask,omitempty"` + Network string `yaml:"network,omitempty"` + Fqdn string `yaml:"fqdn,omitempty"` + Ipaddr6 string `yaml:"ipaddr6,omitempty"` + PrefixLen6 string `yaml:"prefixlen6,omitempty"` + Sunhpc *SunhpcConf `yaml:"sunhpc,omitempty"` + API *APIConf `yaml:"api,omitempty"` + DNSMASQ *DNSMASQConf `yaml:"dhcp,omitempty"` + DHCP *DHCPConf `yaml:"dhcp,omitempty"` + TFTP *TFTPConf `yaml:"tftp,omitempty"` + NFS *NFSConf `yaml:"nfs,omitempty"` + SSH *SSHConf `yaml:"ssh,omitempty"` + MountsImage []*MountEntry `yaml:"image mounts,omitempty" default:"[{\"source\": \"/etc/resolv.conf\", \"dest\": \"/etc/resolv.conf\"}]"` + Paths *BuildConfig `yaml:"paths,omitempty"` + //Client *SPClientConf `yaml:"client,omitempty"` + + sunhpcconf string + autodetected bool +} + +// New caches and returns a new [SunhpcYaml] initialized with empty +// values, clearing replacing any previously cached value. +func New() *SunhpcYaml { + cachedConf = SunhpcYaml{} + cachedConf.sunhpcconf = "" + + cachedConf.Sunhpc = new(SunhpcConf) + cachedConf.DNSMASQ = new(DNSMASQConf) + cachedConf.Paths = new(BuildConfig) + cachedConf.DHCP = new(DHCPConf) + cachedConf.TFTP = new(TFTPConf) + cachedConf.NFS = new(NFSConf) + cachedConf.SSH = new(SSHConf) + cachedConf.API = new(APIConf) + if err := defaults.Set(&cachedConf); err != nil { + panic(err) + } + return &cachedConf +} + +// Get returns a previously cached [SunhpcYaml] if it exists, or returns +// a new SunhpcYaml. +func Get() *SunhpcYaml { + // NOTE: This function can be called before any log level is set + // so using splog.Verbose or splog.Debug won't work + if reflect.ValueOf(cachedConf).IsZero() { + cachedConf = *New() + } + return &cachedConf +} + +// Read populates [SunhpcYaml] with the values from a configuration +// file. +func (conf *SunhpcYaml) Read(confFileName string, autodetect bool) error { + splog.Debug("Reading sunhpc.conf from: %s", confFileName) + conf.sunhpcconf = confFileName + if data, err := os.ReadFile(confFileName); err != nil { + return err + } else if err := conf.Parse(data, autodetect); err != nil { + return err + } else { + return nil + } +} + +// Parse populates [SunhpcYaml] with the values from a yaml document. +func (conf *SunhpcYaml) Parse(data []byte, autodetect bool) error { + // ipxe binaries are merged not overwritten, store defaults separate + defIpxe := make(map[string]string) + for k, v := range conf.TFTP.IpxeBinaries { + defIpxe[k] = v + delete(conf.TFTP.IpxeBinaries, k) + } + if err := yaml.Unmarshal(data, &conf); err != nil { + return err + } + if len(conf.TFTP.IpxeBinaries) == 0 { + conf.TFTP.IpxeBinaries = defIpxe + } + + if ip, network, err := net.ParseCIDR(conf.Ipaddr); err == nil { + conf.Ipaddr = ip.String() + if conf.Network == "" { + conf.Network = network.IP.String() + } + if conf.Netmask == "" { + conf.Netmask = net.IP(network.Mask).String() + } + } + + if autodetect { + if conf.Ipaddr == "" { + if ip := GetOutboundIP(); ip != nil { + conf.Ipaddr = ip.String() + conf.autodetected = true + } + } + + if conf.Netmask == "" { + if ip := net.ParseIP(conf.Ipaddr); ip != nil { + if network, err := GetIPNetForIP(ip); err == nil { + conf.Netmask = net.IP(network.Mask).String() + conf.autodetected = true + } + } + } + + if conf.Network == "" { + if ip := net.ParseIP(conf.Ipaddr); ip != nil { + if mask := net.IPMask(net.ParseIP(conf.Netmask)); mask != nil { + conf.Network = ip.Mask(mask).String() + conf.autodetected = true + } + } + } + } + + if ip := net.ParseIP(conf.Ipaddr6); ip != nil { + if prefix, err := strconv.Atoi(conf.PrefixLen6); err == nil { + conf.Ipaddr6 = ip.String() + conf.PrefixLen6 = strconv.Itoa(prefix) + } + } + + if ip, net, err := net.ParseCIDR(conf.Ipaddr6); err == nil { + conf.Ipaddr6 = ip.String() + prefixLen6, _ := net.Mask.Size() + conf.PrefixLen6 = strconv.Itoa(prefixLen6) + } + + return nil +} + +func (config *SunhpcYaml) Network6() string { + cidr := fmt.Sprintf("%s/%s", config.Ipaddr6, config.PrefixLen6) + _, ipnet, err := net.ParseCIDR(cidr) + if err != nil { + return "" + } + return ipnet.IP.String() +} + +func (config *SunhpcYaml) NetworkCIDR() string { + if config.Network == "" || config.Netmask == "" { + return "" + } + cidr := net.IPNet{ + IP: net.ParseIP(config.Network), + Mask: net.IPMask(net.ParseIP(config.Netmask)), + } + if cidr.IP == nil || cidr.Mask == nil { + return "" + } + return cidr.String() +} + +func (config *SunhpcYaml) NetworkCIDR6() string { + cidr := fmt.Sprintf("%s/%s", config.Ipaddr6, config.PrefixLen6) + _, ipnet, err := net.ParseCIDR(cidr) + if err != nil { + return "" + } + return ipnet.String() +} + +func (config *SunhpcYaml) IpCIDR() string { + if config.Ipaddr == "" || config.Netmask == "" { + return "" + } + cidr := net.IPNet{ + IP: net.ParseIP(config.Ipaddr), + Mask: net.IPMask(net.ParseIP(config.Netmask)), + } + if cidr.IP == nil || cidr.Mask == nil { + return "" + } + return cidr.String() +} + +func (config *SunhpcYaml) IpCIDR6() string { + cidr := fmt.Sprintf("%s/%s", config.Ipaddr6, config.PrefixLen6) + ip, _, err := net.ParseCIDR(cidr) + if err != nil || ip == nil || ip.To4() != nil { + return "" + } + return cidr +} + +// InitializedFromFile returns true if [SunhpcYaml] memory was read from +// a file, or false otherwise. +func (conf *SunhpcYaml) InitializedFromFile() bool { + return conf.sunhpcconf != "" +} + +func (conf *SunhpcYaml) GetSunhpcConf() string { + return conf.sunhpcconf +} + +func (conf *SunhpcYaml) Autodetected() bool { + return conf.autodetected +} + +func (config *SunhpcYaml) Dump() ([]byte, error) { + var buf bytes.Buffer + yamlEncoder := yaml.NewEncoder(&buf) + yamlEncoder.SetIndent(2) + err := yamlEncoder.Encode(config) + return buf.Bytes(), err +} + +func (config *SunhpcYaml) PersistToFile(configFile string) (err error) { + out, dumpErr := config.Dump() + if dumpErr != nil { + splog.Error("%s", dumpErr) + return dumpErr + } + file, err := os.OpenFile(configFile, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o644) + if err != nil { + splog.Error("%s", err) + return err + } + defer func() { + if cerr := file.Close(); cerr != nil && err == nil { + err = cerr + } + }() + _, err = file.WriteString(string(out)) + if err != nil { + return err + } + splog.Debug("persisted: %s", configFile) + return nil +} \ No newline at end of file diff --git a/internal/pkg/config/ssh.go b/internal/pkg/config/ssh.go new file mode 100644 index 0000000..5acb257 --- /dev/null +++ b/internal/pkg/config/ssh.go @@ -0,0 +1,5 @@ +package config + +type SSHConf struct { + KeyTypes []string `yaml:"key types,omitempty" default:"[\"ed25519\",\"ecdsa\",\"rsa\",\"dsa\"]"` +} diff --git a/internal/pkg/config/util.go b/internal/pkg/config/util.go new file mode 100644 index 0000000..878fe89 --- /dev/null +++ b/internal/pkg/config/util.go @@ -0,0 +1,58 @@ +package config + +import ( + "fmt" + "net" +) + +func GetOutboundIP() net.IP { + conn, err := net.Dial("udp", "192.0.2.1:80") + if err != nil { + return nil + } + defer func() { _ = conn.Close() }() + localAddr := conn.LocalAddr().(*net.UDPAddr) + return localAddr.IP +} + +func GetIPNetForIP(ip net.IP) (*net.IPNet, error) { + interfaces, err := net.Interfaces() + if err != nil { + return nil, fmt.Errorf("failed to get network interfaces: %v", err) + } + + for _, iface := range interfaces { + // skip interfaces that are down or not relevant + if iface.Flags&net.FlagUp == 0 { + continue + } + + addrs, err := iface.Addrs() + if err != nil { + continue // try next interface + } + + for _, addr := range addrs { + // We expect addr to be of type *net.IPNet. + var ipNet *net.IPNet + switch v := addr.(type) { + case *net.IPNet: + ipNet = v + case *net.IPAddr: + // Wrap net.IPAddr in an IPNet with its default mask. + ipNet = &net.IPNet{IP: v.IP, Mask: v.IP.DefaultMask()} + } + + if ipNet == nil { + continue + } + + // Check if the IP matches (for IPv4, Equal works well) + if ipNet.IP.Equal(ip) { + return ipNet, nil + } + } + } + + return nil, fmt.Errorf("could not find IPNet for IP %v", ip) +} diff --git a/internal/pkg/configure/dhcp.go b/internal/pkg/configure/dhcp.go new file mode 100644 index 0000000..9406247 --- /dev/null +++ b/internal/pkg/configure/dhcp.go @@ -0,0 +1,22 @@ +package configure + +import ( + "fmt" + + sunhpccconf "gitea.sunhpc.com/kelvin/sunhpc/internal/pkg/config" + "gitea.sunhpc.com/kelvin/sunhpc/internal/pkg/splog" + //"gitea.sunhpc.com/kelvin/sunhpc/internal/pkg/util" +) + + +func DHCP() (err error) { + controller := sunhpccconf.Get() + + if !controller.DHCP.Enabled() { + splog.Warn("This system is not configured as a Sunhpc DHCP controller.") + return + } + + fmt.Printf("Enabling and restarting the DHCP services\n") + return +} \ No newline at end of file diff --git a/internal/pkg/configure/sunhpcd.go b/internal/pkg/configure/sunhpcd.go new file mode 100644 index 0000000..21ce9af --- /dev/null +++ b/internal/pkg/configure/sunhpcd.go @@ -0,0 +1,22 @@ +package configure + +import ( + sunhpcconf "gitea.sunhpc.com/kelvin/sunhpc/internal/pkg/config" + "gitea.sunhpc.com/kelvin/sunhpc/internal/pkg/util" + "gitea.sunhpc.com/kelvin/sunhpc/internal/pkg/splog" +) + +func SUNHPCD() (err error) { + controller := sunhpcconf.Get() + if controller.Sunhpc.SystemdName != "" { + splog.Info("Enabling and restarting the Sunhpc server") + err = util.SystemdStart(controller.Sunhpc.SystemdName) + if err != nil { + return err + } + } else { + splog.Warn("Not (re)starting Sunhpc server: no systemd name configured") + } + + return nil +} diff --git a/internal/pkg/configure/tls.go b/internal/pkg/configure/tls.go new file mode 100644 index 0000000..4186864 --- /dev/null +++ b/internal/pkg/configure/tls.go @@ -0,0 +1,118 @@ +package configure + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "math/big" + "net" + "os" + "path" + "time" + + sunhpcconf "gitea.sunhpc.com/kelvin/sunhpc/internal/pkg/config" + "gitea.sunhpc.com/kelvin/sunhpc/internal/pkg/util" + "gitea.sunhpc.com/kelvin/sunhpc/internal/pkg/splog" +) + +// TLS ensures TLS keys exist if TLS is enabled. +// If force is true, regenerates even if keys already exist. +// Returns true if new keys were generated. +func TLS(force bool) (bool, error) { + conf := sunhpcconf.Get() + if !conf.Sunhpc.TLSEnabled() { + return false, nil + } + + keystore := path.Join(conf.Paths.Sysconfdir, "sunhpc", "tls") + keyFile := path.Join(keystore, "sunhpc.key") + certFile := path.Join(keystore, "sunhpc.crt") + + if !force && util.IsFile(keyFile) && util.IsFile(certFile) { + splog.Info("TLS keys already exist in %s", keystore) + return false, nil + } + + if err := GenTLSKeys(); err != nil { + return false, err + } + splog.Info("TLS keys generated in %s", keystore) + return true, nil +} + +// GenTLSKeys generates new TLS keys and certificate unconditionally. +func GenTLSKeys() error { + conf := sunhpcconf.Get() + keystore := path.Join(conf.Paths.Sysconfdir, "sunhpc", "tls") + + if err := os.MkdirAll(keystore, 0755); err != nil { + return fmt.Errorf("could not create keystore directory: %w", err) + } + + keyFile := path.Join(keystore, "sunhpc.key") + certFile := path.Join(keystore, "sunhpc.crt") + splog.Verbose("Generating new x509 keys in %s", keystore) + priv, err := rsa.GenerateKey(rand.Reader, 4096) + if err != nil { + return fmt.Errorf("failed to generate rsa key: %w", err) + } + + serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) + serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) + if err != nil { + return fmt.Errorf("failed to generate serial number: %w", err) + } + + template := x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{ + CommonName: "Sunhpc Server", + Organization: []string{"Sunhpc"}, + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour * 24 * 365 * 10), + + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + } + + if ip := net.ParseIP(conf.Ipaddr); ip != nil { + template.IPAddresses = append(template.IPAddresses, ip) + } + if ip := net.ParseIP(conf.Ipaddr6); ip != nil { + template.IPAddresses = append(template.IPAddresses, ip) + } + if conf.Fqdn != "" { + template.DNSNames = append(template.DNSNames, conf.Fqdn) + } + template.DNSNames = append(template.DNSNames, "sunhpc") + + derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv) + if err != nil { + return fmt.Errorf("failed to create certificate: %w", err) + } + + keyOut, err := os.OpenFile(keyFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) + if err != nil { + return fmt.Errorf("failed to open key file for writing: %w", err) + } + defer func() { _ = keyOut.Close() }() + if err := pem.Encode(keyOut, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)}); err != nil { + return fmt.Errorf("failed to write data to key file: %w", err) + } + + certOut, err := os.OpenFile(certFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) + if err != nil { + return fmt.Errorf("failed to open cert file for writing: %w", err) + } + defer func() { _ = certOut.Close() }() + if err := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}); err != nil { + return fmt.Errorf("failed to write data to cert file: %w", err) + } + + return nil +} diff --git a/internal/pkg/splog/splog.go b/internal/pkg/splog/splog.go index 1471fe8..791fb1a 100644 --- a/internal/pkg/splog/splog.go +++ b/internal/pkg/splog/splog.go @@ -1,4 +1,4 @@ -package wwlog +package splog import ( "fmt" diff --git a/internal/pkg/util/util.go b/internal/pkg/util/util.go new file mode 100644 index 0000000..31b7ab3 --- /dev/null +++ b/internal/pkg/util/util.go @@ -0,0 +1,464 @@ +package util + +import ( + "bufio" + "bytes" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "io/fs" + "net" + "os" + "os/exec" + //"path" + "path/filepath" + "regexp" + "strings" + "syscall" + //"time" + + "gopkg.in/yaml.v3" + + "gitea.sunhpc.com/kelvin/sunhpc/internal/pkg/splog" +) + +func IsDir(path string) bool { + splog.Debug("Checking if path exists as a directory: %s", path) + + if path == "" { + return false + } + if stat, err := os.Stat(path); err == nil && stat.IsDir() { + return true + } + return false +} + +func IsFile(path string) bool { + splog.Debug("Checking if path exists as a file: %s", path) + + if path == "" { + return false + } + + if stat, err := os.Lstat(path); err == nil && !stat.IsDir() { + return true + } + return false +} + +func ReadFile(path string) ([]string, error) { + lines := []string{} + f, err := os.Open(path) + if err != nil { + return nil, err + } + scanner := bufio.NewScanner(f) + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + _ = f.Close() + return lines, nil +} + +func ValidString(pattern string, s string) bool { + if b, _ := regexp.MatchString(pattern, s); b { + return true + } + return false +} + +func FindFiles(path string) []string { + var ret []string + + splog.Debug("Finding files in path: %s", path) + + err := filepath.Walk(path, func(location string, info os.FileInfo, err error) error { + if err != nil { + splog.Warn("Error walking path %s: %v", location, err) + return err + } + + // Get the relative path from the base directory + relPath, relErr := filepath.Rel(path, location) + if relErr != nil { + splog.Warn("Error computing relative path for %s: %v", location, relErr) + return relErr + } + + if relPath == "." { + return nil + } + + if info.IsDir() { + splog.Debug("FindFiles() found directory: %s", relPath) + ret = append(ret, relPath+"/") + } else { + splog.Debug("FindFiles() found file: %s", relPath) + ret = append(ret, relPath) + } + + return nil + }) + if err != nil { + splog.Warn("Error during file walk: %v", err) + return ret + } + + return ret +} + +/* +Finds all files under a given directory with tar like include and ignore patterns. +/foo/* +will match /foo/baar/ and /foo/baar/sibling +*/ +func FindFilterFiles( + path string, + includePattern []string, + ignorePattern []string, + ignore_xdev bool, +) (ofiles []string, err error) { + splog.Debug("Finding files: %s include: %s ignore: %s", path, includePattern, ignorePattern) + + // Preprocess patterns to remove leading (and trailing) /, as we are handling relative paths + for i, pattern := range ignorePattern { + ignorePattern[i] = strings.Trim(pattern, "/") + } + + // Convert the base path to an absolute path + absPath, err := filepath.Abs(path) + if err != nil { + return ofiles, fmt.Errorf("failed to resolve absolute path: %s: %w", path, err) + } + + // Expand the include list + var globedInclude []string + for _, include := range includePattern { + globed, err := filepath.Glob(filepath.Join(absPath, include)) + if err != nil { + return ofiles, fmt.Errorf("failed to glob pattern %s: %w", include, err) + } + globedInclude = append(globedInclude, globed...) + } + + var dev uint64 + if ignore_xdev { + splog.Debug("Ignoring cross-device (xdev) files") + pathStat, err := os.Stat(absPath) + if err != nil { + return ofiles, fmt.Errorf("failed to stat base path: %s: %w", absPath, err) + } + dev = pathStat.Sys().(*syscall.Stat_t).Dev + } + + for _, inc := range globedInclude { + splog.Debug("Processing include pattern: %s", inc) + stat, err := os.Lstat(inc) + if err != nil { + return ofiles, fmt.Errorf("failed to stat include: %s: %w", inc, err) + } + + if stat.IsDir() { + // Walk the directory + err = filepath.WalkDir(inc, func(location string, info fs.DirEntry, err error) error { + if err != nil { + return err + } + + relPath, relErr := filepath.Rel(absPath, location) + if relErr != nil { + splog.Warn("Error computing relative path for %s: %v", location, relErr) + return relErr + } + + if relPath == "." { + return nil + } + + fsInfo, err := info.Info() + if err != nil { + return err + } + + if ignore_xdev && fsInfo.Sys().(*syscall.Stat_t).Dev != dev { + splog.Debug("Ignored (cross-device): %s", location) + return nil + } + + for _, ignoredPattern := range ignorePattern { + if ignored, _ := filepath.Match(ignoredPattern, relPath); ignored { + splog.Debug("Ignored %s due to pattern %s", relPath, ignoredPattern) + if info.IsDir() { + return filepath.SkipDir + } + return nil + } + } + + ofiles = append(ofiles, relPath) + return nil + }) + if err != nil { + return ofiles, fmt.Errorf("error walking directory %s: %w", inc, err) + } + } else { + // Add the file directly + relPath, relErr := filepath.Rel(absPath, inc) + if relErr != nil { + splog.Warn("Error computing relative path for %s: %v", inc, relErr) + return ofiles, relErr + } + ofiles = append(ofiles, relPath) + } + } + + return ofiles, nil +} + +// ****************************************************************************** +func ExecInteractive(command string, a ...string) error { + splog.Debug("ExecInteractive(%s, %s)", command, a) + c := exec.Command(command, a...) + c.Stdin = os.Stdin + c.Stdout = os.Stdout + c.Stderr = os.Stderr + err := c.Run() + return err +} + +func SystemdStart(systemdName string) error { + startCmd := fmt.Sprintf("systemctl restart %s", systemdName) + enableCmd := fmt.Sprintf("systemctl enable %s", systemdName) + + splog.Debug("Setting up Systemd service: %s", systemdName) + err := ExecInteractive("/bin/sh", "-c", startCmd) + if err != nil { + return fmt.Errorf("failed to run start cmd: %w", err) + } + err = ExecInteractive("/bin/sh", "-c", enableCmd) + if err != nil { + return fmt.Errorf("failed to run enable cmd: %w", err) + } + + return nil +} + +func CopyUIDGID(source string, dest string) error { + info, err := os.Stat(source) + if err != nil { + return err + } + + // root is always good, if we failt to get UID/GID of a file + var UID = 0 + var GID = 0 + if stat, ok := info.Sys().(*syscall.Stat_t); ok { + UID = int(stat.Uid) + GID = int(stat.Gid) + } + splog.Debug("Chown %d:%d '%s'", UID, GID, dest) + err = os.Chown(dest, UID, GID) + return err +} + +func IncrementIPv4(start net.IP, inc uint) net.IP { + ipv4 := start.To4() + v4_int := uint(ipv4[0])<<24 + uint(ipv4[1])<<16 + uint(ipv4[2])<<8 + uint(ipv4[3]) + v4_int += inc + v4_o3 := byte(v4_int & 0xFF) + v4_o2 := byte((v4_int >> 8) & 0xFF) + v4_o1 := byte((v4_int >> 16) & 0xFF) + v4_o0 := byte((v4_int >> 24) & 0xFF) + ipv4_new := net.IPv4(v4_o0, v4_o1, v4_o2, v4_o3) + return ipv4_new +} + +/* +Appending the lines to the given file +*/ +func AppendLines(fileName string, lines []string) (err error) { + splog.Verbose("appending %v lines to %s", len(lines), fileName) + file, err := os.OpenFile(fileName, os.O_APPEND|os.O_WRONLY, 0o644) + if err != nil { + return fmt.Errorf("can't open file: %s: %w", fileName, err) + } + defer func() { + if cerr := file.Close(); cerr != nil && err == nil { + err = cerr + } + }() + for _, line := range lines { + splog.Debug("Appending '%s' to %s", line, fileName) + if _, err := fmt.Fprintf(file, "%s\n", line); err != nil { + return fmt.Errorf("can't write to file: %s: %w", fileName, err) + } + + } + return nil +} + +/* +****************************************************************************** + + Compress a file using gzip or pigz +*/ +func FileGz( + file string, +) (err error) { + file_gz := file + ".gz" + + if IsFile(file_gz) { + err := os.Remove(file_gz) + if err != nil { + return fmt.Errorf("could not remove existing file: %s: %w", file_gz, err) + } + } + + compressor, err := exec.LookPath("pigz") + if err != nil { + splog.Verbose("Could not locate PIGZ") + compressor, err = exec.LookPath("gzip") + if err != nil { + splog.Verbose("Could not locate GZIP") + return fmt.Errorf("no compressor program for image file: %s: %w", file_gz, err) + } + } + + splog.Verbose("Using compressor program: %s", compressor) + + proc := exec.Command( + compressor, + "--keep", + file) + + out, err := proc.CombinedOutput() + if len(out) > 0 { + outStr := string(out[:]) + if err != nil && strings.HasSuffix(compressor, "gzip") && strings.Contains(outStr, "unrecognized option") { + var gzippedFile *os.File + var gzipStderr io.ReadCloser + + /* Older version of gzip, try it another way: */ + splog.Verbose("%s does not recognize the --keep flag, trying redirected stdout", compressor) + + /* Open the output file for writing: */ + gzippedFile, err = os.Create(file_gz) + if err != nil { + return fmt.Errorf("unable to open compressed image file for writing: %s: %w", file_gz, err) + } + + /* We'll execute gzip with output to stdout and attach stdout to the compressed file we just + created: + */ + proc = exec.Command( + compressor, + "--stdout", + file) + proc.Stdout = gzippedFile + gzipStderr, err = proc.StderrPipe() + if err != nil { + return fmt.Errorf("unable to open stderr pipe for compression program: %s: %w", compressor, err) + } + + /* Execute the command: */ + err = proc.Start() + if err != nil { + _ = proc.Wait() + _ = gzippedFile.Close() + _ = os.Remove(file_gz) + err = fmt.Errorf("unable to successfully execute compression program: %s: %w", compressor, err) + } else { + err = proc.Wait() + if cerr := gzippedFile.Close(); cerr != nil { + splog.Warn("failed to close compressed image file %s: %s", file_gz, cerr) + } + if err != nil { + _ = os.Remove(file_gz) + err = fmt.Errorf("unable to successfully create compressed image file: %s: %w", file_gz, err) + } else { + splog.Verbose("Successfully compressed image file: %s", file_gz) + } + } + out, _ = io.ReadAll(gzipStderr) + } + splog.Debug(string(out)) + } + + return err +} + +/* +Get size of given directory in bytes +*/ +func DirSize(path string) (int64, error) { + var size int64 + err := filepath.Walk(path, func(_ string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if !info.IsDir() { + size += info.Size() + } + return err + }) + return size, err +} + +/* +Convert bytes to human friendly format +*/ +func ByteToString(b int64) string { + const base = 1024 + if b < base { + return fmt.Sprintf("%d B", b) + } + div, exp := int64(base), 0 + for n := b / base; n >= base; n /= base { + div *= base + exp++ + } + return fmt.Sprintf("%.1f %ciB", float64(b)/float64(div), "KMGTPE"[exp]) +} + +func HashFile(file *os.File) (string, error) { + if prevOffset, err := file.Seek(0, 0); err != nil { + return "", err + } else { + hasher := sha256.New() + if _, err := io.Copy(hasher, file); err != nil { + return "", err + } + if _, err := file.Seek(prevOffset, 0); err != nil { + return "", err + } + return hex.EncodeToString(hasher.Sum(nil)), nil + } +} + +func EncodeYaml(data interface{}) ([]byte, error) { + buf := new(bytes.Buffer) + encoder := yaml.NewEncoder(buf) + encoder.SetIndent(2) + err := encoder.Encode(data) + return buf.Bytes(), err +} + +func EqualYaml(a interface{}, b interface{}) (bool, error) { + aYaml, err := EncodeYaml(a) + if err != nil { + return false, err + } + + bYaml, err := EncodeYaml(b) + if err != nil { + return false, err + } + + return bytes.Equal(aYaml, bYaml), nil +} + +func BoolP(p *bool) bool { + return p != nil && *p +} \ No newline at end of file diff --git a/sunhpc b/sunhpc index 96b33a0..ab910c9 100755 Binary files a/sunhpc and b/sunhpc differ