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>
This commit is contained in:
Utku Ozdemir 2023-03-30 17:07:30 +02:00
parent ddb014cfdc
commit 7967ccfc13
No known key found for this signature in database
GPG Key ID: 65933E76F0549B0D
15 changed files with 573 additions and 140 deletions

View File

@ -9,12 +9,15 @@ import (
"context" "context"
"fmt" "fmt"
"log" "log"
"net/url"
"github.com/siderolabs/go-procfs/procfs"
"google.golang.org/grpc" "google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/metadata" "google.golang.org/grpc/metadata"
"github.com/siderolabs/talos/internal/app/machined/pkg/runtime/v1alpha1/platform" "github.com/siderolabs/talos/internal/app/machined/pkg/runtime/v1alpha1/platform"
metalurl "github.com/siderolabs/talos/internal/app/machined/pkg/runtime/v1alpha1/platform/metal/url"
"github.com/siderolabs/talos/internal/pkg/dashboard" "github.com/siderolabs/talos/internal/pkg/dashboard"
"github.com/siderolabs/talos/pkg/grpc/middleware/authz" "github.com/siderolabs/talos/pkg/grpc/middleware/authz"
"github.com/siderolabs/talos/pkg/machinery/client" "github.com/siderolabs/talos/pkg/machinery/client"
@ -51,7 +54,30 @@ func dashboardMain() error {
currentPlatform, _ := platform.CurrentPlatform() //nolint:errcheck currentPlatform, _ := platform.CurrentPlatform() //nolint:errcheck
if currentPlatform != nil && currentPlatform.Name() == constants.PlatformMetal { if currentPlatform != nil && currentPlatform.Name() == constants.PlatformMetal {
screens = append(screens, dashboard.ScreenNetworkConfig) screens = append(screens, dashboard.ScreenNetworkConfig)
if showConfigURLTab() {
screens = append(screens, dashboard.ScreenConfigURL)
}
} }
return dashboard.Run(adminCtx, c, dashboard.WithAllowExitKeys(false), dashboard.WithScreens(screens...)) return dashboard.Run(adminCtx, c, dashboard.WithAllowExitKeys(false), dashboard.WithScreens(screens...))
} }
func showConfigURLTab() bool {
option := procfs.ProcCmdline().Get(constants.KernelParamConfig).First()
if option == nil {
return false
}
parsedURL, err := url.Parse(*option)
if err != nil {
return false
}
codeVar := metalurl.AllVariables()[constants.CodeKey]
if codeVar == nil {
return false
}
return codeVar.Matches(parsedURL.Query())
}

View File

