talos/internal/pkg/dashboard/networkconfig.go
Utku Ozdemir 7967ccfc13
feat: add config code entry screen to dashboard
Implement a screen for entering/managing the config `${code}` variable.

Enable this screen only when the platform is `metal` and there is a `${code}` variable in the `talos.config` kernel cmdline URL query.

Additionally, remove the "Delete" button and its functionality from the network config screen to avoid users accidentally deleting PlatformNetworkConfig parts that are not managed by the dashboard.

Add some tests for the form data parsing on the network config screen.
Remove the unnecessary lock on the summary tab - all updates come from the same goroutine.

Closes siderolabs/talos#6993.

Signed-off-by: Utku Ozdemir <utku.ozdemir@siderolabs.com>
2023-03-31 10:33:28 +02:00

447 lines
12 KiB
Go

// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
package dashboard
import (
"context"
"fmt"
"sort"
"strings"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
"github.com/siderolabs/gen/maps"
"github.com/siderolabs/go-pointer"
"gopkg.in/yaml.v3"
"github.com/siderolabs/talos/internal/app/machined/pkg/runtime"
"github.com/siderolabs/talos/internal/pkg/dashboard/resourcedata"
"github.com/siderolabs/talos/internal/pkg/meta"
"github.com/siderolabs/talos/pkg/machinery/resources/network"
runtimeres "github.com/siderolabs/talos/pkg/machinery/resources/runtime"
)
const (
formItemHostname = "Hostname"
formItemDNSServers = "DNS Servers"
formItemTimeServers = "Time Servers"
formItemInterface = "Interface"
formItemMode = "Mode"
formItemAddresses = "Addresses"
formItemGateway = "Gateway"
)
type networkConfigData struct {
existingConfig *runtime.PlatformNetworkConfig
newConfig *runtime.PlatformNetworkConfig
newConfigError error
linkSet map[string]struct{}
}
// NetworkConfigGrid represents the network configuration widget.
type NetworkConfigGrid struct {
tview.Grid
dashboard *Dashboard
configForm *tview.Form
hostnameField *tview.InputField
dnsServersField *tview.InputField
timeServersField *tview.InputField
interfaceDropdown *tview.DropDown
modeDropdown *tview.DropDown
addressesField *tview.InputField
gatewayField *tview.InputField
infoView *tview.TextView
existingConfigView *tview.TextView
newConfigView *tview.TextView
selectedNode string
nodeMap map[string]*networkConfigData
}
// NewNetworkConfigGrid initializes NetworkConfigGrid.
//
//nolint:gocyclo
func NewNetworkConfigGrid(ctx context.Context, dashboard *Dashboard) *NetworkConfigGrid {
widget := &NetworkConfigGrid{
Grid: *tview.NewGrid(),
configForm: tview.NewForm(),
infoView: tview.NewTextView(),
existingConfigView: tview.NewTextView(),
newConfigView: tview.NewTextView(),
nodeMap: make(map[string]*networkConfigData),
dashboard: dashboard,
}
widget.configForm.SetBorder(true).SetTitle("Configure (Ctrl+Q)")
widget.SetRows(0, 3).SetColumns(0, 0, 0)
widget.infoView.
SetDynamicColors(true).
SetScrollable(true).
SetWrap(true)
widget.existingConfigView.
SetDynamicColors(true).
SetScrollable(true).
SetBorderPadding(0, 0, 1, 0).
SetBorder(true).
SetTitle("Existing Config (Ctrl+W)")
widget.newConfigView.
SetDynamicColors(true).
SetScrollable(true).
SetBorderPadding(0, 0, 1, 0).
SetBorder(true).
SetTitle("New Config (Ctrl+E)")
widget.AddItem(widget.configForm, 0, 0, 1, 1, 0, 0, false)
widget.AddItem(widget.infoView, 1, 0, 1, 1, 0, 0, false)
widget.AddItem(widget.existingConfigView, 0, 1, 2, 1, 0, 0, false)
widget.AddItem(widget.newConfigView, 0, 2, 2, 1, 0, 0, false)
widget.hostnameField = tview.NewInputField().SetLabel(formItemHostname)
widget.hostnameField.SetBlurFunc(widget.formEdited)
widget.dnsServersField = tview.NewInputField().SetLabel(formItemDNSServers)
widget.dnsServersField.SetBlurFunc(widget.formEdited)
widget.timeServersField = tview.NewInputField().SetLabel(formItemTimeServers)
widget.timeServersField.SetBlurFunc(widget.formEdited)
widget.interfaceDropdown = tview.NewDropDown().SetLabel(formItemInterface)
widget.interfaceDropdown.SetBlurFunc(widget.formEdited)
widget.interfaceDropdown.SetOptions([]string{interfaceNone}, func(_ string, _ int) {
widget.formEdited()
})
widget.modeDropdown = tview.NewDropDown().SetLabel(formItemMode)
widget.modeDropdown.SetBlurFunc(widget.formEdited)
widget.modeDropdown.SetOptions([]string{ModeDHCP, ModeStatic}, func(_ string, _ int) {
widget.formEdited()
})
widget.addressesField = tview.NewInputField().SetLabel(formItemAddresses)
widget.addressesField.SetBlurFunc(widget.formEdited)
widget.gatewayField = tview.NewInputField().SetLabel(formItemGateway)
widget.gatewayField.SetBlurFunc(widget.formEdited)
widget.configForm.AddFormItem(widget.hostnameField)
widget.configForm.AddFormItem(widget.dnsServersField)
widget.configForm.AddFormItem(widget.timeServersField)
widget.configForm.AddFormItem(widget.interfaceDropdown)
widget.configForm.AddFormItem(widget.modeDropdown)
widget.configForm.AddFormItem(widget.addressesField)
widget.configForm.AddFormItem(widget.gatewayField)
widget.configForm.AddButton("Save", func() {
widget.save(ctx)
})
saveButton := widget.configForm.GetButton(0)
saveButton.SetBlurFunc(widget.formEdited)
inputCapture := func(event *tcell.EventKey) *tcell.EventKey {
if widget.handleFocusSwitch(event) {
return nil
}
return event
}
widget.configForm.SetInputCapture(inputCapture)
widget.existingConfigView.SetInputCapture(inputCapture)
widget.newConfigView.SetInputCapture(inputCapture)
widget.interfaceDropdown.SetCurrentOption(0)
widget.modeDropdown.SetCurrentOption(0)
return widget
}
// OnNodeSelect implements the NodeSelectListener interface.
func (widget *NetworkConfigGrid) OnNodeSelect(node string) {
if node != widget.selectedNode {
widget.selectedNode = node
widget.clearForm()
widget.formEdited()
widget.redraw()
}
}
// OnResourceDataChange implements the ResourceDataListener interface.
func (widget *NetworkConfigGrid) OnResourceDataChange(data resourcedata.Data) {
widget.updateNodeData(data)
if data.Node == widget.selectedNode {
widget.redraw()
}
}
//nolint:gocyclo
func (widget *NetworkConfigGrid) formEdited() {
widget.infoView.SetText("")
resetInputField := func(field *tview.InputField) {
// avoid triggering another form edit if there is nothing to change
if field.GetText() != "" {
field.SetText("")
}
}
resetDropdown := func(dropdown *tview.DropDown) {
// avoid triggering another form edit if there is nothing to change
if currentIndex, _ := dropdown.GetCurrentOption(); currentIndex != 0 {
dropdown.SetCurrentOption(0)
}
}
_, currentInterface := widget.interfaceDropdown.GetCurrentOption()
_, currentMode := widget.modeDropdown.GetCurrentOption()
ifaceSelected := currentInterface != "" && currentInterface != interfaceNone
if ifaceSelected {
if itemIndex := widget.configForm.GetFormItemIndex(formItemMode); itemIndex == -1 {
widget.configForm.AddFormItem(widget.modeDropdown)
}
switch currentMode {
case ModeDHCP:
resetInputField(widget.addressesField)
resetInputField(widget.gatewayField)
if itemIndex := widget.configForm.GetFormItemIndex(formItemAddresses); itemIndex != -1 {
widget.configForm.RemoveFormItem(itemIndex)
}
if itemIndex := widget.configForm.GetFormItemIndex(formItemGateway); itemIndex != -1 {
widget.configForm.RemoveFormItem(itemIndex)
}
case ModeStatic:
if itemIndex := widget.configForm.GetFormItemIndex(formItemAddresses); itemIndex == -1 {
widget.configForm.AddFormItem(widget.addressesField)
}
if itemIndex := widget.configForm.GetFormItemIndex(formItemGateway); itemIndex == -1 {
widget.configForm.AddFormItem(widget.gatewayField)
}
}
} else {
resetDropdown(widget.modeDropdown)
resetInputField(widget.addressesField)
resetInputField(widget.gatewayField)
if itemIndex := widget.configForm.GetFormItemIndex(formItemMode); itemIndex != -1 {
widget.configForm.RemoveFormItem(itemIndex)
}
if itemIndex := widget.configForm.GetFormItemIndex(formItemAddresses); itemIndex != -1 {
widget.configForm.RemoveFormItem(itemIndex)
}
if itemIndex := widget.configForm.GetFormItemIndex(formItemGateway); itemIndex != -1 {
widget.configForm.RemoveFormItem(itemIndex)
}
}
data := widget.getOrCreateNodeData(widget.selectedNode)
formData := NetworkConfigFormData{
Base: pointer.SafeDeref(data.existingConfig),
Hostname: widget.hostnameField.GetText(),
DNSServers: widget.dnsServersField.GetText(),
TimeServers: widget.timeServersField.GetText(),
Iface: currentInterface,
Mode: currentMode,
Addresses: widget.addressesField.GetText(),
Gateway: widget.gatewayField.GetText(),
}
config, err := formData.ToPlatformNetworkConfig()
if err != nil {
data.newConfig = nil
data.newConfigError = err
} else {
data.newConfig = config
data.newConfigError = nil
}
widget.redraw()
}
func (widget *NetworkConfigGrid) redraw() {
data := widget.getOrCreateNodeData(widget.selectedNode)
if data.existingConfig != nil {
var buf strings.Builder
encoder := yaml.NewEncoder(&buf)
encoder.SetIndent(2)
err := encoder.Encode(data.existingConfig)
if err != nil {
widget.existingConfigView.SetText(fmt.Sprintf("[red]error: %v[-]", err))
}
widget.existingConfigView.SetText(fmt.Sprintf("[lightblue]%s[-]", tview.Escape(buf.String())))
} else {
widget.existingConfigView.SetText("[gray]No Config[-]")
}
if data.newConfigError != nil {
widget.newConfigView.SetText(fmt.Sprintf("[red]error: %v[-]", data.newConfigError))
} else if data.newConfig != nil {
var buf strings.Builder
encoder := yaml.NewEncoder(&buf)
encoder.SetIndent(2)
err := encoder.Encode(data.newConfig)
if err != nil {
widget.newConfigView.SetText(fmt.Sprintf("[red]error: %v[-]", err))
}
widget.newConfigView.SetText(fmt.Sprintf("[green]%s[-]", tview.Escape(buf.String())))
}
}
func (widget *NetworkConfigGrid) clearForm() {
widget.hostnameField.SetText("")
widget.dnsServersField.SetText("")
widget.timeServersField.SetText("")
widget.interfaceDropdown.SetCurrentOption(0)
widget.modeDropdown.SetCurrentOption(0)
widget.addressesField.SetText("")
widget.gatewayField.SetText("")
widget.infoView.SetText("")
widget.configForm.SetFocus(0)
widget.formEdited()
}
func (widget *NetworkConfigGrid) updateNodeData(data resourcedata.Data) {
nodeData := widget.getOrCreateNodeData(data.Node)
//nolint:gocritic
switch res := data.Resource.(type) {
case *network.LinkStatus:
if data.Deleted {
delete(nodeData.linkSet, res.Metadata().ID())
} else {
if !res.TypedSpec().Physical() {
return
}
nodeData.linkSet[res.Metadata().ID()] = struct{}{}
}
links := maps.Keys(nodeData.linkSet)
sort.Strings(links)
allLinks := append([]string{interfaceNone}, links...)
widget.interfaceDropdown.SetOptions(allLinks, func(_ string, _ int) {
widget.formEdited()
})
case *runtimeres.MetaKey:
if res.Metadata().ID() == runtimeres.MetaKeyTagToID(meta.MetalNetworkPlatformConfig) {
if data.Deleted {
nodeData.existingConfig = nil
} else {
cfg := runtime.PlatformNetworkConfig{}
if err := yaml.Unmarshal([]byte(res.TypedSpec().Value), &cfg); err != nil {
widget.existingConfigView.SetText(fmt.Sprintf("[red]error: %v[-]", err))
return
}
nodeData.existingConfig = &cfg
widget.formEdited()
}
}
}
}
func (widget *NetworkConfigGrid) getOrCreateNodeData(node string) *networkConfigData {
nodeData, ok := widget.nodeMap[node]
if !ok {
nodeData = &networkConfigData{
linkSet: make(map[string]struct{}),
}
widget.nodeMap[node] = nodeData
}
return nodeData
}
// OnScreenSelect implements the screenSelectListener interface.
func (widget *NetworkConfigGrid) onScreenSelect(active bool) {
if active {
widget.dashboard.app.SetFocus(widget.configForm)
}
}
func (widget *NetworkConfigGrid) save(ctx context.Context) {
nodeData := widget.getOrCreateNodeData(widget.selectedNode)
if nodeData.newConfig == nil {
widget.infoView.SetText("[red]Error: nothing to save[-]")
return
}
if nodeData.newConfigError != nil {
widget.infoView.SetText("[red]Error: cannot save, fix the errors and try again[-]")
return
}
configBytes, err := yaml.Marshal(nodeData.newConfig)
if err != nil {
widget.infoView.SetText(fmt.Sprintf("[red]Error: %v[-]", err))
return
}
ctx = nodeContext(ctx, widget.selectedNode)
if err = widget.dashboard.cli.MetaWrite(ctx, meta.MetalNetworkPlatformConfig, configBytes); err != nil {
widget.infoView.SetText(fmt.Sprintf("[red]Error: %v[-]", err))
return
}
widget.infoView.SetText("[green]Network config saved successfully[-]")
widget.clearForm()
widget.dashboard.selectScreen(ScreenSummary)
}
func (widget *NetworkConfigGrid) handleFocusSwitch(event *tcell.EventKey) bool {
switch event.Key() { //nolint:exhaustive
case tcell.KeyCtrlQ:
widget.dashboard.app.SetFocus(widget.configForm)
return true
case tcell.KeyCtrlW:
widget.dashboard.app.SetFocus(widget.existingConfigView)
return true
case tcell.KeyCtrlE:
widget.dashboard.app.SetFocus(widget.newConfigView)
return true
default:
return false
}
}