diff --git a/internal/app/dashboard/main.go b/internal/app/dashboard/main.go index aa69065e0..e3d173861 100644 --- a/internal/app/dashboard/main.go +++ b/internal/app/dashboard/main.go @@ -9,12 +9,15 @@ import ( "context" "fmt" "log" + "net/url" + "github.com/siderolabs/go-procfs/procfs" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/metadata" "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/pkg/grpc/middleware/authz" "github.com/siderolabs/talos/pkg/machinery/client" @@ -51,7 +54,30 @@ func dashboardMain() error { currentPlatform, _ := platform.CurrentPlatform() //nolint:errcheck if currentPlatform != nil && currentPlatform.Name() == constants.PlatformMetal { screens = append(screens, dashboard.ScreenNetworkConfig) + + if showConfigURLTab() { + screens = append(screens, dashboard.ScreenConfigURL) + } } 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()) +} diff --git a/internal/app/machined/pkg/runtime/v1alpha1/platform/metal/metal.go b/internal/app/machined/pkg/runtime/v1alpha1/platform/metal/metal.go index bfaefea18..d357a36da 100644 --- a/internal/app/machined/pkg/runtime/v1alpha1/platform/metal/metal.go +++ b/internal/app/machined/pkg/runtime/v1alpha1/platform/metal/metal.go @@ -25,7 +25,7 @@ import ( "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/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/pkg/download" "github.com/siderolabs/talos/pkg/machinery/constants" diff --git a/internal/app/machined/pkg/runtime/v1alpha1/platform/metal/internal/url/url.go b/internal/app/machined/pkg/runtime/v1alpha1/platform/metal/url/url.go similarity index 97% rename from internal/app/machined/pkg/runtime/v1alpha1/platform/metal/internal/url/url.go rename to internal/app/machined/pkg/runtime/v1alpha1/platform/metal/url/url.go index 5684f9ca7..2e2b50e36 100644 --- a/internal/app/machined/pkg/runtime/v1alpha1/platform/metal/internal/url/url.go +++ b/internal/app/machined/pkg/runtime/v1alpha1/platform/metal/url/url.go @@ -18,7 +18,7 @@ import ( // Populate populates the config download URL with values replacing variables. 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. diff --git a/internal/app/machined/pkg/runtime/v1alpha1/platform/metal/internal/url/url_test.go b/internal/app/machined/pkg/runtime/v1alpha1/platform/metal/url/url_test.go similarity index 99% rename from internal/app/machined/pkg/runtime/v1alpha1/platform/metal/internal/url/url_test.go rename to internal/app/machined/pkg/runtime/v1alpha1/platform/metal/url/url_test.go index 1bbbca29b..3209ca133 100644 --- a/internal/app/machined/pkg/runtime/v1alpha1/platform/metal/internal/url/url_test.go +++ b/internal/app/machined/pkg/runtime/v1alpha1/platform/metal/url/url_test.go @@ -17,7 +17,7 @@ import ( "github.com/stretchr/testify/assert" "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/pkg/machinery/nethelpers" "github.com/siderolabs/talos/pkg/machinery/resources/hardware" diff --git a/internal/app/machined/pkg/runtime/v1alpha1/platform/metal/internal/url/value.go b/internal/app/machined/pkg/runtime/v1alpha1/platform/metal/url/value.go similarity index 100% rename from internal/app/machined/pkg/runtime/v1alpha1/platform/metal/internal/url/value.go rename to internal/app/machined/pkg/runtime/v1alpha1/platform/metal/url/value.go diff --git a/internal/app/machined/pkg/runtime/v1alpha1/platform/metal/internal/url/variable.go b/internal/app/machined/pkg/runtime/v1alpha1/platform/metal/url/variable.go similarity index 89% rename from internal/app/machined/pkg/runtime/v1alpha1/platform/metal/internal/url/variable.go rename to internal/app/machined/pkg/runtime/v1alpha1/platform/metal/url/variable.go index f836841cc..9e29309e8 100644 --- a/internal/app/machined/pkg/runtime/v1alpha1/platform/metal/internal/url/variable.go +++ b/internal/app/machined/pkg/runtime/v1alpha1/platform/metal/url/variable.go @@ -28,27 +28,27 @@ type Variable struct { r *regexp.Regexp } -// AllVariables is a list of all supported variables. -func AllVariables() []*Variable { - return []*Variable{ - { +// AllVariables is a map of all supported variables. +func AllVariables() map[string]*Variable { + return map[string]*Variable{ + constants.UUIDKey: { Key: constants.UUIDKey, MatchOnArg: true, Value: UUIDValue(), }, - { + constants.SerialNumberKey: { Key: constants.SerialNumberKey, Value: SerialNumberValue(), }, - { + constants.MacKey: { Key: constants.MacKey, Value: MACValue(), }, - { + constants.HostnameKey: { Key: constants.HostnameKey, Value: HostnameValue(), }, - { + constants.CodeKey: { Key: constants.CodeKey, Value: CodeValue(), }, diff --git a/internal/app/machined/pkg/runtime/v1alpha1/platform/metal/internal/url/variable_test.go b/internal/app/machined/pkg/runtime/v1alpha1/platform/metal/url/variable_test.go similarity index 98% rename from internal/app/machined/pkg/runtime/v1alpha1/platform/metal/internal/url/variable_test.go rename to internal/app/machined/pkg/runtime/v1alpha1/platform/metal/url/variable_test.go index 1ef9644b0..dcf79a5b5 100644 --- a/internal/app/machined/pkg/runtime/v1alpha1/platform/metal/internal/url/variable_test.go +++ b/internal/app/machined/pkg/runtime/v1alpha1/platform/metal/url/variable_test.go @@ -13,7 +13,7 @@ import ( "github.com/stretchr/testify/assert" "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" ) diff --git a/internal/pkg/dashboard/configurl.go b/internal/pkg/dashboard/configurl.go new file mode 100644 index 000000000..129129ffb --- /dev/null +++ b/internal/pkg/dashboard/configurl.go @@ -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("") +} diff --git a/internal/pkg/dashboard/context.go b/internal/pkg/dashboard/context.go new file mode 100644 index 000000000..4d69828f9 --- /dev/null +++ b/internal/pkg/dashboard/context.go @@ -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 +} diff --git a/internal/pkg/dashboard/dashboard.go b/internal/pkg/dashboard/dashboard.go index 4a381f622..d40efa36d 100644 --- a/internal/pkg/dashboard/dashboard.go +++ b/internal/pkg/dashboard/dashboard.go @@ -48,6 +48,9 @@ const ( // ScreenNetworkConfig is the network configuration screen. 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. @@ -114,6 +117,7 @@ type Dashboard struct { summaryGrid *SummaryGrid monitorGrid *MonitorGrid networkConfigGrid *NetworkConfigGrid + configURLGrid *ConfigURLGrid selectedScreenConfig *screenConfig screenConfigs []screenConfig @@ -156,6 +160,7 @@ func buildDashboard(ctx context.Context, cli *client.Client, opts ...Option) (*D dashboard.summaryGrid = NewSummaryGrid(dashboard.app) dashboard.monitorGrid = NewMonitorGrid(dashboard.app) dashboard.networkConfigGrid = NewNetworkConfigGrid(ctx, dashboard) + dashboard.configURLGrid = NewConfigURLGrid(ctx, dashboard) err := dashboard.initScreenConfigs(defOptions.screens) if err != nil { @@ -213,6 +218,7 @@ func buildDashboard(ctx context.Context, cli *client.Client, opts ...Option) (*D header, dashboard.summaryGrid, dashboard.networkConfigGrid, + dashboard.configURLGrid, } dashboard.logDataListeners = []LogDataListener{ @@ -223,6 +229,7 @@ func buildDashboard(ctx context.Context, cli *client.Client, opts ...Option) (*D header, dashboard.summaryGrid, dashboard.networkConfigGrid, + dashboard.configURLGrid, dashboard.footer, } @@ -253,6 +260,8 @@ func (d *Dashboard) initScreenConfigs(screens []Screen) error { return d.monitorGrid case ScreenNetworkConfig: return d.networkConfigGrid + case ScreenConfigURL: + return d.configURLGrid default: return nil } @@ -274,7 +283,7 @@ func (d *Dashboard) initScreenConfigs(screens []Screen) error { allowNodeNavigation: true, } - if screen == ScreenNetworkConfig { + if screen == ScreenNetworkConfig || screen == ScreenConfigURL { config.allowNodeNavigation = false } diff --git a/internal/pkg/dashboard/formdata.go b/internal/pkg/dashboard/formdata.go index 63ed81ecd..49e563247 100644 --- a/internal/pkg/dashboard/formdata.go +++ b/internal/pkg/dashboard/formdata.go @@ -20,26 +20,32 @@ import ( const ( interfaceNone = "(none)" - modeDHCP = "DHCP" - modeStatic = "Static" + // ModeDHCP is the DHCP mode for the link. + ModeDHCP = "DHCP" + + // ModeStatic is the static IP mode for the link. + ModeStatic = "Static" ) -type networkConfigFormData struct { - base runtime.PlatformNetworkConfig - hostname string - dnsServers string - timeServers string - iface string - mode string - addresses string - gateway string +// NetworkConfigFormData is the form data for the network config. +type NetworkConfigFormData struct { + Base runtime.PlatformNetworkConfig + Hostname string + DNSServers string + TimeServers string + Iface string + Mode string + Addresses string + Gateway string } +// ToPlatformNetworkConfig converts the form data to a PlatformNetworkConfig. +// //nolint:gocyclo -func (formData *networkConfigFormData) toPlatformNetworkConfig() (*runtime.PlatformNetworkConfig, error) { +func (formData *NetworkConfigFormData) ToPlatformNetworkConfig() (*runtime.PlatformNetworkConfig, error) { var errs error - config := &formData.base + config := &formData.Base // zero-out the fields managed by the form config.Hostnames = nil @@ -50,16 +56,16 @@ func (formData *networkConfigFormData) toPlatformNetworkConfig() (*runtime.Platf config.Addresses = nil config.Routes = nil - if formData.hostname != "" { + if formData.Hostname != "" { config.Hostnames = []network.HostnameSpecSpec{ { - Hostname: formData.hostname, + Hostname: formData.Hostname, ConfigLayer: network.ConfigPlatform, }, } } - dnsServers, err := formData.parseAddresses(formData.dnsServers) + dnsServers, err := formData.parseAddresses(formData.DNSServers) if err != nil { 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 { 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 { config.Links = []network.LinkSpecSpec{ { - Name: formData.iface, + Name: formData.Iface, Logical: false, Up: true, Type: nethelpers.LinkEther, @@ -96,12 +102,12 @@ func (formData *networkConfigFormData) toPlatformNetworkConfig() (*runtime.Platf }, } - switch formData.mode { - case modeDHCP: + switch formData.Mode { + case ModeDHCP: config.Operators = []network.OperatorSpecSpec{ { Operator: network.OperatorDHCP4, - LinkName: formData.iface, + LinkName: formData.Iface, RequireUp: true, DHCP4: network.DHCP4OperatorSpec{ RouteMetric: 1024, @@ -109,8 +115,8 @@ func (formData *networkConfigFormData) toPlatformNetworkConfig() (*runtime.Platf ConfigLayer: network.ConfigPlatform, }, } - case modeStatic: - config.Addresses, err = formData.buildAddresses(formData.iface) + case ModeStatic: + config.Addresses, err = formData.buildAddresses(formData.Iface) if err != nil { errs = multierror.Append(errs, err) } @@ -119,7 +125,7 @@ func (formData *networkConfigFormData) toPlatformNetworkConfig() (*runtime.Platf 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 { errs = multierror.Append(errs, err) } @@ -133,7 +139,7 @@ func (formData *networkConfigFormData) toPlatformNetworkConfig() (*runtime.Platf return config, nil } -func (formData *networkConfigFormData) parseAddresses(text string) ([]netip.Addr, error) { +func (formData *NetworkConfigFormData) parseAddresses(text string) ([]netip.Addr, error) { var errs error split := formData.splitInputList(text) @@ -157,10 +163,10 @@ func (formData *networkConfigFormData) parseAddresses(text string) ([]netip.Addr return addresses, nil } -func (formData *networkConfigFormData) buildAddresses(linkName string) ([]network.AddressSpecSpec, error) { +func (formData *NetworkConfigFormData) buildAddresses(linkName string) ([]network.AddressSpecSpec, error) { var errs error - addressesSplit := formData.splitInputList(formData.addresses) + addressesSplit := formData.splitInputList(formData.Addresses) addresses := make([]network.AddressSpecSpec, 0, len(addressesSplit)) for _, address := range addressesSplit { @@ -193,8 +199,8 @@ func (formData *networkConfigFormData) buildAddresses(linkName string) ([]networ return addresses, nil } -func (formData *networkConfigFormData) buildRoutes(linkName string) ([]network.RouteSpecSpec, error) { - gateway := strings.TrimSpace(formData.gateway) +func (formData *NetworkConfigFormData) buildRoutes(linkName string) ([]network.RouteSpecSpec, error) { + gateway := strings.TrimSpace(formData.Gateway) gatewayAddr, err := netip.ParseAddr(gateway) if err != nil { @@ -220,7 +226,7 @@ func (formData *networkConfigFormData) buildRoutes(linkName string) ([]network.R }, nil } -func (formData *networkConfigFormData) splitInputList(text string) []string { +func (formData *NetworkConfigFormData) splitInputList(text string) []string { parts := strings.FieldsFunc(text, func(r rune) bool { return r == ',' || unicode.IsSpace(r) }) diff --git a/internal/pkg/dashboard/formdata_test.go b/internal/pkg/dashboard/formdata_test.go new file mode 100644 index 000000000..c898e386b --- /dev/null +++ b/internal/pkg/dashboard/formdata_test.go @@ -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) +} diff --git a/internal/pkg/dashboard/networkconfig.go b/internal/pkg/dashboard/networkconfig.go index db71e8be1..987caa4f4 100644 --- a/internal/pkg/dashboard/networkconfig.go +++ b/internal/pkg/dashboard/networkconfig.go @@ -14,20 +14,16 @@ import ( "github.com/rivo/tview" "github.com/siderolabs/gen/maps" "github.com/siderolabs/go-pointer" - "google.golang.org/grpc/metadata" "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/client" "github.com/siderolabs/talos/pkg/machinery/resources/network" runtimeres "github.com/siderolabs/talos/pkg/machinery/resources/runtime" ) const ( - pageDeleteModal = "deleteModal" - formItemHostname = "Hostname" formItemDNSServers = "DNS Servers" formItemTimeServers = "Time Servers" @@ -35,8 +31,6 @@ const ( formItemMode = "Mode" formItemAddresses = "Addresses" formItemGateway = "Gateway" - - buttonConfirmDelete = "Delete" ) type networkConfigData struct { @@ -125,7 +119,7 @@ func NewNetworkConfigGrid(ctx context.Context, dashboard *Dashboard) *NetworkCon widget.modeDropdown = tview.NewDropDown().SetLabel(formItemMode) 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() }) @@ -150,27 +144,6 @@ func NewNetworkConfigGrid(ctx context.Context, dashboard *Dashboard) *NetworkCon saveButton := widget.configForm.GetButton(0) 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 { if widget.handleFocusSwitch(event) { return nil @@ -238,7 +211,7 @@ func (widget *NetworkConfigGrid) formEdited() { } switch currentMode { - case modeDHCP: + case ModeDHCP: resetInputField(widget.addressesField) resetInputField(widget.gatewayField) @@ -249,7 +222,7 @@ func (widget *NetworkConfigGrid) formEdited() { if itemIndex := widget.configForm.GetFormItemIndex(formItemGateway); itemIndex != -1 { widget.configForm.RemoveFormItem(itemIndex) } - case modeStatic: + case ModeStatic: if itemIndex := widget.configForm.GetFormItemIndex(formItemAddresses); itemIndex == -1 { widget.configForm.AddFormItem(widget.addressesField) } @@ -278,18 +251,18 @@ func (widget *NetworkConfigGrid) formEdited() { 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(), + 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() + config, err := formData.ToPlatformNetworkConfig() if err != nil { data.newConfig = nil data.newConfigError = err @@ -378,20 +351,22 @@ func (widget *NetworkConfigGrid) updateNodeData(data resourcedata.Data) { widget.formEdited() }) case *runtimeres.MetaKey: - if data.Deleted { - nodeData.existingConfig = nil - } else { - cfg := runtime.PlatformNetworkConfig{} + 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)) + if err := yaml.Unmarshal([]byte(res.TypedSpec().Value), &cfg); err != nil { + widget.existingConfigView.SetText(fmt.Sprintf("[red]error: %v[-]", err)) - return + return + } + + nodeData.existingConfig = &cfg + + widget.formEdited() } - - nodeData.existingConfig = &cfg - - widget.formEdited() } } } @@ -438,7 +413,7 @@ func (widget *NetworkConfigGrid) save(ctx context.Context) { return } - ctx = widget.nodeContext(ctx) + 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)) @@ -451,33 +426,6 @@ func (widget *NetworkConfigGrid) save(ctx context.Context) { 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 { switch event.Key() { //nolint:exhaustive case tcell.KeyCtrlQ: diff --git a/internal/pkg/dashboard/resourcedata/resourcedata.go b/internal/pkg/dashboard/resourcedata/resourcedata.go index 571dfe1c9..d04e06133 100644 --- a/internal/pkg/dashboard/resourcedata/resourcedata.go +++ b/internal/pkg/dashboard/resourcedata/resourcedata.go @@ -18,7 +18,6 @@ import ( "golang.org/x/sync/errgroup" "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/resources/cluster" "github.com/siderolabs/talos/pkg/machinery/resources/config" @@ -139,10 +138,6 @@ func (source *Source) runResourceWatch(ctx context.Context, node string) error { 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 { return err } @@ -151,6 +146,10 @@ func (source *Source) runResourceWatch(ctx context.Context, node string) error { 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 { return err } diff --git a/internal/pkg/dashboard/summary.go b/internal/pkg/dashboard/summary.go index d7bdf2014..03ffe2401 100644 --- a/internal/pkg/dashboard/summary.go +++ b/internal/pkg/dashboard/summary.go @@ -5,8 +5,6 @@ package dashboard import ( - "sync" - "github.com/rivo/tview" "github.com/siderolabs/talos/internal/pkg/dashboard/apidata" @@ -24,7 +22,6 @@ type SummaryGrid struct { resourceListeners []ResourceDataListener nodeSelectListeners []NodeSelectListener - lock sync.Mutex active bool node string logViewers map[string]*components.LogViewer @@ -70,9 +67,6 @@ func NewSummaryGrid(app *tview.Application) *SummaryGrid { // OnNodeSelect implements the NodeSelectListener interface. func (widget *SummaryGrid) OnNodeSelect(node string) { - widget.lock.Lock() - defer widget.lock.Unlock() - widget.node = node widget.updateLogViewer() @@ -98,9 +92,6 @@ func (widget *SummaryGrid) OnResourceDataChange(nodeResource resourcedata.Data) // OnLogDataChange implements the LogDataListener interface. func (widget *SummaryGrid) OnLogDataChange(node string, logLine string) { - widget.lock.Lock() - defer widget.lock.Unlock() - widget.logViewer(node).WriteLog(logLine) } @@ -137,9 +128,6 @@ func (widget *SummaryGrid) logViewer(node string) *components.LogViewer { // OnScreenSelect implements the screenSelectListener interface. func (widget *SummaryGrid) onScreenSelect(active bool) { - widget.lock.Lock() - defer widget.lock.Unlock() - widget.active = active widget.updateLogViewer()