@ -25,7 +25,7 @@ import (
"github.com/siderolabs/talos/internal/app/machined/pkg/runtime" "github.com/siderolabs/talos/internal/app/machined/pkg/runtime"
"github.com/siderolabs/talos/internal/app/machined/pkg/runtime/v1alpha1/platform/errors" "github.com/siderolabs/talos/internal/app/machined/pkg/runtime/v1alpha1/platform/errors"
"github.com/siderolabs/talos/internal/app/machined/pkg/runtime/v1alpha1/platform/internal/netutils" "github.com/siderolabs/talos/internal/app/machined/pkg/runtime/v1alpha1/platform/internal/netutils"
"github.com/siderolabs/talos/internal/app/machined/pkg/runtime/v1alpha1/platform/metal/internal/url" "github.com/siderolabs/talos/internal/app/machined/pkg/runtime/v1alpha1/platform/metal/url"
"github.com/siderolabs/talos/internal/pkg/meta" "github.com/siderolabs/talos/internal/pkg/meta"
"github.com/siderolabs/talos/pkg/download" "github.com/siderolabs/talos/pkg/download"
"github.com/siderolabs/talos/pkg/machinery/constants" "github.com/siderolabs/talos/pkg/machinery/constants"

View File

@ -18,7 +18,7 @@ import (
// Populate populates the config download URL with values replacing variables. // Populate populates the config download URL with values replacing variables.
func Populate(ctx context.Context, downloadURL string, st state.State) (string, error) { func Populate(ctx context.Context, downloadURL string, st state.State) (string, error) {
return PopulateVariables(ctx, downloadURL, st, AllVariables()) return PopulateVariables(ctx, downloadURL, st, maps.Values(AllVariables()))
} }
// PopulateVariables populates the config download URL with values replacing variables. // PopulateVariables populates the config download URL with values replacing variables.

View File

@ -17,7 +17,7 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/siderolabs/talos/internal/app/machined/pkg/runtime/v1alpha1/platform/metal/internal/url" "github.com/siderolabs/talos/internal/app/machined/pkg/runtime/v1alpha1/platform/metal/url"
"github.com/siderolabs/talos/internal/pkg/meta" "github.com/siderolabs/talos/internal/pkg/meta"
"github.com/siderolabs/talos/pkg/machinery/nethelpers" "github.com/siderolabs/talos/pkg/machinery/nethelpers"
"github.com/siderolabs/talos/pkg/machinery/resources/hardware" "github.com/siderolabs/talos/pkg/machinery/resources/hardware"

View File

@ -28,27 +28,27 @@ type Variable struct {
r *regexp.Regexp r *regexp.Regexp
} }
// AllVariables is a list of all supported variables. // AllVariables is a map of all supported variables.
func AllVariables() []*Variable { func AllVariables() map[string]*Variable {
return []*Variable{ return map[string]*Variable{
{ constants.UUIDKey: {
Key: constants.UUIDKey, Key: constants.UUIDKey,
MatchOnArg: true, MatchOnArg: true,
Value: UUIDValue(), Value: UUIDValue(),
}, },
{ constants.SerialNumberKey: {
Key: constants.SerialNumberKey, Key: constants.SerialNumberKey,
Value: SerialNumberValue(), Value: SerialNumberValue(),
}, },
{ constants.MacKey: {
Key: constants.MacKey, Key: constants.MacKey,
Value: MACValue(), Value: MACValue(),
}, },
{ constants.HostnameKey: {
Key: constants.HostnameKey, Key: constants.HostnameKey,
Value: HostnameValue(), Value: HostnameValue(),
}, },
{ constants.CodeKey: {
Key: constants.CodeKey, Key: constants.CodeKey,
Value: CodeValue(), Value: CodeValue(),
}, },

View File

@ -13,7 +13,7 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/siderolabs/talos/internal/app/machined/pkg/runtime/v1alpha1/platform/metal/internal/url" "github.com/siderolabs/talos/internal/app/machined/pkg/runtime/v1alpha1/platform/metal/url"
"github.com/siderolabs/talos/pkg/machinery/constants" "github.com/siderolabs/talos/pkg/machinery/constants"
) )

View File

@ -0,0 +1,184 @@
// 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"
"github.com/rivo/tview"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"github.com/siderolabs/talos/internal/pkg/dashboard/resourcedata"
"github.com/siderolabs/talos/internal/pkg/meta"
runtimeres "github.com/siderolabs/talos/pkg/machinery/resources/runtime"
)
const unset = "[gray](unset)[-]"
type configURLData struct {
existingCode string
}
// ConfigURLGrid represents the config URL grid.
type ConfigURLGrid struct {
tview.Grid
dashboard *Dashboard
form *tview.Form
existingCode *tview.TextView
newCodeField *tview.InputField
infoView *tview.TextView
selectedNode string
nodeMap map[string]*configURLData
}
// NewConfigURLGrid returns a new config URL grid.
func NewConfigURLGrid(ctx context.Context, dashboard *Dashboard) *ConfigURLGrid {
grid := &ConfigURLGrid{
Grid: *tview.NewGrid(),
dashboard: dashboard,
form: tview.NewForm(),
existingCode: tview.NewTextView().SetDynamicColors(true).SetLabel("Existing Code").SetText(unset).SetSize(1, 0).SetScrollable(false),
newCodeField: tview.NewInputField().SetLabel("New Code"),
infoView: tview.NewTextView().SetDynamicColors(true).SetSize(2, 0).SetScrollable(false),
nodeMap: make(map[string]*configURLData),
}
grid.SetRows(-1, 12, -1).SetColumns(-1, 48, -1)
grid.form.SetBorder(true)
grid.form.AddFormItem(grid.existingCode)
grid.form.AddFormItem(grid.newCodeField)
grid.form.AddButton("Save", func() {
ctx = nodeContext(ctx, grid.selectedNode)
value := grid.newCodeField.GetText()
if value == "" {
grid.infoView.SetText("[red]Error: No code entered[-]")
return
}
err := dashboard.cli.MetaWrite(ctx, meta.DownloadURLCode, []byte(value))
if err != nil {
grid.infoView.SetText(fmt.Sprintf("[red]Error: %v[-]", err))
return
}
grid.clearForm()
grid.infoView.SetText("[green]Saved successfully[-]")
grid.dashboard.selectScreen(ScreenSummary)
})
grid.form.AddButton("Delete", func() {
ctx = nodeContext(ctx, grid.selectedNode)
err := dashboard.cli.MetaDelete(ctx, meta.DownloadURLCode)
if err != nil {
if status.Code(err) == codes.NotFound {
grid.clearForm()
grid.infoView.SetText("[green]Already deleted[-]")
return
}
grid.infoView.SetText(fmt.Sprintf("[red]Error: %v[-]", err))
return
}
grid.clearForm()
grid.infoView.SetText("[green]Deleted successfully[-]")
})
grid.form.AddFormItem(grid.infoView)
grid.AddItem(tview.NewBox(), 0, 0, 1, 3, 0, 0, false)
grid.AddItem(tview.NewBox(), 1, 0, 1, 1, 0, 0, false)
grid.AddItem(grid.form, 1, 1, 1, 1, 0, 0, false)
grid.AddItem(tview.NewBox(), 1, 2, 1, 1, 0, 0, false)
grid.AddItem(tview.NewBox(), 2, 0, 1, 3, 0, 0, false)
return grid
}
// OnScreenSelect implements the screenSelectListener interface.
func (widget *ConfigURLGrid) onScreenSelect(active bool) {
if active {
widget.dashboard.app.SetFocus(widget.form)
}
}
// OnNodeSelect implements the NodeSelectListener interface.
func (widget *ConfigURLGrid) OnNodeSelect(node string) {
if node != widget.selectedNode {
widget.selectedNode = node
widget.clearForm()
widget.redraw()
}
}
// OnResourceDataChange implements the ResourceDataListener interface.
func (widget *ConfigURLGrid) OnResourceDataChange(data resourcedata.Data) {
widget.updateNodeData(data)
if data.Node == widget.selectedNode {
widget.redraw()
}
}
func (widget *ConfigURLGrid) updateNodeData(data resourcedata.Data) {
nodeData := widget.getOrCreateNodeData(data.Node)
//nolint:gocritic
switch res := data.Resource.(type) {
case *runtimeres.MetaKey:
if res.Metadata().ID() == runtimeres.MetaKeyTagToID(meta.DownloadURLCode) {
if data.Deleted {
nodeData.existingCode = unset
} else {
val := res.TypedSpec().Value
if val == "" {
val = "(empty)"
}
nodeData.existingCode = fmt.Sprintf("[blue]%s[-]", val)
}
}
}
}
func (widget *ConfigURLGrid) redraw() {
data := widget.getOrCreateNodeData(widget.selectedNode)
widget.existingCode.SetText(data.existingCode)
}
func (widget *ConfigURLGrid) getOrCreateNodeData(node string) *configURLData {
nodeData, ok := widget.nodeMap[node]
if !ok {
nodeData = &configURLData{
existingCode: unset,
}
widget.nodeMap[node] = nodeData
}
return nodeData
}
func (widget *ConfigURLGrid) clearForm() {
widget.form.SetFocus(0)
widget.infoView.SetText("")
widget.newCodeField.SetText("")
}

View File

@ -0,0 +1,28 @@
// 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"
"google.golang.org/grpc/metadata"
"github.com/siderolabs/talos/pkg/machinery/client"
)
func nodeContext(ctx context.Context, selectedNode string) context.Context {
md, mdOk := metadata.FromOutgoingContext(ctx)
if mdOk {
md.Delete("nodes")
ctx = metadata.NewOutgoingContext(ctx, md)
}
if selectedNode != "" {
ctx = client.WithNode(ctx, selectedNode)
}
return ctx
}

View File

@ -48,6 +48,9 @@ const (
// ScreenNetworkConfig is the network configuration screen. // ScreenNetworkConfig is the network configuration screen.
ScreenNetworkConfig Screen = "Network Config" ScreenNetworkConfig Screen = "Network Config"
// ScreenConfigURL is the config URL screen.
ScreenConfigURL Screen = "Config URL"
) )
// APIDataListener is a listener which is notified when API-sourced data is updated. // APIDataListener is a listener which is notified when API-sourced data is updated.
@ -114,6 +117,7 @@ type Dashboard struct {
summaryGrid *SummaryGrid summaryGrid *SummaryGrid
monitorGrid *MonitorGrid monitorGrid *MonitorGrid
networkConfigGrid *NetworkConfigGrid networkConfigGrid *NetworkConfigGrid
configURLGrid *ConfigURLGrid
selectedScreenConfig *screenConfig selectedScreenConfig *screenConfig
screenConfigs []screenConfig screenConfigs []screenConfig
@ -156,6 +160,7 @@ func buildDashboard(ctx context.Context, cli *client.Client, opts ...Option) (*D
dashboard.summaryGrid = NewSummaryGrid(dashboard.app) dashboard.summaryGrid = NewSummaryGrid(dashboard.app)
dashboard.monitorGrid = NewMonitorGrid(dashboard.app) dashboard.monitorGrid = NewMonitorGrid(dashboard.app)
dashboard.networkConfigGrid = NewNetworkConfigGrid(ctx, dashboard) dashboard.networkConfigGrid = NewNetworkConfigGrid(ctx, dashboard)
dashboard.configURLGrid = NewConfigURLGrid(ctx, dashboard)
err := dashboard.initScreenConfigs(defOptions.screens) err := dashboard.initScreenConfigs(defOptions.screens)
if err != nil { if err != nil {
@ -213,6 +218,7 @@ func buildDashboard(ctx context.Context, cli *client.Client, opts ...Option) (*D
header, header,
dashboard.summaryGrid, dashboard.summaryGrid,
dashboard.networkConfigGrid, dashboard.networkConfigGrid,
dashboard.configURLGrid,
} }
dashboard.logDataListeners = []LogDataListener{ dashboard.logDataListeners = []LogDataListener{
@ -223,6 +229,7 @@ func buildDashboard(ctx context.Context, cli *client.Client, opts ...Option) (*D
header, header,
dashboard.summaryGrid, dashboard.summaryGrid,
dashboard.networkConfigGrid, dashboard.networkConfigGrid,
dashboard.configURLGrid,
dashboard.footer, dashboard.footer,
} }
@ -253,6 +260,8 @@ func (d *Dashboard) initScreenConfigs(screens []Screen) error {
return d.monitorGrid return d.monitorGrid
case ScreenNetworkConfig: case ScreenNetworkConfig:
return d.networkConfigGrid return d.networkConfigGrid
case ScreenConfigURL:
return d.configURLGrid
default: default:
return nil return nil
} }
@ -274,7 +283,7 @@ func (d *Dashboard) initScreenConfigs(screens []Screen) error {
allowNodeNavigation: true, allowNodeNavigation: true,
} }
if screen == ScreenNetworkConfig { if screen == ScreenNetworkConfig || screen == ScreenConfigURL {
config.allowNodeNavigation = false config.allowNodeNavigation = false
} }

View File

@ -20,26 +20,32 @@ import (
const ( const (
interfaceNone = "(none)" interfaceNone = "(none)"
modeDHCP = "DHCP" // ModeDHCP is the DHCP mode for the link.
modeStatic = "Static" ModeDHCP = "DHCP"
// ModeStatic is the static IP mode for the link.
ModeStatic = "Static"
) )
type networkConfigFormData struct { // NetworkConfigFormData is the form data for the network config.
base runtime.PlatformNetworkConfig type NetworkConfigFormData struct {
hostname string Base runtime.PlatformNetworkConfig
dnsServers string Hostname string
timeServers string DNSServers string
iface string TimeServers string
mode string Iface string
addresses string Mode string
gateway string Addresses string
Gateway string
} }
// ToPlatformNetworkConfig converts the form data to a PlatformNetworkConfig.
//
//nolint:gocyclo //nolint:gocyclo
func (formData *networkConfigFormData) toPlatformNetworkConfig() (*runtime.PlatformNetworkConfig, error) { func (formData *NetworkConfigFormData) ToPlatformNetworkConfig() (*runtime.PlatformNetworkConfig, error) {
var errs error var errs error
config := &formData.base config := &formData.Base
// zero-out the fields managed by the form // zero-out the fields managed by the form
config.Hostnames = nil config.Hostnames = nil
@ -50,16 +56,16 @@ func (formData *networkConfigFormData) toPlatformNetworkConfig() (*runtime.Platf
config.Addresses = nil config.Addresses = nil
config.Routes = nil config.Routes = nil
if formData.hostname != "" { if formData.Hostname != "" {
config.Hostnames = []network.HostnameSpecSpec{ config.Hostnames = []network.HostnameSpecSpec{
{ {
Hostname: formData.hostname, Hostname: formData.Hostname,
ConfigLayer: network.ConfigPlatform, ConfigLayer: network.ConfigPlatform,
}, },
} }
} }
dnsServers, err := formData.parseAddresses(formData.dnsServers) dnsServers, err := formData.parseAddresses(formData.DNSServers)
if err != nil { if err != nil {
errs = multierror.Append(errs, fmt.Errorf("failed to parse DNS servers: %w", err)) errs = multierror.Append(errs, fmt.Errorf("failed to parse DNS servers: %w", err))
} }
@ -73,7 +79,7 @@ func (formData *networkConfigFormData) toPlatformNetworkConfig() (*runtime.Platf
} }
} }
timeServers := formData.splitInputList(formData.timeServers) timeServers := formData.splitInputList(formData.TimeServers)
if len(timeServers) > 0 { if len(timeServers) > 0 {
config.TimeServers = []network.TimeServerSpecSpec{ config.TimeServers = []network.TimeServerSpecSpec{
@ -84,11 +90,11 @@ func (formData *networkConfigFormData) toPlatformNetworkConfig() (*runtime.Platf
} }
} }
ifaceSelected := formData.iface != "" && formData.iface != interfaceNone ifaceSelected := formData.Iface != "" && formData.Iface != interfaceNone
if ifaceSelected { if ifaceSelected {
config.Links = []network.LinkSpecSpec{ config.Links = []network.LinkSpecSpec{
{ {
Name: formData.iface, Name: formData.Iface,
Logical: false, Logical: false,
Up: true, Up: true,
Type: nethelpers.LinkEther, Type: nethelpers.LinkEther,
@ -96,12 +102,12 @@ func (formData *networkConfigFormData) toPlatformNetworkConfig() (*runtime.Platf
}, },
} }
switch formData.mode { switch formData.Mode {
case modeDHCP: case ModeDHCP:
config.Operators = []network.OperatorSpecSpec{ config.Operators = []network.OperatorSpecSpec{
{ {
Operator: network.OperatorDHCP4, Operator: network.OperatorDHCP4,
LinkName: formData.iface, LinkName: formData.Iface,
RequireUp: true, RequireUp: true,
DHCP4: network.DHCP4OperatorSpec{ DHCP4: network.DHCP4OperatorSpec{
RouteMetric: 1024, RouteMetric: 1024,
@ -109,8 +115,8 @@ func (formData *networkConfigFormData) toPlatformNetworkConfig() (*runtime.Platf
ConfigLayer: network.ConfigPlatform, ConfigLayer: network.ConfigPlatform,
}, },
} }
case modeStatic: case ModeStatic:
config.Addresses, err = formData.buildAddresses(formData.iface) config.Addresses, err = formData.buildAddresses(formData.Iface)
if err != nil { if err != nil {
errs = multierror.Append(errs, err) errs = multierror.Append(errs, err)
} }
@ -119,7 +125,7 @@ func (formData *networkConfigFormData) toPlatformNetworkConfig() (*runtime.Platf
errs = multierror.Append(errs, fmt.Errorf("no addresses specified")) errs = multierror.Append(errs, fmt.Errorf("no addresses specified"))
} }
config.Routes, err = formData.buildRoutes(formData.iface) config.Routes, err = formData.buildRoutes(formData.Iface)
if err != nil { if err != nil {
errs = multierror.Append(errs, err) errs = multierror.Append(errs, err)
} }
@ -133,7 +139,7 @@ func (formData *networkConfigFormData) toPlatformNetworkConfig() (*runtime.Platf
return config, nil return config, nil
} }
func (formData *networkConfigFormData) parseAddresses(text string) ([]netip.Addr, error) { func (formData *NetworkConfigFormData) parseAddresses(text string) ([]netip.Addr, error) {
var errs error var errs error
split := formData.splitInputList(text) split := formData.splitInputList(text)
@ -157,10 +163,10 @@ func (formData *networkConfigFormData) parseAddresses(text string) ([]netip.Addr
return addresses, nil return addresses, nil
} }
func (formData *networkConfigFormData) buildAddresses(linkName string) ([]network.AddressSpecSpec, error) { func (formData *NetworkConfigFormData) buildAddresses(linkName string) ([]network.AddressSpecSpec, error) {
var errs error var errs error
addressesSplit := formData.splitInputList(formData.addresses) addressesSplit := formData.splitInputList(formData.Addresses)
addresses := make([]network.AddressSpecSpec, 0, len(addressesSplit)) addresses := make([]network.AddressSpecSpec, 0, len(addressesSplit))
for _, address := range addressesSplit { for _, address := range addressesSplit {
@ -193,8 +199,8 @@ func (formData *networkConfigFormData) buildAddresses(linkName string) ([]networ
return addresses, nil return addresses, nil
} }
func (formData *networkConfigFormData) buildRoutes(linkName string) ([]network.RouteSpecSpec, error) { func (formData *NetworkConfigFormData) buildRoutes(linkName string) ([]network.RouteSpecSpec, error) {
gateway := strings.TrimSpace(formData.gateway) gateway := strings.TrimSpace(formData.Gateway)
gatewayAddr, err := netip.ParseAddr(gateway) gatewayAddr, err := netip.ParseAddr(gateway)
if err != nil { if err != nil {
@ -220,7 +226,7 @@ func (formData *networkConfigFormData) buildRoutes(linkName string) ([]network.R
}, nil }, nil
} }
func (formData *networkConfigFormData) splitInputList(text string) []string { func (formData *NetworkConfigFormData) splitInputList(text string) []string {
parts := strings.FieldsFunc(text, func(r rune) bool { parts := strings.FieldsFunc(text, func(r rune) bool {
return r == ',' || unicode.IsSpace(r) return r == ',' || unicode.IsSpace(r)
}) })

View File

@ -0,0 +1,245 @@
// 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_test
import (
"net/netip"
"testing"
"github.com/stretchr/testify/assert"
"github.com/siderolabs/talos/internal/app/machined/pkg/runtime"
"github.com/siderolabs/talos/internal/pkg/dashboard"
"github.com/siderolabs/talos/pkg/machinery/nethelpers"
"github.com/siderolabs/talos/pkg/machinery/resources/network"
runtimeres "github.com/siderolabs/talos/pkg/machinery/resources/runtime"
)
func TestEmptyFormData(t *testing.T) {
formData := dashboard.NetworkConfigFormData{}
config, err := formData.ToPlatformNetworkConfig()
assert.NoError(t, err)
assert.Equal(t, runtime.PlatformNetworkConfig{}, *config)
}
func TestBaseDataZeroOut(t *testing.T) {
base := runtime.PlatformNetworkConfig{
Addresses: []network.AddressSpecSpec{
{
LinkName: "foobar",
},
},
Links: []network.LinkSpecSpec{
{
Name: "foobar",
},
},
Routes: []network.RouteSpecSpec{
{
OutLinkName: "foobar",
},
},
Hostnames: []network.HostnameSpecSpec{
{
Hostname: "foobar",
},
},
Resolvers: []network.ResolverSpecSpec{
{
DNSServers: []netip.Addr{
netip.MustParseAddr("1.2.3.4"),
},
},
},
TimeServers: []network.TimeServerSpecSpec{
{
NTPServers: []string{"foobar"},
},
},
Operators: []network.OperatorSpecSpec{
{
LinkName: "foobar",
},
},
ExternalIPs: []netip.Addr{
netip.MustParseAddr("2.3.4.5"),
},
Metadata: &runtimeres.PlatformMetadataSpec{
Platform: "foobar",
Spot: true,
},
}
formData := dashboard.NetworkConfigFormData{
Base: base,
}
config, err := formData.ToPlatformNetworkConfig()
assert.NoError(t, err)
// assert that the fields managed by the form are zeroed out
assert.Empty(t, config.Addresses)
assert.Empty(t, config.Links)
assert.Empty(t, config.Routes)
assert.Empty(t, config.Hostnames)
assert.Empty(t, config.Resolvers)
assert.Empty(t, config.TimeServers)
assert.Empty(t, config.Operators)
// assert that the fields not managed by the form are untouched
assert.Equal(t, base.ExternalIPs, config.ExternalIPs)
assert.Equal(t, base.Metadata, config.Metadata)
}
func TestFilledFormNoIface(t *testing.T) {
formData := dashboard.NetworkConfigFormData{
Base: runtime.PlatformNetworkConfig{
Metadata: &runtimeres.PlatformMetadataSpec{
Platform: "foobar",
},
},
Hostname: "foobar",
DNSServers: "1.2.3.4 5.6.7.8",
TimeServers: "a.example.com , b.example.com",
}
config, err := formData.ToPlatformNetworkConfig()
assert.NoError(t, err)
assert.Equal(
t,
runtime.PlatformNetworkConfig{
Hostnames: []network.HostnameSpecSpec{{
Hostname: "foobar",
ConfigLayer: network.ConfigPlatform,
}},
Resolvers: []network.ResolverSpecSpec{{
DNSServers: []netip.Addr{
netip.MustParseAddr("1.2.3.4"),
netip.MustParseAddr("5.6.7.8"),
},
ConfigLayer: network.ConfigPlatform,
}},
TimeServers: []network.TimeServerSpecSpec{
{
NTPServers: []string{"a.example.com", "b.example.com"},
ConfigLayer: network.ConfigPlatform,
},
},
Metadata: &runtimeres.PlatformMetadataSpec{
Platform: "foobar",
},
},
*config,
)
}
func TestFilledFormModeDHCP(t *testing.T) {
formData := dashboard.NetworkConfigFormData{
Iface: "eth0",
Mode: dashboard.ModeDHCP,
}
config, err := formData.ToPlatformNetworkConfig()
assert.NoError(t, err)
assert.Equal(t, runtime.PlatformNetworkConfig{
Links: []network.LinkSpecSpec{
{
Name: formData.Iface,
Logical: false,
Up: true,
Type: nethelpers.LinkEther,
ConfigLayer: network.ConfigPlatform,
},
},
Operators: []network.OperatorSpecSpec{
{
Operator: network.OperatorDHCP4,
LinkName: formData.Iface,
RequireUp: true,
DHCP4: network.DHCP4OperatorSpec{
RouteMetric: 1024,
},
ConfigLayer: network.ConfigPlatform,
},
},
}, *config)
}
func TestFilledFormModeStaticNoAddresses(t *testing.T) {
formData := dashboard.NetworkConfigFormData{
Iface: "eth0",
Mode: dashboard.ModeStatic,
}
_, err := formData.ToPlatformNetworkConfig()
assert.ErrorContains(t, err, "no addresses specified")
}
func TestFilledFormModeStaticNoGateway(t *testing.T) {
formData := dashboard.NetworkConfigFormData{
Iface: "eth0",
Mode: dashboard.ModeStatic,
Addresses: "1.2.3.4/24",
}
_, err := formData.ToPlatformNetworkConfig()
assert.ErrorContains(t, err, "unable to parse")
}
func TestFilledFormModeStatic(t *testing.T) {
formData := dashboard.NetworkConfigFormData{
Iface: "eth42",
Mode: dashboard.ModeStatic,
Addresses: "1.2.3.4/24 2.3.4.5/32",
Gateway: "3.4.5.6",
}
config, err := formData.ToPlatformNetworkConfig()
assert.NoError(t, err)
assert.Equal(t, runtime.PlatformNetworkConfig{
Links: []network.LinkSpecSpec{
{
Name: formData.Iface,
Logical: false,
Up: true,
Type: nethelpers.LinkEther,
ConfigLayer: network.ConfigPlatform,
},
},
Addresses: []network.AddressSpecSpec{
{
Address: netip.MustParsePrefix("1.2.3.4/24"),
LinkName: formData.Iface,
Family: nethelpers.FamilyInet4,
Flags: nethelpers.AddressFlags(nethelpers.AddressPermanent),
ConfigLayer: network.ConfigPlatform,
},
{
Address: netip.MustParsePrefix("2.3.4.5/32"),
LinkName: formData.Iface,
Family: nethelpers.FamilyInet4,
Flags: nethelpers.AddressFlags(nethelpers.AddressPermanent),
ConfigLayer: network.ConfigPlatform,
},
},
Routes: []network.RouteSpecSpec{
{
Family: nethelpers.FamilyInet4,
Gateway: netip.MustParseAddr("3.4.5.6"),
OutLinkName: "eth42",
Table: nethelpers.TableMain,
Scope: nethelpers.ScopeGlobal,
Type: nethelpers.TypeUnicast,
Protocol: nethelpers.ProtocolStatic,
ConfigLayer: network.ConfigPlatform,
},
},
}, *config)
}

View File

@ -14,20 +14,16 @@ import (
"github.com/rivo/tview" "github.com/rivo/tview"
"github.com/siderolabs/gen/maps" "github.com/siderolabs/gen/maps"
"github.com/siderolabs/go-pointer" "github.com/siderolabs/go-pointer"
"google.golang.org/grpc/metadata"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
"github.com/siderolabs/talos/internal/app/machined/pkg/runtime" "github.com/siderolabs/talos/internal/app/machined/pkg/runtime"
"github.com/siderolabs/talos/internal/pkg/dashboard/resourcedata" "github.com/siderolabs/talos/internal/pkg/dashboard/resourcedata"
"github.com/siderolabs/talos/internal/pkg/meta" "github.com/siderolabs/talos/internal/pkg/meta"
"github.com/siderolabs/talos/pkg/machinery/client"
"github.com/siderolabs/talos/pkg/machinery/resources/network" "github.com/siderolabs/talos/pkg/machinery/resources/network"
runtimeres "github.com/siderolabs/talos/pkg/machinery/resources/runtime" runtimeres "github.com/siderolabs/talos/pkg/machinery/resources/runtime"
) )
const ( const (
pageDeleteModal = "deleteModal"
formItemHostname = "Hostname" formItemHostname = "Hostname"
formItemDNSServers = "DNS Servers" formItemDNSServers = "DNS Servers"
formItemTimeServers = "Time Servers" formItemTimeServers = "Time Servers"
@ -35,8 +31,6 @@ const (
formItemMode = "Mode" formItemMode = "Mode"
formItemAddresses = "Addresses" formItemAddresses = "Addresses"
formItemGateway = "Gateway" formItemGateway = "Gateway"
buttonConfirmDelete = "Delete"
) )
type networkConfigData struct { type networkConfigData struct {
@ -125,7 +119,7 @@ func NewNetworkConfigGrid(ctx context.Context, dashboard *Dashboard) *NetworkCon
widget.modeDropdown = tview.NewDropDown().SetLabel(formItemMode) widget.modeDropdown = tview.NewDropDown().SetLabel(formItemMode)
widget.modeDropdown.SetBlurFunc(widget.formEdited) widget.modeDropdown.SetBlurFunc(widget.formEdited)
widget.modeDropdown.SetOptions([]string{modeDHCP, modeStatic}, func(_ string, _ int) { widget.modeDropdown.SetOptions([]string{ModeDHCP, ModeStatic}, func(_ string, _ int) {
widget.formEdited() widget.formEdited()
}) })
@ -150,27 +144,6 @@ func NewNetworkConfigGrid(ctx context.Context, dashboard *Dashboard) *NetworkCon
saveButton := widget.configForm.GetButton(0) saveButton := widget.configForm.GetButton(0)
saveButton.SetBlurFunc(widget.formEdited) saveButton.SetBlurFunc(widget.formEdited)
widget.configForm.AddButton("Delete", func() {
widget.dashboard.pages.SwitchToPage(pageDeleteModal)
})
deleteButton := widget.configForm.GetButton(1)
deleteButton.SetBlurFunc(widget.formEdited)
deleteModal := tview.NewModal().
SetText("Are you sure you want to delete the configuration?").
AddButtons([]string{buttonConfirmDelete, "Cancel"}).
SetDoneFunc(func(buttonIndex int, buttonLabel string) {
if buttonLabel == buttonConfirmDelete {
widget.delete(ctx)
}
widget.dashboard.pages.SwitchToPage(pageMain)
widget.dashboard.app.SetFocus(deleteButton)
})
widget.dashboard.pages.AddPage(pageDeleteModal, deleteModal, true, false)
inputCapture := func(event *tcell.EventKey) *tcell.EventKey { inputCapture := func(event *tcell.EventKey) *tcell.EventKey {
if widget.handleFocusSwitch(event) { if widget.handleFocusSwitch(event) {
return nil return nil
@ -238,7 +211,7 @@ func (widget *NetworkConfigGrid) formEdited() {
} }
switch currentMode { switch currentMode {
case modeDHCP: case ModeDHCP:
resetInputField(widget.addressesField) resetInputField(widget.addressesField)
resetInputField(widget.gatewayField) resetInputField(widget.gatewayField)
@ -249,7 +222,7 @@ func (widget *NetworkConfigGrid) formEdited() {
if itemIndex := widget.configForm.GetFormItemIndex(formItemGateway); itemIndex != -1 { if itemIndex := widget.configForm.GetFormItemIndex(formItemGateway); itemIndex != -1 {
widget.configForm.RemoveFormItem(itemIndex) widget.configForm.RemoveFormItem(itemIndex)
} }
case modeStatic: case ModeStatic:
if itemIndex := widget.configForm.GetFormItemIndex(formItemAddresses); itemIndex == -1 { if itemIndex := widget.configForm.GetFormItemIndex(formItemAddresses); itemIndex == -1 {
widget.configForm.AddFormItem(widget.addressesField) widget.configForm.AddFormItem(widget.addressesField)
} }
@ -278,18 +251,18 @@ func (widget *NetworkConfigGrid) formEdited() {
data := widget.getOrCreateNodeData(widget.selectedNode) data := widget.getOrCreateNodeData(widget.selectedNode)
formData := networkConfigFormData{ formData := NetworkConfigFormData{
base: pointer.SafeDeref(data.existingConfig), Base: pointer.SafeDeref(data.existingConfig),
hostname: widget.hostnameField.GetText(), Hostname: widget.hostnameField.GetText(),
dnsServers: widget.dnsServersField.GetText(), DNSServers: widget.dnsServersField.GetText(),
timeServers: widget.timeServersField.GetText(), TimeServers: widget.timeServersField.GetText(),
iface: currentInterface, Iface: currentInterface,
mode: currentMode, Mode: currentMode,
addresses: widget.addressesField.GetText(), Addresses: widget.addressesField.GetText(),
gateway: widget.gatewayField.GetText(), Gateway: widget.gatewayField.GetText(),
} }
config, err := formData.toPlatformNetworkConfig() config, err := formData.ToPlatformNetworkConfig()
if err != nil { if err != nil {
data.newConfig = nil data.newConfig = nil
data.newConfigError = err data.newConfigError = err
@ -378,6 +351,7 @@ func (widget *NetworkConfigGrid) updateNodeData(data resourcedata.Data) {
widget.formEdited() widget.formEdited()
}) })
case *runtimeres.MetaKey: case *runtimeres.MetaKey:
if res.Metadata().ID() == runtimeres.MetaKeyTagToID(meta.MetalNetworkPlatformConfig) {
if data.Deleted { if data.Deleted {
nodeData.existingConfig = nil nodeData.existingConfig = nil
} else { } else {
@ -394,6 +368,7 @@ func (widget *NetworkConfigGrid) updateNodeData(data resourcedata.Data) {
widget.formEdited() widget.formEdited()
} }
} }
}
} }
func (widget *NetworkConfigGrid) getOrCreateNodeData(node string) *networkConfigData { func (widget *NetworkConfigGrid) getOrCreateNodeData(node string) *networkConfigData {
@ -438,7 +413,7 @@ func (widget *NetworkConfigGrid) save(ctx context.Context) {
return return
} }
ctx = widget.nodeContext(ctx) ctx = nodeContext(ctx, widget.selectedNode)
if err = widget.dashboard.cli.MetaWrite(ctx, meta.MetalNetworkPlatformConfig, configBytes); err != nil { if err = widget.dashboard.cli.MetaWrite(ctx, meta.MetalNetworkPlatformConfig, configBytes); err != nil {
widget.infoView.SetText(fmt.Sprintf("[red]Error: %v[-]", err)) widget.infoView.SetText(fmt.Sprintf("[red]Error: %v[-]", err))
@ -451,33 +426,6 @@ func (widget *NetworkConfigGrid) save(ctx context.Context) {
widget.dashboard.selectScreen(ScreenSummary) widget.dashboard.selectScreen(ScreenSummary)
} }
func (widget *NetworkConfigGrid) delete(ctx context.Context) {
ctx = widget.nodeContext(ctx)
if err := widget.dashboard.cli.MetaDelete(ctx, meta.MetalNetworkPlatformConfig); err != nil {
widget.infoView.SetText(fmt.Sprintf("[red]Error: %v[-]", err))
return
}
widget.infoView.SetText("[green]Network config deleted successfully[-]")
}
func (widget *NetworkConfigGrid) nodeContext(ctx context.Context) context.Context {
md, mdOk := metadata.FromOutgoingContext(ctx)
if mdOk {
md.Delete("nodes")
ctx = metadata.NewOutgoingContext(ctx, md)
}
if widget.selectedNode != "" {
ctx = client.WithNode(ctx, widget.selectedNode)
}
return ctx
}
func (widget *NetworkConfigGrid) handleFocusSwitch(event *tcell.EventKey) bool { func (widget *NetworkConfigGrid) handleFocusSwitch(event *tcell.EventKey) bool {
switch event.Key() { //nolint:exhaustive switch event.Key() { //nolint:exhaustive
case tcell.KeyCtrlQ: case tcell.KeyCtrlQ:

View File

@ -18,7 +18,6 @@ import (
"golang.org/x/sync/errgroup" "golang.org/x/sync/errgroup"
"google.golang.org/grpc/metadata" "google.golang.org/grpc/metadata"
"github.com/siderolabs/talos/internal/pkg/meta"
"github.com/siderolabs/talos/pkg/machinery/client" "github.com/siderolabs/talos/pkg/machinery/client"
"github.com/siderolabs/talos/pkg/machinery/resources/cluster" "github.com/siderolabs/talos/pkg/machinery/resources/cluster"
"github.com/siderolabs/talos/pkg/machinery/resources/config" "github.com/siderolabs/talos/pkg/machinery/resources/config"
@ -139,10 +138,6 @@ func (source *Source) runResourceWatch(ctx context.Context, node string) error {
return err return err
} }
if err := source.COSI.Watch(ctx, runtime.NewMetaKey(runtime.NamespaceName, runtime.MetaKeyTagToID(meta.MetalNetworkPlatformConfig)).Metadata(), eventCh); err != nil {
return err
}
if err := source.COSI.Watch(ctx, network.NewStatus(network.NamespaceName, network.StatusID).Metadata(), eventCh); err != nil { if err := source.COSI.Watch(ctx, network.NewStatus(network.NamespaceName, network.StatusID).Metadata(), eventCh); err != nil {
return err return err
} }
@ -151,6 +146,10 @@ func (source *Source) runResourceWatch(ctx context.Context, node string) error {
return err return err
} }
if err := source.COSI.WatchKind(ctx, runtime.NewMetaKey(runtime.NamespaceName, "").Metadata(), eventCh, state.WithBootstrapContents(true)); err != nil {
return err
}
if err := source.COSI.WatchKind(ctx, k8s.NewStaticPodStatus(k8s.NamespaceName, "").Metadata(), eventCh, state.WithBootstrapContents(true)); err != nil { if err := source.COSI.WatchKind(ctx, k8s.NewStaticPodStatus(k8s.NamespaceName, "").Metadata(), eventCh, state.WithBootstrapContents(true)); err != nil {
return err return err
} }

View File

@ -5,8 +5,6 @@
package dashboard package dashboard
import ( import (
"sync"
"github.com/rivo/tview" "github.com/rivo/tview"
"github.com/siderolabs/talos/internal/pkg/dashboard/apidata" "github.com/siderolabs/talos/internal/pkg/dashboard/apidata"
@ -24,7 +22,6 @@ type SummaryGrid struct {
resourceListeners []ResourceDataListener resourceListeners []ResourceDataListener
nodeSelectListeners []NodeSelectListener nodeSelectListeners []NodeSelectListener
lock sync.Mutex
active bool active bool
node string node string
logViewers map[string]*components.LogViewer logViewers map[string]*components.LogViewer
@ -70,9 +67,6 @@ func NewSummaryGrid(app *tview.Application) *SummaryGrid {
// OnNodeSelect implements the NodeSelectListener interface. // OnNodeSelect implements the NodeSelectListener interface.
func (widget *SummaryGrid) OnNodeSelect(node string) { func (widget *SummaryGrid) OnNodeSelect(node string) {
widget.lock.Lock()
defer widget.lock.Unlock()
widget.node = node widget.node = node
widget.updateLogViewer() widget.updateLogViewer()
@ -98,9 +92,6 @@ func (widget *SummaryGrid) OnResourceDataChange(nodeResource resourcedata.Data)
// OnLogDataChange implements the LogDataListener interface. // OnLogDataChange implements the LogDataListener interface.
func (widget *SummaryGrid) OnLogDataChange(node string, logLine string) { func (widget *SummaryGrid) OnLogDataChange(node string, logLine string) {
widget.lock.Lock()
defer widget.lock.Unlock()
widget.logViewer(node).WriteLog(logLine) widget.logViewer(node).WriteLog(logLine)
} }
@ -137,9 +128,6 @@ func (widget *SummaryGrid) logViewer(node string) *components.LogViewer {
// OnScreenSelect implements the screenSelectListener interface. // OnScreenSelect implements the screenSelectListener interface.
func (widget *SummaryGrid) onScreenSelect(active bool) { func (widget *SummaryGrid) onScreenSelect(active bool) {
widget.lock.Lock()
defer widget.lock.Unlock()
widget.active = active widget.active = active
widget.updateLogViewer() widget.updateLogViewer()