diff --git a/addons/apcupsd/addon.go b/addons/apcupsd/addon.go new file mode 100644 index 000000000..68f80f792 --- /dev/null +++ b/addons/apcupsd/addon.go @@ -0,0 +1,59 @@ +package apcupsd + +import ( + "fmt" + + "github.com/rocket-pool/smartnode/shared/types/addons" + cfgtypes "github.com/rocket-pool/smartnode/shared/types/config" +) + +const ( + ContainerID_Apcupsd cfgtypes.ContainerID = "apcupsd" + ContainerID_ApcupsdExporter cfgtypes.ContainerID = "apcupsd_exporter" + ApcupsdContainerName string = "addon_apcupsd" + ApcupsdNetworkComposeTemplateName string = "addon_apcupsd.network" + ApcupsdContainerComposeTemplateName string = "addon_apcupsd.container" + ApcupsdConfigTemplateName string = "addon_apcupsd_config" + ApcupsdConfigName string = "addon_apcupsd.conf" +) + +type Apcupsd struct { + cfg *ApcupsdConfig `yaml:"config,omitempty"` +} + +func NewApcupsd() addons.SmartnodeAddon { + return &Apcupsd{ + cfg: NewConfig(), + } +} + +func (apcupsd *Apcupsd) GetName() string { + return "APCUPS Monitor" +} + +func (apcupsd *Apcupsd) GetDescription() string { + return "This addon adds UPS monitoring to your node so you can monitor the status of your APCUPSD compatible UPS within grafana \n\nMade with love by killjoy.eth." +} + +func (apcupsd *Apcupsd) GetConfig() cfgtypes.Config { + return apcupsd.cfg +} + +func (apcupsd *Apcupsd) GetContainerName() string { + return fmt.Sprint(ContainerID_Apcupsd) +} + +func (apcupsd *Apcupsd) GetEnabledParameter() *cfgtypes.Parameter { + return &apcupsd.cfg.Enabled +} + +func (apcupsd *Apcupsd) GetContainerTag() string { + return containerTag +} + +func (apcupsd *Apcupsd) UpdateEnvVars(envVars map[string]string) error { + if apcupsd.cfg.Enabled.Value == true { + cfgtypes.AddParametersToEnvVars(apcupsd.cfg.GetParameters(), envVars) + } + return nil +} diff --git a/addons/apcupsd/config.go b/addons/apcupsd/config.go new file mode 100644 index 000000000..01847bc43 --- /dev/null +++ b/addons/apcupsd/config.go @@ -0,0 +1,136 @@ +package apcupsd + +import ( + "github.com/rocket-pool/smartnode/shared/types/config" +) + +// Constants +const ( + containerTag string = "gersilex/apcupsd:v1.0.0" + exporterContainerTag string = "jangrewe/apcupsd-exporter:latest" +) + +type Mode string + +const ( + Mode_Network Mode = "network" + Mode_Container Mode = "docker" +) + +// Configuration for the Graffiti Wall Writer +type ApcupsdConfig struct { + Title string `yaml:"-"` + + Enabled config.Parameter `yaml:"enabled,omitempty"` + ApcupsdContainerTag config.Parameter `yaml:"apcupsdContainerTag,omitempty"` + ApcupsdExporterContainerTag config.Parameter `yaml:"apcupsdExporterContainerTag,omitempty"` + MetricsPort config.Parameter `yaml:"metricsPort,omitempty"` + MountPoint config.Parameter `yaml:"mountPoint,omitempty"` + Mode config.Parameter `yaml:"mode,omitempty"` + NetworkAddress config.Parameter `yaml:"NetworkAddress,omitempty"` +} + +// Creates a new configuration instance +func NewConfig() *ApcupsdConfig { + return &ApcupsdConfig{ + Title: "APCUPSD Settings", + + Enabled: config.Parameter{ + ID: "enabled", + Name: "Enabled", + Description: "Enable APCUPSD monitoring", + Type: config.ParameterType_Bool, + Default: map[config.Network]interface{}{config.Network_All: false}, + AffectsContainers: []config.ContainerID{ContainerID_Apcupsd, ContainerID_ApcupsdExporter}, + EnvironmentVariables: []string{"ADDON_APCUPSD_ENABLED"}, + CanBeBlank: false, + OverwriteOnUpgrade: false, + }, + Mode: config.Parameter{ + ID: "mode", + Name: "Mode", + Description: "How would you like to run APCUPSD?\n Select `Container` if you'd like smart node to run apcupsd inside a container for you.\nSelect `network` mode if you want to connect to an instance of apcupsd running on your host machine or on your network.", + Type: config.ParameterType_Choice, + Default: map[config.Network]interface{}{config.Network_All: Mode_Container}, + AffectsContainers: []config.ContainerID{ContainerID_Apcupsd, ContainerID_ApcupsdExporter}, + EnvironmentVariables: []string{}, + CanBeBlank: false, + OverwriteOnUpgrade: false, + Options: []config.ParameterOption{{ + Name: "Container", + Description: "Let the smart node run APCUPSD inside a container for you", + Value: Mode_Container, + }, { + Name: "Network", + Description: "Connect the APCUPSD exporter to an instance of APCUSD running on your host machine or on your network", + Value: Mode_Network, + }}, + }, + ApcupsdContainerTag: config.Parameter{ + ID: "containerTag", + Name: "APCUPSD Container Tag", + Description: "The container tag name of the APCUPSD container.", + Type: config.ParameterType_String, + Default: map[config.Network]interface{}{config.Network_All: containerTag}, + AffectsContainers: []config.ContainerID{ContainerID_Apcupsd}, + EnvironmentVariables: []string{"ADDON_APCUPSD_CONTAINER_TAG"}, + CanBeBlank: false, + OverwriteOnUpgrade: true, + }, + ApcupsdExporterContainerTag: config.Parameter{ + ID: "exporterContainerTag", + Name: "APCUPSD Exporter Container Tag", + Description: "The container tag name of the APCUPSD Prometheus Exporter.", + Type: config.ParameterType_String, + Default: map[config.Network]interface{}{config.Network_All: exporterContainerTag}, + AffectsContainers: []config.ContainerID{ContainerID_ApcupsdExporter}, + EnvironmentVariables: []string{"ADDON_APCUPSD_EXPORTER_CONTAINER_TAG"}, + CanBeBlank: false, + OverwriteOnUpgrade: true, + }, + MetricsPort: config.Parameter{ + ID: "metricsPort", + Name: "APCUPSD Exporter Metrics Port", + Description: "The port the exporter should use to provide metrics to prometheus.", + Type: config.ParameterType_String, + Default: map[config.Network]interface{}{config.Network_All: "9162"}, + AffectsContainers: []config.ContainerID{ContainerID_ApcupsdExporter, config.ContainerID_Prometheus}, + EnvironmentVariables: []string{"ADDON_APCUPSD_METRICS_PORT"}, + CanBeBlank: false, + OverwriteOnUpgrade: false, + }, + MountPoint: config.Parameter{ + ID: "mountPoint", + Name: "APC USB Mount Location", + Description: "The USB mount point for your APC device. This must be set correctly for the container to read data from your UPC. To determine the mount point on your system:\n1. Unplug the USB cable of your UPS and plug it back in.\n2. When your server detects the device an entry will show up when you run `sudo dmesg | grep usb`.\n3. Identify the mount point for your UPS. Often it is named `hiddev*` e.g. `hiddev0`,`hiddev1`... but may vary depending on how many peripherals you have connected.\n4. Verify the mount point for your distribution. Often this maps to `/dev/usb/hiddev*`\nThis is the value to enter in the field below. NOTE: If you reconnect your UPC this value may need to be updated.", + Type: config.ParameterType_String, + Default: map[config.Network]interface{}{config.Network_All: "/dev/usb/hiddev0"}, + AffectsContainers: []config.ContainerID{ContainerID_Apcupsd}, + EnvironmentVariables: []string{"ADDON_APCUPSD_MOUNT_POINT"}, + CanBeBlank: false, + OverwriteOnUpgrade: false, + }, + NetworkAddress: config.Parameter{ + ID: "networkAddress", + Name: "APCUPSD Network Address", + Description: "The network address and port that should be used to connect to APCUPSD.\nIf you have apcupsd installed on your host you should use the default host.docker.internal:3551.", + Type: config.ParameterType_String, + Default: map[config.Network]interface{}{config.Network_All: "host.docker.internal:3551"}, + AffectsContainers: []config.ContainerID{ContainerID_ApcupsdExporter}, + EnvironmentVariables: []string{"ADDON_APCUPSD_NETWORK_ADDRESS"}, + CanBeBlank: false, + OverwriteOnUpgrade: false, + }, + } +} + +// Get the parameters for this config +func (cfg *ApcupsdConfig) GetParameters() []*config.Parameter { + return []*config.Parameter{&cfg.Enabled, &cfg.Mode, &cfg.ApcupsdExporterContainerTag, &cfg.ApcupsdContainerTag, &cfg.MetricsPort, &cfg.MountPoint, &cfg.NetworkAddress} + +} + +// The the title for the config +func (cfg *ApcupsdConfig) GetConfigTitle() string { + return cfg.Title +} diff --git a/addons/constructors.go b/addons/constructors.go index e1009cbb8..02518d4ca 100644 --- a/addons/constructors.go +++ b/addons/constructors.go @@ -1,6 +1,7 @@ package addons import ( + "github.com/rocket-pool/smartnode/addons/apcupsd" "github.com/rocket-pool/smartnode/addons/graffiti_wall_writer" "github.com/rocket-pool/smartnode/shared/types/addons" ) @@ -8,3 +9,7 @@ import ( func NewGraffitiWallWriter() addons.SmartnodeAddon { return graffiti_wall_writer.NewGraffitiWallWriter() } + +func NewApcupsd() addons.SmartnodeAddon { + return apcupsd.NewApcupsd() +} diff --git a/rocketpool-cli/service/config/addon-apcupsd.go b/rocketpool-cli/service/config/addon-apcupsd.go new file mode 100644 index 000000000..29a873157 --- /dev/null +++ b/rocketpool-cli/service/config/addon-apcupsd.go @@ -0,0 +1,174 @@ +package config + +import ( + "fmt" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + "github.com/rocket-pool/smartnode/shared/services/config" + "github.com/rocket-pool/smartnode/shared/types/addons" +) + +// The page wrapper for the APCUPSD addon config +type AddonApcupsdPage struct { + addonsPage *AddonsPage + page *page + layout *standardLayout + masterConfig *config.RocketPoolConfig + addon addons.SmartnodeAddon + enabledBox *parameterizedFormItem + modeBox *parameterizedFormItem + exporterImage *parameterizedFormItem + apcupsdImage *parameterizedFormItem + mountPoint *parameterizedFormItem + metricsPort *parameterizedFormItem + apcupsdAddress *parameterizedFormItem +} + +// Creates a new page for the APCUPSD addon settings +func NewAddonApcupsdPage(addonsPage *AddonsPage, addon addons.SmartnodeAddon) *AddonApcupsdPage { + + configPage := &AddonApcupsdPage{ + addonsPage: addonsPage, + masterConfig: addonsPage.home.md.Config, + addon: addon, + } + configPage.createContent() + + configPage.page = newPage( + addonsPage.page, + "settings-addon-apcupsd", + addon.GetName(), + addon.GetDescription(), + configPage.layout.grid, + ) + + return configPage + +} + +// Get the underlying page +func (configPage *AddonApcupsdPage) getPage() *page { + return configPage.page +} + +// Creates the content for the APCUPSD settings page +func (configPage *AddonApcupsdPage) createContent() { + + // Create the layout + configPage.layout = newStandardLayout() + configPage.layout.createForm(&configPage.masterConfig.Smartnode.Network, fmt.Sprintf("%s Settings", configPage.addon.GetName())) + + // Return to the home page after pressing Escape + configPage.layout.form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyEsc { + // Close all dropdowns and break if one was open + for _, param := range configPage.layout.parameters { + dropDown, ok := param.item.(*DropDown) + if ok && dropDown.open { + dropDown.CloseList(configPage.addonsPage.home.md.app) + return nil + } + } + + // Return to the home page + configPage.addonsPage.home.md.setPage(configPage.addonsPage.page) + return nil + } + return event + }) + + // Get the parameters + enabledParam := configPage.addon.GetEnabledParameter() + // TODO: Don't like how I reference these by index here. Is there a better way? + modeParam := configPage.addon.GetConfig().GetParameters()[1] + exporterImageParam := configPage.addon.GetConfig().GetParameters()[2] + apcupsdImageParam := configPage.addon.GetConfig().GetParameters()[3] + metricsPortParam := configPage.addon.GetConfig().GetParameters()[4] + mountPointParam := configPage.addon.GetConfig().GetParameters()[5] + networkAddress := configPage.addon.GetConfig().GetParameters()[6] + + // Set up the form items + configPage.enabledBox = createParameterizedCheckbox(enabledParam) + configPage.modeBox = createParameterizedDropDown(modeParam, configPage.layout.descriptionBox) + configPage.exporterImage = createParameterizedStringField(exporterImageParam) + configPage.apcupsdImage = createParameterizedStringField(apcupsdImageParam) + configPage.metricsPort = createParameterizedStringField(metricsPortParam) + configPage.mountPoint = createParameterizedStringField(mountPointParam) + configPage.apcupsdAddress = createParameterizedStringField(networkAddress) + + // Map the parameters to the form items in the layout + configPage.layout.mapParameterizedFormItems(configPage.enabledBox) + configPage.layout.mapParameterizedFormItems(configPage.modeBox) + configPage.layout.mapParameterizedFormItems(configPage.exporterImage) + configPage.layout.mapParameterizedFormItems(configPage.apcupsdImage) + configPage.layout.mapParameterizedFormItems(configPage.metricsPort) + configPage.layout.mapParameterizedFormItems(configPage.mountPoint) + configPage.layout.mapParameterizedFormItems(configPage.apcupsdAddress) + + // Set up the setting callbacks + configPage.enabledBox.item.(*tview.Checkbox).SetChangedFunc(func(checked bool) { + if enabledParam.Value == checked { + return + } + enabledParam.Value = checked + configPage.handleEnableChanged() + }) + configPage.modeBox.item.(*DropDown).SetSelectedFunc(func(text string, index int) { + if configPage.modeBox.parameter.Value == configPage.modeBox.parameter.Options[index].Value { + return + } + configPage.modeBox.parameter.Value = configPage.modeBox.parameter.Options[index].Value + configPage.handleModeChanged() + }) + + // Do the initial draw + configPage.handleDraw() + +} + +// Handle all of the form changes when the Enabled box has changed +func (configPage *AddonApcupsdPage) handleEnableChanged() { + configPage.handleDraw() +} + +// Handle all of the form changes when the Mode box has changed +func (configPage *AddonApcupsdPage) handleModeChanged() { + configPage.handleDraw() +} + +func (configPage *AddonApcupsdPage) handleDraw() { + configPage.layout.form.Clear(true) + configPage.layout.form.AddFormItem(configPage.enabledBox.item) + + // Only add the supporting stuff if addon is enabled + if configPage.addon.GetEnabledParameter().Value == false { + return + } + configPage.addCommonFields() + if configPage.modeBox.parameter.Value == configPage.modeBox.parameter.Options[0].Value { + configPage.addContainerFields() + } else { + configPage.addNetworkFields() + } + configPage.layout.refresh() +} + +func (configPage *AddonApcupsdPage) addCommonFields() { + configPage.layout.form.AddFormItem(configPage.modeBox.item) + configPage.layout.form.AddFormItem(configPage.exporterImage.item) + configPage.layout.form.AddFormItem(configPage.metricsPort.item) +} +func (configPage *AddonApcupsdPage) addContainerFields() { + configPage.layout.form.AddFormItem(configPage.apcupsdImage.item) + configPage.layout.form.AddFormItem(configPage.mountPoint.item) +} + +func (configPage *AddonApcupsdPage) addNetworkFields() { + configPage.layout.form.AddFormItem(configPage.apcupsdAddress.item) +} + +// Handle a bulk redraw request +func (configPage *AddonApcupsdPage) handleLayoutChanged() { + configPage.handleEnableChanged() +} diff --git a/rocketpool-cli/service/config/addons.go b/rocketpool-cli/service/config/addons.go index 0272cc20c..f26dae995 100644 --- a/rocketpool-cli/service/config/addons.go +++ b/rocketpool-cli/service/config/addons.go @@ -17,6 +17,7 @@ type AddonsPage struct { masterConfig *config.RocketPoolConfig gwwPage *AddonGwwPage gwwButton *parameterizedFormItem + apcupsdPage *AddonApcupsdPage categoryList *tview.List addonSubpages []settingsPage content tview.Primitive @@ -39,9 +40,17 @@ func NewAddonsPage(home *settingsHome) *AddonsPage { // Create the addon subpages addonsPage.gwwPage = NewAddonGwwPage(addonsPage, home.md.Config.GraffitiWallWriter) + addonSubpages := []settingsPage{ addonsPage.gwwPage, } + + // TODO: Make this respond to uncommitted config changes + if home.md.Config.EnableMetrics.Value == true { + addonsPage.apcupsdPage = NewAddonApcupsdPage(addonsPage, home.md.Config.Apcupsd) + addonSubpages = append(addonSubpages, addonsPage.apcupsdPage) + } + addonsPage.addonSubpages = addonSubpages // Add the subpages to the main display diff --git a/shared/services/config/rocket-pool-config.go b/shared/services/config/rocket-pool-config.go index 50596c4e0..b4c5f62de 100644 --- a/shared/services/config/rocket-pool-config.go +++ b/shared/services/config/rocket-pool-config.go @@ -123,6 +123,7 @@ type RocketPoolConfig struct { // Addons GraffitiWallWriter addontypes.SmartnodeAddon `yaml:"addon-gww,omitempty"` + Apcupsd addontypes.SmartnodeAddon `yaml:"addon-apcupsd,omitempty"` } // Load configuration settings from a file @@ -469,6 +470,7 @@ func NewRocketPoolConfig(rpDir string, isNativeMode bool) *RocketPoolConfig { // Addons cfg.GraffitiWallWriter = addons.NewGraffitiWallWriter() + cfg.Apcupsd = addons.NewApcupsd() // Apply the default values for mainnet cfg.Smartnode.Network.Value = cfg.Smartnode.Network.Options[0].Value @@ -570,6 +572,7 @@ func (cfg *RocketPoolConfig) GetSubconfigs() map[string]config.Config { "native": cfg.Native, "mevBoost": cfg.MevBoost, "addons-gww": cfg.GraffitiWallWriter.GetConfig(), + "addons-apcupsd": cfg.Apcupsd.GetConfig(), } } @@ -1009,6 +1012,7 @@ func (cfg *RocketPoolConfig) GenerateEnvironmentVariables() map[string]string { // Addons cfg.GraffitiWallWriter.UpdateEnvVars(envVars) + cfg.Apcupsd.UpdateEnvVars(envVars) return envVars diff --git a/shared/services/rocketpool/client.go b/shared/services/rocketpool/client.go index 37ad4b93e..d980e6bd4 100644 --- a/shared/services/rocketpool/client.go +++ b/shared/services/rocketpool/client.go @@ -25,6 +25,7 @@ import ( "github.com/blang/semver/v4" externalip "github.com/glendc/go-external-ip" "github.com/mitchellh/go-homedir" + "github.com/rocket-pool/smartnode/addons/apcupsd" "github.com/rocket-pool/smartnode/addons/graffiti_wall_writer" "github.com/rocket-pool/smartnode/shared/services/config" cfgtypes "github.com/rocket-pool/smartnode/shared/types/config" @@ -1635,6 +1636,63 @@ func (c *Client) composeAddons(cfg *config.RocketPoolConfig, rocketpoolDir strin deployedContainers = append(deployedContainers, filepath.Join(overrideFolder, graffiti_wall_writer.GraffitiWallWriterContainerName+composeFileSuffix)) } + // APCUPSD + if cfg.EnableMetrics.Value == true && cfg.Apcupsd.GetEnabledParameter().Value == true { + runtimeFolder := filepath.Join(rocketpoolDir, runtimeDir, "addons", "apcupsd") + templatesFolder := filepath.Join(rocketpoolDir, templatesDir, "addons", "apcupsd") + overrideFolder := filepath.Join(rocketpoolDir, overrideDir, "addons", "apcupsd") + + // Make the addon folder + err := os.MkdirAll(runtimeFolder, 0775) + if err != nil { + return []string{}, fmt.Errorf("error creating addon runtime folder (%s): %w", runtimeFolder, err) + } + + // Write container file + apcupsdMode := cfg.Apcupsd.GetConfig().GetParameters()[1] + + var containerContents []byte + + if apcupsdMode.Value == apcupsd.Mode_Network { + + containerContents, err = envsubst.ReadFile(filepath.Join(templatesFolder, apcupsd.ApcupsdNetworkComposeTemplateName+templateSuffix)) + if err != nil { + return []string{}, fmt.Errorf("error reading and substituting APCUPSD addon container template: %w", err) + } + + } else if apcupsdMode.Value == apcupsd.Mode_Container { + + containerContents, err = envsubst.ReadFile(filepath.Join(templatesFolder, apcupsd.ApcupsdContainerComposeTemplateName+templateSuffix)) + if err != nil { + return []string{}, fmt.Errorf("error reading and substituting APCUPSD addon container template: %w", err) + } + + } + + containerPath := filepath.Join(runtimeFolder, apcupsd.ApcupsdContainerName+composeFileSuffix) + err = os.WriteFile(containerPath, containerContents, 0664) + if err != nil { + return []string{}, fmt.Errorf("could not write APCUPSD addon container file to %s: %w", containerPath, err) + } + + deployedContainers = append(deployedContainers, containerPath) + deployedContainers = append(deployedContainers, filepath.Join(overrideFolder, apcupsd.ApcupsdContainerName+composeFileSuffix)) + + // Write config file + if apcupsdMode.Value == apcupsd.Mode_Container { + // TODO: Confirm this is a good location for a generic config file (i.e. not a container file) + configContents, err := envsubst.ReadFile(filepath.Join(templatesFolder, apcupsd.ApcupsdConfigTemplateName+templateSuffix)) + if err != nil { + return []string{}, fmt.Errorf("error reading and substituting APCUPSD addon config template: %w", err) + } + configPath := filepath.Join(rocketpoolDir, apcupsd.ApcupsdConfigName) + err = os.WriteFile(configPath, configContents, 0664) + if err != nil { + return []string{}, fmt.Errorf("could not write APCUPSD addon config file to %s: %w", configPath, err) + } + } + } + return deployedContainers, nil }