From ff0c943115fe8fe25cb92828aa250be3cd892431 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Mon, 10 Nov 2025 12:17:15 +0000 Subject: [PATCH 01/11] web: update to React Router v7 --- web/package-lock.json | 54 ++++++++++++++++++++----------------------- web/package.json | 2 +- 2 files changed, 26 insertions(+), 30 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index edca841e6d..ffd806c7b2 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -20,7 +20,7 @@ "radashi": "^12.6.2", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-router-dom": "^6.30.1", + "react-router": "^7.9.5", "sprintf-js": "^1.1.3", "xbytes": "^1.9.1" }, @@ -4444,15 +4444,6 @@ } } }, - "node_modules/@remix-run/router": { - "version": "1.23.0", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz", - "integrity": "sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==", - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -16411,35 +16402,34 @@ } }, "node_modules/react-router": { - "version": "6.30.1", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.1.tgz", - "integrity": "sha512-X1m21aEmxGXqENEPG3T6u0Th7g0aS4ZmoNynhbs+Cn+q+QGTLt+d5IQ2bHAXKzKcxGJjxACpVbnYQSCRcfxHlQ==", + "version": "7.9.5", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.5.tgz", + "integrity": "sha512-JmxqrnBZ6E9hWmf02jzNn9Jm3UqyeimyiwzD69NjxGySG6lIz/1LVPsoTCwN7NBX2XjCEa1LIX5EMz1j2b6u6A==", "license": "MIT", "dependencies": { - "@remix-run/router": "1.23.0" + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" }, "engines": { - "node": ">=14.0.0" + "node": ">=20.0.0" }, "peerDependencies": { - "react": ">=16.8" + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } } }, - "node_modules/react-router-dom": { - "version": "6.30.1", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.1.tgz", - "integrity": "sha512-llKsgOkZdbPU1Eg3zK8lCn+sjD9wMRZZPuzmdWWX5SUs8OFkN5HnFVC0u5KMeMaC9aoancFI/KoLuKPqN+hxHw==", + "node_modules/react-router/node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", "license": "MIT", - "dependencies": { - "@remix-run/router": "1.23.0", - "react-router": "6.30.1" - }, "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "react": ">=16.8", - "react-dom": ">=16.8" + "node": ">=18" } }, "node_modules/readable-stream": { @@ -17181,6 +17171,12 @@ "node": ">= 0.8.0" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", diff --git a/web/package.json b/web/package.json index 26991b3891..8219b8810a 100644 --- a/web/package.json +++ b/web/package.json @@ -101,7 +101,7 @@ "radashi": "^12.6.2", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-router-dom": "^6.30.1", + "react-router": "^7.9.5", "sprintf-js": "^1.1.3", "xbytes": "^1.9.1" }, From c1060f2e32aec223aebc1377fe59e9c07d653f69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Mon, 10 Nov 2025 15:04:35 +0000 Subject: [PATCH 02/11] web: adapt code to React Router v7 By using react-router and react-router/dom imports as stated in documentation https://reactrouter.com/upgrading/v6#upgrade-to-v7 --- web/src/App.tsx | 2 +- web/src/Protected.tsx | 2 +- web/src/components/core/ChangeProductOption.tsx | 2 +- web/src/components/core/InstallButton.tsx | 2 +- web/src/components/core/InstallationFinished.tsx | 2 +- web/src/components/core/InstallationProgress.tsx | 2 +- web/src/components/core/InstallerOptions.tsx | 2 +- web/src/components/core/Link.tsx | 2 +- web/src/components/core/LoginPage.tsx | 2 +- web/src/components/core/Page.tsx | 2 +- web/src/components/l10n/KeyboardSelection.tsx | 2 +- web/src/components/l10n/LocaleSelection.tsx | 2 +- web/src/components/l10n/TimezoneSelection.tsx | 2 +- web/src/components/layout/Header.tsx | 2 +- web/src/components/layout/Layout.tsx | 2 +- web/src/components/layout/Sidebar.tsx | 2 +- web/src/components/network/BindingSettingsForm.tsx | 2 +- web/src/components/network/IpSettingsForm.tsx | 2 +- web/src/components/network/WifiNetworkPage.tsx | 2 +- web/src/components/network/WiredConnectionPage.tsx | 2 +- web/src/components/network/WiredConnectionsList.tsx | 2 +- web/src/components/product/ProductRegistrationAlert.tsx | 2 +- web/src/components/product/ProductSelectionPage.tsx | 2 +- web/src/components/product/ProductSelectionProgress.tsx | 2 +- web/src/components/storage/BootSelection.tsx | 2 +- web/src/components/storage/ConfigEditorMenu.tsx | 2 +- web/src/components/storage/ConfigureDeviceMenu.tsx | 2 +- web/src/components/storage/EncryptionSettingsPage.tsx | 2 +- web/src/components/storage/FilesystemMenu.tsx | 2 +- web/src/components/storage/FormattableDevicePage.tsx | 2 +- web/src/components/storage/LogicalVolumePage.tsx | 2 +- web/src/components/storage/LvmPage.tsx | 2 +- web/src/components/storage/MountPathMenuItem.tsx | 2 +- web/src/components/storage/PartitionPage.tsx | 2 +- web/src/components/storage/PartitionsMenu.tsx | 2 +- web/src/components/storage/Progress.tsx | 2 +- web/src/components/storage/ProposalPage.tsx | 2 +- web/src/components/storage/SpacePolicyMenu.tsx | 2 +- web/src/components/storage/SpacePolicySelection.tsx | 2 +- web/src/components/storage/UnusedMenu.tsx | 2 +- web/src/components/storage/VolumeGroupEditor.tsx | 2 +- web/src/components/storage/zfcp/ZFCPDiskActivationPage.tsx | 2 +- web/src/components/storage/zfcp/ZFCPPage.tsx | 2 +- web/src/components/users/FirstUserForm.tsx | 2 +- web/src/components/users/RootUserForm.tsx | 2 +- web/src/index.tsx | 2 +- web/src/router.tsx | 2 +- web/src/routes/storage.tsx | 2 +- web/src/types/routes.ts | 2 +- web/src/utils.ts | 2 +- 50 files changed, 50 insertions(+), 50 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index 258cf9883b..161950a3ab 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -21,7 +21,7 @@ */ import React, { useEffect } from "react"; -import { Navigate, Outlet, useLocation } from "react-router-dom"; +import { Navigate, Outlet, useLocation } from "react-router"; import { Loading } from "~/components/layout"; import { useProduct, useProductChanges } from "~/queries/software"; import { useProposalChanges } from "~/queries/proposal"; diff --git a/web/src/Protected.tsx b/web/src/Protected.tsx index bf8c468a9e..b3aa43fc9c 100644 --- a/web/src/Protected.tsx +++ b/web/src/Protected.tsx @@ -21,7 +21,7 @@ */ import React from "react"; -import { Navigate, Outlet } from "react-router-dom"; +import { Navigate, Outlet } from "react-router"; import { useAuth } from "./context/auth"; import { AppProviders } from "./context/app"; diff --git a/web/src/components/core/ChangeProductOption.tsx b/web/src/components/core/ChangeProductOption.tsx index b5863e4596..2d66d7f362 100644 --- a/web/src/components/core/ChangeProductOption.tsx +++ b/web/src/components/core/ChangeProductOption.tsx @@ -22,7 +22,7 @@ import React from "react"; import { DropdownItem, DropdownItemProps } from "@patternfly/react-core"; -import { useHref, useLocation } from "react-router-dom"; +import { useHref, useLocation } from "react-router"; import { useProduct, useRegistration } from "~/queries/software"; import { PRODUCT as PATHS, SIDE_PATHS } from "~/routes/paths"; import { _ } from "~/i18n"; diff --git a/web/src/components/core/InstallButton.tsx b/web/src/components/core/InstallButton.tsx index 2323a5d6cf..2665b9cb21 100644 --- a/web/src/components/core/InstallButton.tsx +++ b/web/src/components/core/InstallButton.tsx @@ -26,7 +26,7 @@ import { Popup } from "~/components/core"; import { startInstallation } from "~/api/manager"; import { useAllIssues } from "~/queries/issues"; import { IssueSeverity } from "~/types/issues"; -import { useLocation } from "react-router-dom"; +import { useLocation } from "react-router"; import { SIDE_PATHS } from "~/routes/paths"; import { _ } from "~/i18n"; import { Icon } from "../layout"; diff --git a/web/src/components/core/InstallationFinished.tsx b/web/src/components/core/InstallationFinished.tsx index 619f4e5eed..eb81183a4f 100644 --- a/web/src/components/core/InstallationFinished.tsx +++ b/web/src/components/core/InstallationFinished.tsx @@ -37,7 +37,7 @@ import { GridItem, Stack, } from "@patternfly/react-core"; -import { Navigate, useNavigate } from "react-router-dom"; +import { Navigate, useNavigate } from "react-router"; import { Icon } from "~/components/layout"; import alignmentStyles from "@patternfly/react-styles/css/utilities/Alignment/alignment"; import { useInstallerStatus } from "~/queries/status"; diff --git a/web/src/components/core/InstallationProgress.tsx b/web/src/components/core/InstallationProgress.tsx index 6197d8720d..5c8f20cd80 100644 --- a/web/src/components/core/InstallationProgress.tsx +++ b/web/src/components/core/InstallationProgress.tsx @@ -25,7 +25,7 @@ import { _ } from "~/i18n"; import ProgressReport from "./ProgressReport"; import { InstallationPhase } from "~/types/status"; import { ROOT as PATHS } from "~/routes/paths"; -import { Navigate } from "react-router-dom"; +import { Navigate } from "react-router"; import { useInstallerStatus, useInstallerStatusChanges } from "~/queries/status"; function InstallationProgress() { diff --git a/web/src/components/core/InstallerOptions.tsx b/web/src/components/core/InstallerOptions.tsx index 3b97d0c31c..89b771eddb 100644 --- a/web/src/components/core/InstallerOptions.tsx +++ b/web/src/components/core/InstallerOptions.tsx @@ -32,7 +32,7 @@ */ import React, { useReducer } from "react"; -import { useHref, useLocation } from "react-router-dom"; +import { useHref, useLocation } from "react-router"; import { Button, ButtonProps, diff --git a/web/src/components/core/Link.tsx b/web/src/components/core/Link.tsx index d05401db7b..05847470c9 100644 --- a/web/src/components/core/Link.tsx +++ b/web/src/components/core/Link.tsx @@ -22,7 +22,7 @@ import React from "react"; import { Button, ButtonProps } from "@patternfly/react-core"; -import { To, useHref, useLinkClickHandler } from "react-router-dom"; +import { To, useHref, useLinkClickHandler } from "react-router"; export type LinkProps = Omit & { /** The target route */ diff --git a/web/src/components/core/LoginPage.tsx b/web/src/components/core/LoginPage.tsx index fb9e788884..a9737787e9 100644 --- a/web/src/components/core/LoginPage.tsx +++ b/web/src/components/core/LoginPage.tsx @@ -21,7 +21,7 @@ */ import React, { useState } from "react"; -import { Navigate } from "react-router-dom"; +import { Navigate } from "react-router"; import { ActionGroup, Alert, diff --git a/web/src/components/core/Page.tsx b/web/src/components/core/Page.tsx index 20674f8ecb..3e6e41ef84 100644 --- a/web/src/components/core/Page.tsx +++ b/web/src/components/core/Page.tsx @@ -44,7 +44,7 @@ import { ProductRegistrationAlert } from "~/components/product"; import Link, { LinkProps } from "~/components/core/Link"; import textStyles from "@patternfly/react-styles/css/utilities/Text/text"; import flexStyles from "@patternfly/react-styles/css/utilities/Flex/flex"; -import { useLocation, useNavigate } from "react-router-dom"; +import { useLocation, useNavigate } from "react-router"; import { isEmpty, isObject } from "radashi"; import { SIDE_PATHS } from "~/routes/paths"; import { _ } from "~/i18n"; diff --git a/web/src/components/l10n/KeyboardSelection.tsx b/web/src/components/l10n/KeyboardSelection.tsx index 6b34273243..e685692cfa 100644 --- a/web/src/components/l10n/KeyboardSelection.tsx +++ b/web/src/components/l10n/KeyboardSelection.tsx @@ -22,7 +22,7 @@ import React, { useState } from "react"; import { Content, Flex, Form, FormGroup, Radio } from "@patternfly/react-core"; -import { useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router"; import { ListSearch, Page } from "~/components/core"; import { updateConfig } from "~/api/api"; import { useSystem } from "~/queries/system"; diff --git a/web/src/components/l10n/LocaleSelection.tsx b/web/src/components/l10n/LocaleSelection.tsx index 3243a83969..ac2e19fcf8 100644 --- a/web/src/components/l10n/LocaleSelection.tsx +++ b/web/src/components/l10n/LocaleSelection.tsx @@ -22,7 +22,7 @@ import React, { useState } from "react"; import { Content, Flex, Form, FormGroup, Radio } from "@patternfly/react-core"; -import { useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router"; import { ListSearch, Page } from "~/components/core"; import { updateConfig } from "~/api/api"; import { useSystem } from "~/queries/system"; diff --git a/web/src/components/l10n/TimezoneSelection.tsx b/web/src/components/l10n/TimezoneSelection.tsx index 5656a8e795..b2da31b0a9 100644 --- a/web/src/components/l10n/TimezoneSelection.tsx +++ b/web/src/components/l10n/TimezoneSelection.tsx @@ -22,7 +22,7 @@ import React, { useState } from "react"; import { Content, Flex, Form, FormGroup, Radio } from "@patternfly/react-core"; -import { useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router"; import { ListSearch, Page } from "~/components/core"; import { Timezone } from "~/types/l10n"; import { updateConfig } from "~/api/api"; diff --git a/web/src/components/layout/Header.tsx b/web/src/components/layout/Header.tsx index 61be78df71..3538f92f82 100644 --- a/web/src/components/layout/Header.tsx +++ b/web/src/components/layout/Header.tsx @@ -39,7 +39,7 @@ import { ToolbarGroup, ToolbarItem, } from "@patternfly/react-core"; -import { useMatches } from "react-router-dom"; +import { useMatches } from "react-router"; import { Icon } from "~/components/layout"; import { useProduct } from "~/queries/software"; import { Route } from "~/types/routes"; diff --git a/web/src/components/layout/Layout.tsx b/web/src/components/layout/Layout.tsx index 5d7a89d522..968ba9a508 100644 --- a/web/src/components/layout/Layout.tsx +++ b/web/src/components/layout/Layout.tsx @@ -21,7 +21,7 @@ */ import React, { Suspense, useRef, useState } from "react"; -import { Outlet, useLocation } from "react-router-dom"; +import { Outlet, useLocation } from "react-router"; import { Masthead, Page, PageProps } from "@patternfly/react-core"; import { Questions } from "~/components/questions"; import Header, { HeaderProps } from "~/components/layout/Header"; diff --git a/web/src/components/layout/Sidebar.tsx b/web/src/components/layout/Sidebar.tsx index b4291fae99..bfc2154064 100644 --- a/web/src/components/layout/Sidebar.tsx +++ b/web/src/components/layout/Sidebar.tsx @@ -21,7 +21,7 @@ */ import React from "react"; -import { NavLink, useLocation } from "react-router-dom"; +import { NavLink, useLocation } from "react-router"; import { Nav, NavItem, diff --git a/web/src/components/network/BindingSettingsForm.tsx b/web/src/components/network/BindingSettingsForm.tsx index 1fdb8d98d5..d120a7a8e5 100644 --- a/web/src/components/network/BindingSettingsForm.tsx +++ b/web/src/components/network/BindingSettingsForm.tsx @@ -21,7 +21,7 @@ */ import React, { useReducer } from "react"; -import { useNavigate, useParams } from "react-router-dom"; +import { useNavigate, useParams } from "react-router"; import { ActionGroup, Content, diff --git a/web/src/components/network/IpSettingsForm.tsx b/web/src/components/network/IpSettingsForm.tsx index 0b0d87d36e..d23d25f951 100644 --- a/web/src/components/network/IpSettingsForm.tsx +++ b/web/src/components/network/IpSettingsForm.tsx @@ -21,7 +21,7 @@ */ import React, { useState } from "react"; -import { useNavigate, useParams } from "react-router-dom"; +import { useNavigate, useParams } from "react-router"; import { ActionGroup, Alert, diff --git a/web/src/components/network/WifiNetworkPage.tsx b/web/src/components/network/WifiNetworkPage.tsx index 7ff06ff184..bbc4d13a0c 100644 --- a/web/src/components/network/WifiNetworkPage.tsx +++ b/web/src/components/network/WifiNetworkPage.tsx @@ -21,7 +21,7 @@ */ import React from "react"; -import { useParams } from "react-router-dom"; +import { useParams } from "react-router"; import { Content, EmptyState, diff --git a/web/src/components/network/WiredConnectionPage.tsx b/web/src/components/network/WiredConnectionPage.tsx index 2cb251b30f..affc71addf 100644 --- a/web/src/components/network/WiredConnectionPage.tsx +++ b/web/src/components/network/WiredConnectionPage.tsx @@ -21,7 +21,7 @@ */ import React from "react"; -import { useParams } from "react-router-dom"; +import { useParams } from "react-router"; import { Content, EmptyState, diff --git a/web/src/components/network/WiredConnectionsList.tsx b/web/src/components/network/WiredConnectionsList.tsx index 873c765bb0..5f539fa017 100644 --- a/web/src/components/network/WiredConnectionsList.tsx +++ b/web/src/components/network/WiredConnectionsList.tsx @@ -21,7 +21,7 @@ */ import React, { useId } from "react"; -import { useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router"; import { Content, DataList, diff --git a/web/src/components/product/ProductRegistrationAlert.tsx b/web/src/components/product/ProductRegistrationAlert.tsx index 1e6660670d..199dc88465 100644 --- a/web/src/components/product/ProductRegistrationAlert.tsx +++ b/web/src/components/product/ProductRegistrationAlert.tsx @@ -22,7 +22,7 @@ import React from "react"; import { Alert } from "@patternfly/react-core"; -import { useLocation } from "react-router-dom"; +import { useLocation } from "react-router"; import { Link } from "~/components/core"; import { useProduct } from "~/queries/software"; import { REGISTRATION, SIDE_PATHS } from "~/routes/paths"; diff --git a/web/src/components/product/ProductSelectionPage.tsx b/web/src/components/product/ProductSelectionPage.tsx index 0568882e9b..c65d723be5 100644 --- a/web/src/components/product/ProductSelectionPage.tsx +++ b/web/src/components/product/ProductSelectionPage.tsx @@ -37,7 +37,7 @@ import { Stack, StackItem, } from "@patternfly/react-core"; -import { Navigate, useNavigate } from "react-router-dom"; +import { Navigate, useNavigate } from "react-router"; import { Page } from "~/components/core"; import { useConfigMutation, useProduct, useRegistration } from "~/queries/software"; import pfTextStyles from "@patternfly/react-styles/css/utilities/Text/text"; diff --git a/web/src/components/product/ProductSelectionProgress.tsx b/web/src/components/product/ProductSelectionProgress.tsx index 19525cfd8c..b971b337ce 100644 --- a/web/src/components/product/ProductSelectionProgress.tsx +++ b/web/src/components/product/ProductSelectionProgress.tsx @@ -21,7 +21,7 @@ */ import React from "react"; -import { Navigate } from "react-router-dom"; +import { Navigate } from "react-router"; import { Page, ProgressReport } from "~/components/core"; import { useProduct } from "~/queries/software"; import { useInstallerStatus } from "~/queries/status"; diff --git a/web/src/components/storage/BootSelection.tsx b/web/src/components/storage/BootSelection.tsx index d167d584b4..b0aeb21d86 100644 --- a/web/src/components/storage/BootSelection.tsx +++ b/web/src/components/storage/BootSelection.tsx @@ -21,7 +21,7 @@ */ import React, { useEffect, useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router"; import { ActionGroup, Content, Form, FormGroup, Radio, Stack } from "@patternfly/react-core"; import { DevicesFormSelect } from "~/components/storage"; import { Page, SubtleContent } from "~/components/core"; diff --git a/web/src/components/storage/ConfigEditorMenu.tsx b/web/src/components/storage/ConfigEditorMenu.tsx index 0437893e66..fa379a9ede 100644 --- a/web/src/components/storage/ConfigEditorMenu.tsx +++ b/web/src/components/storage/ConfigEditorMenu.tsx @@ -21,7 +21,7 @@ */ import React, { useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router"; import { _ } from "~/i18n"; import { Dropdown, diff --git a/web/src/components/storage/ConfigureDeviceMenu.tsx b/web/src/components/storage/ConfigureDeviceMenu.tsx index fe01608c0e..8042a7283e 100644 --- a/web/src/components/storage/ConfigureDeviceMenu.tsx +++ b/web/src/components/storage/ConfigureDeviceMenu.tsx @@ -21,7 +21,7 @@ */ import React, { useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router"; import MenuButton, { MenuButtonItem } from "~/components/core/MenuButton"; import { Divider, MenuItemProps } from "@patternfly/react-core"; import { useAvailableDevices } from "~/hooks/storage/system"; diff --git a/web/src/components/storage/EncryptionSettingsPage.tsx b/web/src/components/storage/EncryptionSettingsPage.tsx index 8709533a38..d11b482348 100644 --- a/web/src/components/storage/EncryptionSettingsPage.tsx +++ b/web/src/components/storage/EncryptionSettingsPage.tsx @@ -21,7 +21,7 @@ */ import React, { useEffect, useState, useRef } from "react"; -import { useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router"; import { ActionGroup, Alert, Checkbox, Content, Form } from "@patternfly/react-core"; import { NestedContent, Page, PasswordAndConfirmationInput } from "~/components/core"; import PasswordCheck from "~/components/users/PasswordCheck"; diff --git a/web/src/components/storage/FilesystemMenu.tsx b/web/src/components/storage/FilesystemMenu.tsx index b0c4d345a1..11dfcb1121 100644 --- a/web/src/components/storage/FilesystemMenu.tsx +++ b/web/src/components/storage/FilesystemMenu.tsx @@ -22,7 +22,7 @@ import React, { useId } from "react"; import { Divider, Flex } from "@patternfly/react-core"; -import { useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router"; import Text from "~/components/core/Text"; import MenuHeader from "~/components/core/MenuHeader"; import MenuButton from "~/components/core/MenuButton"; diff --git a/web/src/components/storage/FormattableDevicePage.tsx b/web/src/components/storage/FormattableDevicePage.tsx index b93e42b72f..6d21d236d0 100644 --- a/web/src/components/storage/FormattableDevicePage.tsx +++ b/web/src/components/storage/FormattableDevicePage.tsx @@ -26,7 +26,7 @@ */ import React, { useId } from "react"; -import { useParams, useNavigate } from "react-router-dom"; +import { useParams, useNavigate } from "react-router"; import { ActionGroup, Content, diff --git a/web/src/components/storage/LogicalVolumePage.tsx b/web/src/components/storage/LogicalVolumePage.tsx index f948904ecb..46f9359c28 100644 --- a/web/src/components/storage/LogicalVolumePage.tsx +++ b/web/src/components/storage/LogicalVolumePage.tsx @@ -27,7 +27,7 @@ */ import React, { useCallback, useEffect, useId, useMemo, useState } from "react"; -import { useParams, useNavigate } from "react-router-dom"; +import { useParams, useNavigate } from "react-router"; import { ActionGroup, Content, diff --git a/web/src/components/storage/LvmPage.tsx b/web/src/components/storage/LvmPage.tsx index eaee72523b..b3a4b03311 100644 --- a/web/src/components/storage/LvmPage.tsx +++ b/web/src/components/storage/LvmPage.tsx @@ -21,7 +21,7 @@ */ import React, { useState, useEffect, useMemo } from "react"; -import { useParams, useNavigate } from "react-router-dom"; +import { useParams, useNavigate } from "react-router"; import { ActionGroup, Alert, diff --git a/web/src/components/storage/MountPathMenuItem.tsx b/web/src/components/storage/MountPathMenuItem.tsx index 4a667652d2..2f18118f23 100644 --- a/web/src/components/storage/MountPathMenuItem.tsx +++ b/web/src/components/storage/MountPathMenuItem.tsx @@ -21,7 +21,7 @@ */ import React from "react"; -import { useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router"; import * as partitionUtils from "~/components/storage/utils/partition"; import { Icon } from "~/components/layout"; import { MenuItem, MenuItemAction } from "@patternfly/react-core"; diff --git a/web/src/components/storage/PartitionPage.tsx b/web/src/components/storage/PartitionPage.tsx index 5e0d039c46..5d07970230 100644 --- a/web/src/components/storage/PartitionPage.tsx +++ b/web/src/components/storage/PartitionPage.tsx @@ -21,7 +21,7 @@ */ import React, { useId } from "react"; -import { useParams, useNavigate } from "react-router-dom"; +import { useParams, useNavigate } from "react-router"; import { ActionGroup, Content, diff --git a/web/src/components/storage/PartitionsMenu.tsx b/web/src/components/storage/PartitionsMenu.tsx index 6421f3fede..952b8c8df1 100644 --- a/web/src/components/storage/PartitionsMenu.tsx +++ b/web/src/components/storage/PartitionsMenu.tsx @@ -22,7 +22,7 @@ import React, { useId } from "react"; import { Divider, Stack, Flex } from "@patternfly/react-core"; -import { useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router"; import Text from "~/components/core/Text"; import MenuButton from "~/components/core/MenuButton"; import MenuHeader from "~/components/core/MenuHeader"; diff --git a/web/src/components/storage/Progress.tsx b/web/src/components/storage/Progress.tsx index 3ece2a79b9..ce445bcb20 100644 --- a/web/src/components/storage/Progress.tsx +++ b/web/src/components/storage/Progress.tsx @@ -33,7 +33,7 @@ import { _ } from "~/i18n"; import { useProgress, useProgressChanges, useResetProgress } from "~/queries/progress"; import sizingStyles from "@patternfly/react-styles/css/utilities/Sizing/sizing"; import { STORAGE } from "~/routes/paths"; -import { useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router"; type StepProps = { id: string; diff --git a/web/src/components/storage/ProposalPage.tsx b/web/src/components/storage/ProposalPage.tsx index ef1f65ef86..a2f496c7a9 100644 --- a/web/src/components/storage/ProposalPage.tsx +++ b/web/src/components/storage/ProposalPage.tsx @@ -59,7 +59,7 @@ import { useSystemErrors, useConfigErrors } from "~/queries/issues"; import { STORAGE as PATHS } from "~/routes/paths"; import { _, n_ } from "~/i18n"; import { useProgress, useProgressChanges } from "~/queries/progress"; -import { useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router"; function InvalidConfigEmptyState(): React.ReactNode { const errors = useConfigErrors("storage"); diff --git a/web/src/components/storage/SpacePolicyMenu.tsx b/web/src/components/storage/SpacePolicyMenu.tsx index bc39a47d39..acb4fff49f 100644 --- a/web/src/components/storage/SpacePolicyMenu.tsx +++ b/web/src/components/storage/SpacePolicyMenu.tsx @@ -24,7 +24,7 @@ import React from "react"; import { Flex } from "@patternfly/react-core"; import MenuButton from "~/components/core/MenuButton"; import Text from "~/components/core/Text"; -import { useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router"; import { useSetSpacePolicy } from "~/hooks/storage/space-policy"; import { SPACE_POLICIES } from "~/components/storage/utils"; import { apiModel } from "~/api/storage/types"; diff --git a/web/src/components/storage/SpacePolicySelection.tsx b/web/src/components/storage/SpacePolicySelection.tsx index 37d7ed02cc..0a134935e2 100644 --- a/web/src/components/storage/SpacePolicySelection.tsx +++ b/web/src/components/storage/SpacePolicySelection.tsx @@ -22,7 +22,7 @@ import React, { useState } from "react"; import { ActionGroup, Content, Form } from "@patternfly/react-core"; -import { useNavigate, useParams } from "react-router-dom"; +import { useNavigate, useParams } from "react-router"; import { Page } from "~/components/core"; import { SpaceActionsTable } from "~/components/storage"; import { deviceChildren } from "~/components/storage/utils"; diff --git a/web/src/components/storage/UnusedMenu.tsx b/web/src/components/storage/UnusedMenu.tsx index 2c8cc53259..b9fc1bc190 100644 --- a/web/src/components/storage/UnusedMenu.tsx +++ b/web/src/components/storage/UnusedMenu.tsx @@ -22,7 +22,7 @@ import React, { useId } from "react"; import { Flex } from "@patternfly/react-core"; -import { useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router"; import Text from "~/components/core/Text"; import MenuButton from "~/components/core/MenuButton"; import { STORAGE as PATHS } from "~/routes/paths"; diff --git a/web/src/components/storage/VolumeGroupEditor.tsx b/web/src/components/storage/VolumeGroupEditor.tsx index 1a918ab9dc..774c6ce29a 100644 --- a/web/src/components/storage/VolumeGroupEditor.tsx +++ b/web/src/components/storage/VolumeGroupEditor.tsx @@ -22,7 +22,7 @@ import React, { useId } from "react"; import { Divider, Flex, Title } from "@patternfly/react-core"; -import { useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router"; import Link from "~/components/core/Link"; import Text from "~/components/core/Text"; import MenuButton from "~/components/core/MenuButton"; diff --git a/web/src/components/storage/zfcp/ZFCPDiskActivationPage.tsx b/web/src/components/storage/zfcp/ZFCPDiskActivationPage.tsx index 1f4d8a0ce4..3fd0b05ccd 100644 --- a/web/src/components/storage/zfcp/ZFCPDiskActivationPage.tsx +++ b/web/src/components/storage/zfcp/ZFCPDiskActivationPage.tsx @@ -28,7 +28,7 @@ import { useCancellablePromise } from "~/hooks/use-cancellable-promise"; import { LUNInfo } from "~/types/zfcp"; import { activateZFCPDisk } from "~/api/storage/zfcp"; import { PATHS } from "~/routes/storage"; -import { useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router"; import ZFCPDiskForm from "./ZFCPDiskForm"; import { useZFCPControllersChanges, useZFCPDisksChanges } from "~/queries/storage/zfcp"; diff --git a/web/src/components/storage/zfcp/ZFCPPage.tsx b/web/src/components/storage/zfcp/ZFCPPage.tsx index 7e3ca849b6..dd8a28ef36 100644 --- a/web/src/components/storage/zfcp/ZFCPPage.tsx +++ b/web/src/components/storage/zfcp/ZFCPPage.tsx @@ -43,7 +43,7 @@ import ZFCPDisksTable from "./ZFCPDisksTable"; import ZFCPControllersTable from "./ZFCPControllersTable"; import { probeZFCP } from "~/api/storage/zfcp"; import { STORAGE as PATHS } from "~/routes/paths"; -import { useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router"; import { inactiveLuns } from "~/utils/zfcp"; const LUNScanInfo = () => { diff --git a/web/src/components/users/FirstUserForm.tsx b/web/src/components/users/FirstUserForm.tsx index d4989b5ba4..d15f91b04e 100644 --- a/web/src/components/users/FirstUserForm.tsx +++ b/web/src/components/users/FirstUserForm.tsx @@ -34,7 +34,7 @@ import { ActionGroup, Button, } from "@patternfly/react-core"; -import { useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router"; import { Loading } from "~/components/layout"; import { PasswordAndConfirmationInput, Page } from "~/components/core"; import PasswordCheck from "~/components/users/PasswordCheck"; diff --git a/web/src/components/users/RootUserForm.tsx b/web/src/components/users/RootUserForm.tsx index 4b74b03fa0..993f2e2e11 100644 --- a/web/src/components/users/RootUserForm.tsx +++ b/web/src/components/users/RootUserForm.tsx @@ -31,7 +31,7 @@ import { Form, FormGroup, } from "@patternfly/react-core"; -import { useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router"; import { NestedContent, Page, PasswordAndConfirmationInput } from "~/components/core"; import { useRootUser, useRootUserMutation } from "~/queries/users"; import { RootUser } from "~/types/users"; diff --git a/web/src/index.tsx b/web/src/index.tsx index e678bef2a8..b164322cd5 100644 --- a/web/src/index.tsx +++ b/web/src/index.tsx @@ -22,7 +22,7 @@ import React from "react"; import { createRoot } from "react-dom/client"; -import { RouterProvider } from "react-router-dom"; +import { RouterProvider } from "react-router/dom"; import { RootProviders } from "~/context/root"; import { router } from "~/router"; diff --git a/web/src/router.tsx b/web/src/router.tsx index 61912d1cce..81a9e73ce1 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -21,7 +21,7 @@ */ import React from "react"; -import { createHashRouter, Outlet } from "react-router-dom"; +import { createHashRouter, Outlet } from "react-router"; import App from "~/App"; import Protected from "~/Protected"; import { FullLayout, PlainLayout } from "~/components/layout"; diff --git a/web/src/routes/storage.tsx b/web/src/routes/storage.tsx index 82e9b1f4ca..44bfc2330e 100644 --- a/web/src/routes/storage.tsx +++ b/web/src/routes/storage.tsx @@ -21,7 +21,7 @@ */ import React from "react"; -import { redirect } from "react-router-dom"; +import { redirect } from "react-router"; import { N_ } from "~/i18n"; import { Route } from "~/types/routes"; import BootSelection from "~/components/storage/BootSelection"; diff --git a/web/src/types/routes.ts b/web/src/types/routes.ts index fe98027843..e73fa36e51 100644 --- a/web/src/types/routes.ts +++ b/web/src/types/routes.ts @@ -20,7 +20,7 @@ * find current contact information at www.suse.com. */ -import { RouteObject } from "react-router-dom"; +import { RouteObject } from "react-router"; type RouteHandle = { /** Text to be used as label when building a link from route information */ diff --git a/web/src/utils.ts b/web/src/utils.ts index 3b0865998e..51bcce1950 100644 --- a/web/src/utils.ts +++ b/web/src/utils.ts @@ -21,7 +21,7 @@ */ import { mapEntries } from "radashi"; -import { generatePath } from "react-router-dom"; +import { generatePath } from "react-router"; import { ISortBy, sort } from "fast-sort"; /** From 706160d7f460670df7b093f004719975db7baf51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Mon, 10 Nov 2025 15:08:13 +0000 Subject: [PATCH 03/11] web: start adapting test to React Router v7 By using the right imports/mocks --- web/src/components/l10n/KeyboardSelection.test.tsx | 4 ++-- web/src/components/l10n/LocaleSelection.test.tsx | 4 ++-- web/src/components/l10n/TimezoneSelection.test.tsx | 4 ++-- web/src/components/storage/BootSelection.test.tsx | 4 ++-- web/src/test-utils.tsx | 8 ++++---- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/web/src/components/l10n/KeyboardSelection.test.tsx b/web/src/components/l10n/KeyboardSelection.test.tsx index 3c5ec93c03..7b256dc184 100644 --- a/web/src/components/l10n/KeyboardSelection.test.tsx +++ b/web/src/components/l10n/KeyboardSelection.test.tsx @@ -53,8 +53,8 @@ jest.mock("~/api/api", () => ({ updateConfig: (config) => mockUpdateConfigFn(config), })); -jest.mock("react-router-dom", () => ({ - ...jest.requireActual("react-router-dom"), +jest.mock("react-router", () => ({ + ...jest.requireActual("react-router"), useNavigate: () => mockNavigateFn, })); diff --git a/web/src/components/l10n/LocaleSelection.test.tsx b/web/src/components/l10n/LocaleSelection.test.tsx index 0bf485e541..1507979541 100644 --- a/web/src/components/l10n/LocaleSelection.test.tsx +++ b/web/src/components/l10n/LocaleSelection.test.tsx @@ -53,8 +53,8 @@ jest.mock("~/api/api", () => ({ updateConfig: (config) => mockUpdateConfigFn(config), })); -jest.mock("react-router-dom", () => ({ - ...jest.requireActual("react-router-dom"), +jest.mock("react-router", () => ({ + ...jest.requireActual("react-router"), useNavigate: () => mockNavigateFn, })); diff --git a/web/src/components/l10n/TimezoneSelection.test.tsx b/web/src/components/l10n/TimezoneSelection.test.tsx index 72cfb9b57c..bb8a79f8d5 100644 --- a/web/src/components/l10n/TimezoneSelection.test.tsx +++ b/web/src/components/l10n/TimezoneSelection.test.tsx @@ -60,8 +60,8 @@ jest.mock("~/queries/proposal", () => ({ useProposal: () => ({ l10n: { timezones, timezone: "Europe/Berlin" } }), })); -jest.mock("react-router-dom", () => ({ - ...jest.requireActual("react-router-dom"), +jest.mock("react-router", () => ({ + ...jest.requireActual("react-router"), useNavigate: () => mockNavigateFn, })); diff --git a/web/src/components/storage/BootSelection.test.tsx b/web/src/components/storage/BootSelection.test.tsx index 12f8c93279..7cba257175 100644 --- a/web/src/components/storage/BootSelection.test.tsx +++ b/web/src/components/storage/BootSelection.test.tsx @@ -98,8 +98,8 @@ const sdc: StorageDevice = { udevPaths: ["pci-0000:00-19"], }; -jest.mock("react-router-dom", () => ({ - ...jest.requireActual("react-router-dom"), +jest.mock("react-router", () => ({ + ...jest.requireActual("react-router"), useNavigate: () => mockNavigateFn, })); diff --git a/web/src/test-utils.tsx b/web/src/test-utils.tsx index 6df15d8af3..b2d62a2057 100644 --- a/web/src/test-utils.tsx +++ b/web/src/test-utils.tsx @@ -29,7 +29,7 @@ */ import React from "react"; -import { MemoryRouter, useParams } from "react-router-dom"; +import { MemoryRouter, useParams } from "react-router"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import userEvent from "@testing-library/user-event"; import { render, within } from "@testing-library/react"; @@ -87,9 +87,9 @@ const mockRoutes = (...routes) => initialRoutes.mockReturnValueOnce(routes); */ const mockParams = (params: ReturnType) => (paramsMock = params); -// Centralize the react-router-dom mock here -jest.mock("react-router-dom", () => ({ - ...jest.requireActual("react-router-dom"), +// Centralize the react-router mock here +jest.mock("react-router", () => ({ + ...jest.requireActual("react-router"), useHref: (to) => to, useNavigate: () => mockNavigateFn, useMatches: () => [], From 3fb632a169b63adfdb786a8ce6da5d94a3f741b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Mon, 10 Nov 2025 15:09:20 +0000 Subject: [PATCH 04/11] web: fix ReferenceError after React Router v7 migration After migrating to React Router v7, the test suite fails with the following error: > ReferenceError: TextEncoder is not defined This appears to be due to an unimplemented TextEncoder in jsdom. To resolve this, we follow the same workaround as React Router, as outlined in https://github.com/remix-run/react-router/issues/12363#issuecomment-2496226528 --- web/src/setupTests.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/web/src/setupTests.ts b/web/src/setupTests.ts index 1dd407a63e..04773a7b04 100644 --- a/web/src/setupTests.ts +++ b/web/src/setupTests.ts @@ -3,3 +3,11 @@ // expect(element).toHaveTextContent(/react/i) // learn more: https://github.com/testing-library/jest-dom import "@testing-library/jest-dom"; +import { TextDecoder, TextEncoder } from "util"; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +if (!globalThis.TextEncoder || !globalThis.TextDecoder) { + globalThis.TextEncoder = TextEncoder; + globalThis.TextDecoder = TextDecoder; +} From 1878d878ea1ca5318120e527f9ce911f321b6884 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Mon, 10 Nov 2025 15:34:29 +0000 Subject: [PATCH 05/11] web: stop using custom generateEncodedPath It was introduced to work around React Router v6's issue of not encoding parameters in the generatePath function. However, after migrating to v7, tests began failing due to double encoding. This revealed that React Router v7 already handles encoding parameters in generatePath. See https://github.com/remix-run/react-router/pull/13530 Thus, this commit reverts the changes made in https://github.com/agama-project/agama/pull/2576 --- .../network/WifiConnectionDetails.tsx | 4 +- .../components/network/WifiNetworksList.tsx | 5 +- .../network/WiredConnectionDetails.tsx | 6 +-- .../network/WiredConnectionsList.tsx | 5 +- web/src/components/storage/FilesystemMenu.tsx | 5 +- web/src/components/storage/PartitionsMenu.tsx | 7 ++- .../components/storage/SpacePolicyMenu.tsx | 5 +- web/src/components/storage/UnusedMenu.tsx | 7 ++- .../components/storage/VolumeGroupEditor.tsx | 9 ++-- web/src/utils.test.ts | 53 +------------------ web/src/utils.ts | 22 -------- 11 files changed, 24 insertions(+), 104 deletions(-) diff --git a/web/src/components/network/WifiConnectionDetails.tsx b/web/src/components/network/WifiConnectionDetails.tsx index 846c027963..3e52572baa 100644 --- a/web/src/components/network/WifiConnectionDetails.tsx +++ b/web/src/components/network/WifiConnectionDetails.tsx @@ -32,12 +32,12 @@ import { GridItem, Stack, } from "@patternfly/react-core"; +import { generatePath } from "react-router"; import { Link, Page } from "~/components/core"; import InstallationOnlySwitch from "./InstallationOnlySwitch"; import { Device, WifiNetwork } from "~/types/network"; import { NETWORK } from "~/routes/paths"; import { formatIp } from "~/utils/network"; -import { generateEncodedPath } from "~/utils"; import { _ } from "~/i18n"; const NetworkDetails = ({ network }: { network: WifiNetwork }) => { @@ -96,7 +96,7 @@ const IpDetails = ({ device, settings }: { device: Device; settings: WifiNetwork title={_("IP settings")} pfCardProps={{ isPlain: false, isFullHeight: false }} actions={ - + {_("Edit")} } diff --git a/web/src/components/network/WifiNetworksList.tsx b/web/src/components/network/WifiNetworksList.tsx index 3c22ab84ad..6ee95cb8ba 100644 --- a/web/src/components/network/WifiNetworksList.tsx +++ b/web/src/components/network/WifiNetworksList.tsx @@ -21,7 +21,7 @@ */ import React, { useId } from "react"; -import { useNavigate } from "react-router-dom"; +import { generatePath, useNavigate } from "react-router"; import { Content, DataList, @@ -41,7 +41,6 @@ import { Connection, ConnectionState, WifiNetwork, WifiNetworkStatus } from "~/t import { useConnections, useNetworkChanges, useWifiNetworks } from "~/queries/network"; import { NETWORK as PATHS } from "~/routes/paths"; import { isEmpty } from "radashi"; -import { generateEncodedPath } from "~/utils"; import { formatIp } from "~/utils/network"; import { sprintf } from "sprintf-js"; import { _ } from "~/i18n"; @@ -190,7 +189,7 @@ function WifiNetworksList({ showIp = true, ...props }: WifiNetworksListProps) { return ( navigate(generateEncodedPath(PATHS.wifiNetwork, { ssid }))} + onSelectDataListItem={(_, ssid) => navigate(generatePath(PATHS.wifiNetwork, { ssid }))} {...props} > {networks.map((n) => ( diff --git a/web/src/components/network/WiredConnectionDetails.tsx b/web/src/components/network/WiredConnectionDetails.tsx index e54b3616f8..c39b5e3350 100644 --- a/web/src/components/network/WiredConnectionDetails.tsx +++ b/web/src/components/network/WiredConnectionDetails.tsx @@ -37,13 +37,13 @@ import { Tabs, TabTitleText, } from "@patternfly/react-core"; +import { generatePath } from "react-router"; import { Link, Page } from "~/components/core"; import InstallationOnlySwitch from "./InstallationOnlySwitch"; import { Connection, Device } from "~/types/network"; import { connectionBindingMode, formatIp } from "~/utils/network"; import { NETWORK } from "~/routes/paths"; import { useNetworkDevices } from "~/queries/network"; -import { generateEncodedPath } from "~/utils"; import { isEmpty } from "radashi"; import { sprintf } from "sprintf-js"; import { _ } from "~/i18n"; @@ -73,7 +73,7 @@ const BindingSettings = ({ connection }: { connection: Connection }) => { pfCardProps={{ isPlain: false, isFullHeight: false }} actions={ @@ -203,7 +203,7 @@ const ConnectionDetails = ({ connection }: { connection: Connection }) => { title={_("Settings")} pfCardProps={{ isPlain: false, isFullHeight: false }} actions={ - + {_("Edit connection settings")} } diff --git a/web/src/components/network/WiredConnectionsList.tsx b/web/src/components/network/WiredConnectionsList.tsx index 5f539fa017..7c09839980 100644 --- a/web/src/components/network/WiredConnectionsList.tsx +++ b/web/src/components/network/WiredConnectionsList.tsx @@ -21,7 +21,7 @@ */ import React, { useId } from "react"; -import { useNavigate } from "react-router"; +import { generatePath, useNavigate } from "react-router"; import { Content, DataList, @@ -39,7 +39,6 @@ import { useConnections, useNetworkDevices } from "~/queries/network"; import { NETWORK as PATHS } from "~/routes/paths"; import { formatIp } from "~/utils/network"; import { _ } from "~/i18n"; -import { generateEncodedPath } from "~/utils"; type ConnectionListItemProps = { connection: Connection }; @@ -93,7 +92,7 @@ function WiredConnectionsList(props: DataListProps) { return ( navigate(generateEncodedPath(PATHS.wiredConnection, { id }))} + onSelectDataListItem={(_, id) => navigate(generatePath(PATHS.wiredConnection, { id }))} {...props} > {wiredConnections.map((c: Connection) => ( diff --git a/web/src/components/storage/FilesystemMenu.tsx b/web/src/components/storage/FilesystemMenu.tsx index 11dfcb1121..1853000f4d 100644 --- a/web/src/components/storage/FilesystemMenu.tsx +++ b/web/src/components/storage/FilesystemMenu.tsx @@ -22,7 +22,7 @@ import React, { useId } from "react"; import { Divider, Flex } from "@patternfly/react-core"; -import { useNavigate } from "react-router"; +import { generatePath, useNavigate } from "react-router"; import Text from "~/components/core/Text"; import MenuHeader from "~/components/core/MenuHeader"; import MenuButton from "~/components/core/MenuButton"; @@ -30,7 +30,6 @@ import { STORAGE as PATHS } from "~/routes/paths"; import { model } from "~/types/storage"; import * as driveUtils from "~/components/storage/utils/drive"; import { filesystemType, formattedPath } from "~/components/storage/utils"; -import { generateEncodedPath } from "~/utils"; import { sprintf } from "sprintf-js"; import { _ } from "~/i18n"; @@ -61,7 +60,7 @@ export default function FilesystemMenu({ deviceModel }: FilesystemMenuProps): Re const ariaLabelId = useId(); const toggleTextId = useId(); const { list, listIndex } = deviceModel; - const editFilesystemPath = generateEncodedPath(PATHS.formatDevice, { list, listIndex }); + const editFilesystemPath = generatePath(PATHS.formatDevice, { list, listIndex }); // TRANSLATORS: %s is the name of device, like '/dev/sda'. const detailsAriaLabel = sprintf(_("Details for %s"), deviceModel.name); diff --git a/web/src/components/storage/PartitionsMenu.tsx b/web/src/components/storage/PartitionsMenu.tsx index 952b8c8df1..91fe74fe48 100644 --- a/web/src/components/storage/PartitionsMenu.tsx +++ b/web/src/components/storage/PartitionsMenu.tsx @@ -22,7 +22,7 @@ import React, { useId } from "react"; import { Divider, Stack, Flex } from "@patternfly/react-core"; -import { useNavigate } from "react-router"; +import { generatePath, useNavigate } from "react-router"; import Text from "~/components/core/Text"; import MenuButton from "~/components/core/MenuButton"; import MenuHeader from "~/components/core/MenuHeader"; @@ -31,14 +31,13 @@ import { Partition } from "~/api/storage/types/model"; import { STORAGE as PATHS } from "~/routes/paths"; import { useDeletePartition } from "~/hooks/storage/partition"; import * as driveUtils from "~/components/storage/utils/drive"; -import { generateEncodedPath } from "~/utils"; import { sprintf } from "sprintf-js"; import { _, n_ } from "~/i18n"; const PartitionMenuItem = ({ device, mountPath }) => { const partition = device.getPartition(mountPath); const { list, listIndex } = device; - const editPath = generateEncodedPath(PATHS.editPartition, { + const editPath = generatePath(PATHS.editPartition, { list, listIndex, partitionId: mountPath, @@ -140,7 +139,7 @@ export default function PartitionsMenu({ device }) { const ariaLabelId = useId(); const toggleTextId = useId(); const { list, listIndex } = device; - const newPartitionPath = generateEncodedPath(PATHS.addPartition, { list, listIndex }); + const newPartitionPath = generatePath(PATHS.addPartition, { list, listIndex }); // TRANSLATORS: %s is the name of device, like '/dev/sda'. const detailsAriaLabel = sprintf(_("Details for %s"), device.name); const hasPartitions = device.partitions.some((p: Partition) => p.mountPath); diff --git a/web/src/components/storage/SpacePolicyMenu.tsx b/web/src/components/storage/SpacePolicyMenu.tsx index acb4fff49f..04b403f901 100644 --- a/web/src/components/storage/SpacePolicyMenu.tsx +++ b/web/src/components/storage/SpacePolicyMenu.tsx @@ -24,13 +24,12 @@ import React from "react"; import { Flex } from "@patternfly/react-core"; import MenuButton from "~/components/core/MenuButton"; import Text from "~/components/core/Text"; -import { useNavigate } from "react-router"; +import { generatePath, useNavigate } from "react-router"; import { useSetSpacePolicy } from "~/hooks/storage/space-policy"; import { SPACE_POLICIES } from "~/components/storage/utils"; import { apiModel } from "~/api/storage/types"; import { STORAGE as PATHS } from "~/routes/paths"; import * as driveUtils from "~/components/storage/utils/drive"; -import { generateEncodedPath } from "~/utils"; import { isEmpty } from "radashi"; import { _ } from "~/i18n"; @@ -57,7 +56,7 @@ export default function SpacePolicyMenu({ modelDevice, device }) { const onSpacePolicyChange = (spacePolicy: apiModel.SpacePolicy) => { if (spacePolicy === "custom") { - return navigate(generateEncodedPath(PATHS.editSpacePolicy, { list, listIndex })); + return navigate(generatePath(PATHS.editSpacePolicy, { list, listIndex })); } else { setSpacePolicy(list, listIndex, { type: spacePolicy }); } diff --git a/web/src/components/storage/UnusedMenu.tsx b/web/src/components/storage/UnusedMenu.tsx index b9fc1bc190..b3d672438f 100644 --- a/web/src/components/storage/UnusedMenu.tsx +++ b/web/src/components/storage/UnusedMenu.tsx @@ -22,12 +22,11 @@ import React, { useId } from "react"; import { Flex } from "@patternfly/react-core"; -import { useNavigate } from "react-router"; +import { generatePath, useNavigate } from "react-router"; import Text from "~/components/core/Text"; import MenuButton from "~/components/core/MenuButton"; import { STORAGE as PATHS } from "~/routes/paths"; import { model } from "~/types/storage"; -import { generateEncodedPath } from "~/utils"; import { sprintf } from "sprintf-js"; import { _ } from "~/i18n"; @@ -38,8 +37,8 @@ export default function UnusedMenu({ deviceModel }: UnusedMenuProps): React.Reac const ariaLabelId = useId(); const toggleTextId = useId(); const { list, listIndex } = deviceModel; - const newPartitionPath = generateEncodedPath(PATHS.addPartition, { list, listIndex }); - const formatDevicePath = generateEncodedPath(PATHS.formatDevice, { list, listIndex }); + const newPartitionPath = generatePath(PATHS.addPartition, { list, listIndex }); + const formatDevicePath = generatePath(PATHS.formatDevice, { list, listIndex }); // TRANSLATORS: %s is the name of device, like '/dev/sda'. const detailsAriaLabel = sprintf(_("Details for %s"), deviceModel.name); diff --git a/web/src/components/storage/VolumeGroupEditor.tsx b/web/src/components/storage/VolumeGroupEditor.tsx index 774c6ce29a..3381ba95ab 100644 --- a/web/src/components/storage/VolumeGroupEditor.tsx +++ b/web/src/components/storage/VolumeGroupEditor.tsx @@ -22,7 +22,7 @@ import React, { useId } from "react"; import { Divider, Flex, Title } from "@patternfly/react-core"; -import { useNavigate } from "react-router"; +import { generatePath, useNavigate } from "react-router"; import Link from "~/components/core/Link"; import Text from "~/components/core/Text"; import MenuButton from "~/components/core/MenuButton"; @@ -36,7 +36,6 @@ import { baseName, formattedPath } from "~/components/storage/utils"; import { contentDescription } from "~/components/storage/utils/volume-group"; import { useDeleteVolumeGroup } from "~/hooks/storage/volume-group"; import { useDeleteLogicalVolume } from "~/hooks/storage/logical-volume"; -import { generateEncodedPath } from "~/utils"; import { isEmpty } from "radashi"; import { sprintf } from "sprintf-js"; import { _, n_, formatList } from "~/i18n"; @@ -93,7 +92,7 @@ const EditVgOption = ({ vg }: { vg: model.VolumeGroup }) => { itemId="edit-volume-group" description={_("Modify settings and physical volumes")} role="menuitem" - onClick={() => navigate(generateEncodedPath(PATHS.volumeGroup.edit, { id: vg.vgName }))} + onClick={() => navigate(generatePath(PATHS.volumeGroup.edit, { id: vg.vgName }))} > {_("Edit volume group")} @@ -130,7 +129,7 @@ const LogicalVolumes = ({ vg }: { vg: model.VolumeGroup }) => { const deleteLogicalVolume = useDeleteLogicalVolume(); const ariaLabelId = useId(); const toggleTextId = useId(); - const newLvPath = generateEncodedPath(PATHS.volumeGroup.logicalVolume.add, { id: vg.vgName }); + const newLvPath = generatePath(PATHS.volumeGroup.logicalVolume.add, { id: vg.vgName }); const menuAriaLabel = sprintf(_("Logical volumes for %s"), vg.vgName); if (isEmpty(vg.logicalVolumes)) { @@ -176,7 +175,7 @@ const LogicalVolumes = ({ vg }: { vg: model.VolumeGroup }) => { { it("removes null and undefined values", () => { @@ -197,49 +189,6 @@ describe("localConnection", () => { }); }); -describe("generateEncodedPath", () => { - it("encodes special characters in parameters", () => { - const path = "/network/:id"; - const params = { id: "Wired #1" }; - - const result = generateEncodedPath(path, params); - - expect(result).toBe("/network/Wired%20%231"); - }); - - it("handles multiple parameters", () => { - const path = "/network/:id/bridge/:bridge"; - const params = { id: "Wired #1", bridge: "br $0" }; - - const result = generateEncodedPath(path, params); - - expect(result).toBe("/network/Wired%20%231/bridge/br%20%240"); - }); - - it("leaves safe characters unchanged", () => { - const path = "/product/:id"; - const params = { id: "12345" }; - - const result = generateEncodedPath(path, params); - - expect(result).toBe("/product/12345"); - }); - - it("works with empty params", () => { - const path = "/static/path"; - - const result = generateEncodedPath(path, {}); - - expect(result).toBe("/static/path"); - }); - - it("throws if a param is missing", () => { - const path = "/network/:id"; - - expect(() => generateEncodedPath(path, {})).toThrow(); - }); -}); - describe("simpleFastSort", () => { const fakeDevices = [ { sid: 100, name: "/dev/sdz", size: 5 }, diff --git a/web/src/utils.ts b/web/src/utils.ts index 51bcce1950..ebd97eeaf0 100644 --- a/web/src/utils.ts +++ b/web/src/utils.ts @@ -20,8 +20,6 @@ * find current contact information at www.suse.com. */ -import { mapEntries } from "radashi"; -import { generatePath } from "react-router"; import { ISortBy, sort } from "fast-sort"; /** @@ -160,25 +158,6 @@ const mask = (value: string, visible: number = 4, maskChar: string = "*"): strin return maskChar.repeat(maskedLength) + visiblePart; }; -/** - * A wrapper around React Router's `generatePath` that ensures all path parameters - * are URI-encoded using `encodeURIComponent`. This prevents broken URLs caused by - * special characters such as spaces, `#`, `$`, and others. - * - * @example - * ```ts - * // Returns "/network/Wired%20%231" - * generateEncodedPath("/network/:id", { id: "Wired #1" }); - * ``` - */ -const generateEncodedPath = (...args: Parameters) => { - const [path, params] = args; - return generatePath( - path, - mapEntries(params, (key, value) => [key, encodeURIComponent(value)]), - ); -}; - /** * A lightweight wrapper around `fast-sort`. * @@ -213,6 +192,5 @@ export { localConnection, timezoneTime, mask, - generateEncodedPath, sortCollection, }; From 916a4aa0df70350442fa86192868213eb59563c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Mon, 10 Nov 2025 16:02:17 +0000 Subject: [PATCH 06/11] web: fix TypeScript conflicts with TextEncoder and TextDecoder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In TypeScript, TextEncoder and TextDecoder are global types when targeting the DOM environment, causing conflicts when importing these classes from Node’s util module. To avoid these conflicts, TextEncoder and TextDecoder from util have been imported with different names (NodeTextEncoder, NodeTextDecoder) and assigned to globalThis with explicit type assertions. * MDN - https://developer.mozilla.org/en-US/docs/Web/API/TextEncoder - https://developer.mozilla.org/en-US/docs/Web/API/TextDecoder * StackOverflow - https://stackoverflow.com/a/77752064 * TypeScript types - https://github.com/microsoft/TypeScript/blob/efca03ffed10dccede4fbc8dd8a624374e5424d9/src/lib/dom.generated.d.ts#L32378 --- web/src/setupTests.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/src/setupTests.ts b/web/src/setupTests.ts index 04773a7b04..1f940b3cd9 100644 --- a/web/src/setupTests.ts +++ b/web/src/setupTests.ts @@ -3,11 +3,11 @@ // expect(element).toHaveTextContent(/react/i) // learn more: https://github.com/testing-library/jest-dom import "@testing-library/jest-dom"; -import { TextDecoder, TextEncoder } from "util"; +import { TextDecoder as NodeTextDecoder, TextEncoder as NodeTextEncoder } from "util"; globalThis.IS_REACT_ACT_ENVIRONMENT = true; if (!globalThis.TextEncoder || !globalThis.TextDecoder) { - globalThis.TextEncoder = TextEncoder; - globalThis.TextDecoder = TextDecoder; + globalThis.TextEncoder = NodeTextEncoder as typeof TextEncoder; + globalThis.TextDecoder = NodeTextDecoder as typeof TextDecoder; } From 2dcde83900c4bb13589f70f1e47403c76918b429 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20D=C3=ADaz=20Gonz=C3=A1lez?= Date: Mon, 10 Nov 2025 16:57:50 +0000 Subject: [PATCH 07/11] web: switch to "bundler" moduleResolution To fix an issue with React Router v7 types > src/index.tsx:25:32 - error TS2307: Cannot find module 'react-router/dom' or its corresponding type declarations. > There are types at 'node_modules/react-router/dist/development/dom-export.d.mts', > but this result could not be resolved under your current 'moduleResolution' setting. > Consider updating to 'node16', 'nodenext', or 'bundler'. As per TypeScript documentation, https://www.typescriptlang.org/tsconfig/#moduleResolution > 'bundler' for use with bundlers. Like node16 and nodenext, this mode > supports package.json "imports" and "exports", but unlike the Node.js > resolution modes, bundler never requires file extensions on relative > paths in imports. --- web/tsconfig.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/tsconfig.json b/web/tsconfig.json index f1524252f8..248e8afa22 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -5,7 +5,7 @@ "outDir": "dist/", "isolatedModules": true, "target": "esnext", - "moduleResolution": "node", + "moduleResolution": "bundler", "resolveJsonModule": true, "esModuleInterop": true, "allowJs": true, From 73a6f38b4b0f12fbe2a1826b9897915b355f9ca4 Mon Sep 17 00:00:00 2001 From: Knut Anderssen Date: Tue, 11 Nov 2025 07:14:39 +0000 Subject: [PATCH 08/11] Adapted network service to the new config based API --- rust/Cargo.lock | 4 + rust/agama-lib/share/profile.schema.json | 23 + rust/agama-lib/src/network.rs | 4 +- rust/agama-lib/src/network/client.rs | 2 +- rust/agama-lib/src/network/store.rs | 19 +- rust/agama-manager/Cargo.toml | 1 + rust/agama-manager/src/lib.rs | 1 + rust/agama-manager/src/service.rs | 50 +- rust/agama-manager/src/start.rs | 10 +- rust/agama-network/src/action.rs | 16 +- rust/agama-network/src/error.rs | 10 + rust/agama-network/src/lib.rs | 1 - rust/agama-network/src/model.rs | 716 ++++++---------- rust/agama-network/src/nm/builder.rs | 3 +- rust/agama-network/src/nm/client.rs | 11 +- rust/agama-network/src/nm/dbus.rs | 28 +- rust/agama-network/src/nm/error.rs | 2 +- rust/agama-network/src/nm/model.rs | 4 +- rust/agama-network/src/nm/watcher.rs | 9 +- rust/agama-network/src/system.rs | 53 +- rust/agama-network/src/types.rs | 323 +------ rust/agama-server/Cargo.toml | 1 + rust/agama-server/src/lib.rs | 1 - rust/agama-server/src/network/web.rs | 490 ----------- rust/agama-server/src/web.rs | 9 - rust/agama-server/src/web/docs.rs | 2 - rust/agama-server/src/web/docs/config.rs | 47 +- rust/agama-server/src/web/docs/network.rs | 119 --- rust/agama-server/tests/network_service.rs | 285 ------- rust/agama-utils/Cargo.toml | 2 + rust/agama-utils/src/api.rs | 1 + rust/agama-utils/src/api/config.rs | 4 +- .../src => agama-utils/src/api}/network.rs | 19 +- rust/agama-utils/src/api/network/config.rs | 34 + rust/agama-utils/src/api/network/proposal.rs | 34 + .../src/api/network}/settings.rs | 36 +- .../src/api/network/system_info.rs | 36 + rust/agama-utils/src/api/network/types.rs | 790 ++++++++++++++++++ rust/agama-utils/src/api/proposal.rs | 3 +- rust/agama-utils/src/api/system_info.rs | 2 + rust/xtask/src/main.rs | 5 +- 41 files changed, 1415 insertions(+), 1795 deletions(-) delete mode 100644 rust/agama-server/src/network/web.rs delete mode 100644 rust/agama-server/src/web/docs/network.rs delete mode 100644 rust/agama-server/tests/network_service.rs rename rust/{agama-server/src => agama-utils/src/api}/network.rs (70%) create mode 100644 rust/agama-utils/src/api/network/config.rs create mode 100644 rust/agama-utils/src/api/network/proposal.rs rename rust/{agama-network/src => agama-utils/src/api/network}/settings.rs (90%) create mode 100644 rust/agama-utils/src/api/network/system_info.rs create mode 100644 rust/agama-utils/src/api/network/types.rs diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 9d07e9219b..e1aece7139 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -131,6 +131,7 @@ name = "agama-manager" version = "0.1.0" dependencies = [ "agama-l10n", + "agama-network", "agama-storage", "agama-utils", "async-trait", @@ -175,6 +176,7 @@ dependencies = [ "agama-lib", "agama-locale-data", "agama-manager", + "agama-network", "agama-utils", "anyhow", "async-trait", @@ -238,7 +240,9 @@ version = "0.1.0" dependencies = [ "agama-locale-data", "async-trait", + "cidr", "gettext-rs", + "macaddr", "serde", "serde_json", "serde_with", diff --git a/rust/agama-lib/share/profile.schema.json b/rust/agama-lib/share/profile.schema.json index 33e06eab27..a40f9da479 100644 --- a/rust/agama-lib/share/profile.schema.json +++ b/rust/agama-lib/share/profile.schema.json @@ -336,6 +336,29 @@ "type": "object", "additionalProperties": false, "properties": { + "state": { + "title": "Network general state settings", + "type": "object", + "properties": { + "connectivity": { + "title": "Determines whether the user is able to access the Internet", + "type": "boolean", + "readOnly": true + }, + "copyNetwork": { + "title": "Whether the network configuration should be copied to the target system", + "type": "boolean" + }, + "networkingEnabled": { + "title": "Whether the network should be enabled", + "type": "boolean" + }, + "wirelessEnabled": { + "title": "Whether the wireless should be enabled", + "type": "boolean" + } + } + }, "connections": { "title": "Network connections to be defined", "type": "array", diff --git a/rust/agama-lib/src/network.rs b/rust/agama-lib/src/network.rs index 41fa7fb7d9..5fe3da04f5 100644 --- a/rust/agama-lib/src/network.rs +++ b/rust/agama-lib/src/network.rs @@ -24,9 +24,9 @@ mod client; mod store; pub use agama_network::{ - error, model, settings, types, Action, Adapter, NetworkAdapterError, NetworkManagerAdapter, + error, model, types, Action, Adapter, NetworkAdapterError, NetworkManagerAdapter, NetworkSystem, NetworkSystemClient, NetworkSystemError, }; +pub use agama_utils::api::network::*; pub use client::{NetworkClient, NetworkClientError}; -pub use settings::NetworkSettings; pub use store::{NetworkStore, NetworkStoreError}; diff --git a/rust/agama-lib/src/network/client.rs b/rust/agama-lib/src/network/client.rs index 0fb0b6bb30..dbb3854beb 100644 --- a/rust/agama-lib/src/network/client.rs +++ b/rust/agama-lib/src/network/client.rs @@ -18,8 +18,8 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use super::{settings::NetworkConnection, types::Device}; use crate::http::{BaseHTTPClient, BaseHTTPClientError}; +use crate::network::{Device, NetworkConnection}; use crate::utils::url::encode; #[derive(Debug, thiserror::Error)] diff --git a/rust/agama-lib/src/network/store.rs b/rust/agama-lib/src/network/store.rs index a591b529dd..9527d212fd 100644 --- a/rust/agama-lib/src/network/store.rs +++ b/rust/agama-lib/src/network/store.rs @@ -18,11 +18,13 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use super::{settings::NetworkConnection, NetworkClientError}; +use super::NetworkClientError; use crate::{ http::BaseHTTPClient, network::{NetworkClient, NetworkSettings}, }; +use agama_network::types::NetworkConnectionsCollection; +use agama_utils::api::network::NetworkConnection; #[derive(Debug, thiserror::Error)] #[error("Error processing network settings: {0}")] @@ -44,15 +46,20 @@ impl NetworkStore { // TODO: read the settings from the service pub async fn load(&self) -> NetworkStoreResult { - let connections = self.network_client.connections().await?; - Ok(NetworkSettings { connections }) + let connections = NetworkConnectionsCollection(self.network_client.connections().await?); + + Ok(NetworkSettings { + connections, + ..Default::default() + }) } pub async fn store(&self, settings: &NetworkSettings) -> NetworkStoreResult<()> { - for id in ordered_connections(&settings.connections) { + let connections = &settings.connections.0; + for id in ordered_connections(connections) { let id = id.as_str(); let fallback = default_connection(id); - let conn = find_connection(id, &settings.connections).unwrap_or(&fallback); + let conn = find_connection(id, connections).unwrap_or(&fallback); self.network_client .add_or_update_connection(conn.clone()) .await?; @@ -129,7 +136,7 @@ fn default_connection(id: &str) -> NetworkConnection { #[cfg(test)] mod tests { use super::ordered_connections; - use crate::network::settings::{BondSettings, BridgeSettings, NetworkConnection}; + use crate::network::{BondSettings, BridgeSettings, NetworkConnection}; #[test] fn test_ordered_connections() { diff --git a/rust/agama-manager/Cargo.toml b/rust/agama-manager/Cargo.toml index 9738008b51..5004fffb6c 100644 --- a/rust/agama-manager/Cargo.toml +++ b/rust/agama-manager/Cargo.toml @@ -7,6 +7,7 @@ edition.workspace = true [dependencies] agama-utils = { path = "../agama-utils" } agama-l10n = { path = "../agama-l10n" } +agama-network = { path = "../agama-network" } agama-storage = { path = "../agama-storage" } thiserror = "2.0.12" tokio = { version = "1.40.0", features = ["macros", "rt-multi-thread", "sync"] } diff --git a/rust/agama-manager/src/lib.rs b/rust/agama-manager/src/lib.rs index 49a1a5b366..39260e92ad 100644 --- a/rust/agama-manager/src/lib.rs +++ b/rust/agama-manager/src/lib.rs @@ -27,4 +27,5 @@ pub use service::Service; pub mod message; pub use agama_l10n as l10n; +pub use agama_network as network; pub use agama_storage as storage; diff --git a/rust/agama-manager/src/service.rs b/rust/agama-manager/src/service.rs index 29e03e6dfc..62873e87eb 100644 --- a/rust/agama-manager/src/service.rs +++ b/rust/agama-manager/src/service.rs @@ -18,7 +18,7 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::{l10n, message, storage}; +use crate::{l10n, message, network, storage}; use agama_utils::{ actor::{self, Actor, Handler, MessageHandler}, api::{ @@ -29,6 +29,7 @@ use agama_utils::{ }; use async_trait::async_trait; use merge_struct::merge; +use network::{NetworkSystemClient, NetworkSystemError}; use serde_json::Value; use tokio::sync::broadcast; @@ -50,10 +51,13 @@ pub enum Error { Questions(#[from] question::service::Error), #[error(transparent)] Progress(#[from] progress::service::Error), + #[error(transparent)] + NetworkSystemError(#[from] NetworkSystemError), } pub struct Service { l10n: Handler, + network: NetworkSystemClient, storage: Handler, issues: Handler, progress: Handler, @@ -66,6 +70,7 @@ pub struct Service { impl Service { pub fn new( l10n: Handler, + network: NetworkSystemClient, storage: Handler, issues: Handler, progress: Handler, @@ -74,6 +79,7 @@ impl Service { ) -> Self { Self { l10n, + network, storage, issues, progress, @@ -147,7 +153,12 @@ impl MessageHandler for Service { async fn handle(&mut self, _message: message::GetSystem) -> Result { let l10n = self.l10n.call(l10n::message::GetSystem).await?; let storage = self.storage.call(storage::message::GetSystem).await?; - Ok(SystemInfo { l10n, storage }) + let network = self.network.get_system_config().await?; + Ok(SystemInfo { + l10n, + network, + storage, + }) } } @@ -159,10 +170,13 @@ impl MessageHandler for Service { async fn handle(&mut self, _message: message::GetExtendedConfig) -> Result { let l10n = self.l10n.call(l10n::message::GetConfig).await?; let questions = self.questions.call(question::message::GetConfig).await?; + let network = self.network.get_config().await?; let storage = self.storage.call(storage::message::GetConfig).await?; + Ok(Config { l10n: Some(l10n), - questions, + questions: questions, + network: Some(network), storage, }) } @@ -196,11 +210,28 @@ impl MessageHandler for Service { .call(storage::message::SetConfig::new(config.storage.clone())) .await?; + if let Some(network) = config.network.clone() { + self.network.update_config(network).await?; + self.network.apply().await?; + } + self.config = config; Ok(()) } } +fn merge_network(mut config: Config, update_config: Config) -> Config { + if let Some(network) = &update_config.network { + if let Some(connections) = &network.connections { + if let Some(ref mut config_network) = config.network { + config_network.connections = Some(connections.clone()); + } + } + } + + config +} + #[async_trait] impl MessageHandler for Service { /// Patches the config. @@ -209,6 +240,7 @@ impl MessageHandler for Service { /// config, then it keeps the values from the current config. async fn handle(&mut self, message: message::UpdateConfig) -> Result<(), Error> { let config = merge(&self.config, &message.config).map_err(|_| Error::MergeConfig)?; + let config = merge_network(config, message.config); if let Some(l10n) = &config.l10n { self.l10n @@ -228,6 +260,10 @@ impl MessageHandler for Service { .await?; } + if let Some(network) = &config.network { + self.network.update_config(network.clone()).await?; + } + self.config = config; Ok(()) } @@ -239,7 +275,13 @@ impl MessageHandler for Service { async fn handle(&mut self, _message: message::GetProposal) -> Result, Error> { let l10n = self.l10n.call(l10n::message::GetProposal).await?; let storage = self.storage.call(storage::message::GetProposal).await?; - Ok(Some(Proposal { l10n, storage })) + let network = self.network.get_proposal().await?; + + Ok(Some(Proposal { + l10n, + network, + storage, + })) } } diff --git a/rust/agama-manager/src/start.rs b/rust/agama-manager/src/start.rs index 98acd79976..8a3f034e7f 100644 --- a/rust/agama-manager/src/start.rs +++ b/rust/agama-manager/src/start.rs @@ -18,7 +18,7 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::{l10n, service::Service, storage}; +use crate::{l10n, network, service::Service, storage}; use agama_utils::{ actor::{self, Handler}, api::event, @@ -35,6 +35,8 @@ pub enum Error { L10n(#[from] l10n::start::Error), #[error(transparent)] Storage(#[from] storage::start::Error), + #[error(transparent)] + NetworkSystem(#[from] network::NetworkSystemError), } /// Starts the manager service. @@ -51,8 +53,12 @@ pub async fn start( let progress = progress::start(events.clone()).await?; let l10n = l10n::start(issues.clone(), events.clone()).await?; let storage = storage::start(progress.clone(), issues.clone(), events.clone(), dbus).await?; + let network_adapter = network::NetworkManagerAdapter::from_system() + .await + .expect("Could not connect to NetworkManager"); + let network = network::NetworkSystem::new(network_adapter).start().await?; - let service = Service::new(l10n, storage, issues, progress, questions, events); + let service = Service::new(l10n, network, storage, issues, progress, questions, events); let handler = actor::spawn(service); Ok(handler) } diff --git a/rust/agama-network/src/action.rs b/rust/agama-network/src/action.rs index d1d18a83ba..791bcb3622 100644 --- a/rust/agama-network/src/action.rs +++ b/rust/agama-network/src/action.rs @@ -18,12 +18,13 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::model::{AccessPoint, Connection, Device}; -use crate::types::{ConnectionState, DeviceType}; +use crate::model::{Connection, GeneralState}; +use crate::types::{AccessPoint, ConnectionState, Device, DeviceType, Proposal, SystemInfo}; +use agama_utils::api::network::Config; use tokio::sync::oneshot; use uuid::Uuid; -use super::{error::NetworkStateError, model::GeneralState, NetworkAdapterError}; +use super::{error::NetworkStateError, NetworkAdapterError}; pub type Responder = oneshot::Sender; pub type ControllerConnection = (Connection, Vec); @@ -42,6 +43,15 @@ pub enum Action { GetConnection(String, Responder>), /// Gets a connection by its Uuid GetConnectionByUuid(Uuid, Responder>), + /// Gets the internal state of the network configuration + GetConfig(Responder), + /// Gets the internal state of the network configuration proposal + GetProposal(Responder), + /// Updates th internal state of the network configuration + UpdateConfig(Box, Responder>), + /// Gets the current network configuration containing connections, devices, access_points and + /// also the general state + GetSystemConfig(Responder), /// Gets a connection GetConnections(Responder>), /// Gets a controller connection diff --git a/rust/agama-network/src/error.rs b/rust/agama-network/src/error.rs index 87a3498554..291f40317f 100644 --- a/rust/agama-network/src/error.rs +++ b/rust/agama-network/src/error.rs @@ -21,6 +21,16 @@ //! Error types. use thiserror::Error; +use crate::NetworkSystemError; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + NetworkStateError(#[from] NetworkStateError), + #[error(transparent)] + NetworkSystemError(#[from] NetworkSystemError), +} + /// Errors that are related to the network configuration. #[derive(Error, Debug)] pub enum NetworkStateError { diff --git a/rust/agama-network/src/lib.rs b/rust/agama-network/src/lib.rs index 01b992bc03..0cf9b47e59 100644 --- a/rust/agama-network/src/lib.rs +++ b/rust/agama-network/src/lib.rs @@ -27,7 +27,6 @@ pub mod adapter; pub mod error; pub mod model; mod nm; -pub mod settings; mod system; pub mod types; diff --git a/rust/agama-network/src/model.rs b/rust/agama-network/src/model.rs index e5a2eb28ed..0e9372a04f 100644 --- a/rust/agama-network/src/model.rs +++ b/rust/agama-network/src/model.rs @@ -23,13 +23,9 @@ //! * This module contains the types that represent the network concepts. They are supposed to be //! agnostic from the real network service (e.g., NetworkManager). use crate::error::NetworkStateError; -use crate::settings::{ - BondSettings, BridgeSettings, IEEE8021XSettings, NetworkConnection, VlanSettings, - WirelessSettings, -}; -use crate::types::{BondMode, ConnectionState, DeviceState, DeviceType, Status, SSID}; +use crate::types::*; + use agama_utils::openapi::schemas; -use cidr::IpInet; use macaddr::MacAddr6; use serde::{Deserialize, Serialize}; use serde_with::{serde_as, skip_serializing_none, DisplayFromStr}; @@ -37,12 +33,10 @@ use std::{ collections::HashMap, default::Default, fmt, - net::IpAddr, str::{self, FromStr}, }; use thiserror::Error; use uuid::Uuid; -use zbus::zvariant::Value; #[derive(PartialEq)] pub struct StateConfig { @@ -164,6 +158,52 @@ impl NetworkState { Ok(()) } + pub fn update_state(&mut self, config: Config) -> Result<(), NetworkStateError> { + if let Some(connections) = config.connections { + let mut collection: ConnectionCollection = connections.clone().try_into()?; + for conn in collection.0.iter_mut() { + if let Some(current_conn) = self.get_connection(conn.id.as_str()) { + // Replaced the UUID with a real one + conn.uuid = current_conn.uuid; + self.update_connection(conn.to_owned())?; + } else { + self.add_connection(conn.to_owned())?; + } + } + + for conn in connections.0 { + if conn.bridge.is_some() | conn.bond.is_some() { + let mut ports = vec![]; + if let Some(model) = conn.bridge { + ports = model.ports; + } + if let Some(model) = conn.bond { + ports = model.ports; + } + + if let Some(controller) = self.get_connection(conn.id.as_str()) { + self.set_ports(&controller.clone(), ports)?; + } + } + } + } + + if let Some(state) = config.state { + if let Some(wireless_enabled) = state.wireless_enabled { + self.general_state.wireless_enabled = wireless_enabled; + } + + if let Some(networking_enabled) = state.networking_enabled { + self.general_state.networking_enabled = networking_enabled; + } + + if let Some(copy_network) = state.copy_network { + self.general_state.copy_network = copy_network; + } + } + Ok(()) + } + /// Updates a connection with a new one. /// /// It uses the `id` to decide which connection to update. @@ -252,6 +292,20 @@ impl NetworkState { )), } } + + pub fn ports_for(&self, uuid: Uuid) -> Vec { + self.connections + .iter() + .filter(|c| c.controller == Some(uuid)) + .map(|c| { + if let Some(interface) = c.interface.to_owned() { + interface + } else { + c.clone().id + } + }) + .collect() + } } #[cfg(test)] @@ -260,57 +314,6 @@ mod tests { use crate::error::NetworkStateError; use uuid::Uuid; - #[test] - fn test_macaddress() { - let mut val: Option = None; - assert!(matches!( - MacAddress::try_from(&val).unwrap(), - MacAddress::Unset - )); - - val = Some(String::from("")); - assert!(matches!( - MacAddress::try_from(&val).unwrap(), - MacAddress::Unset - )); - - val = Some(String::from("preserve")); - assert!(matches!( - MacAddress::try_from(&val).unwrap(), - MacAddress::Preserve - )); - - val = Some(String::from("permanent")); - assert!(matches!( - MacAddress::try_from(&val).unwrap(), - MacAddress::Permanent - )); - - val = Some(String::from("random")); - assert!(matches!( - MacAddress::try_from(&val).unwrap(), - MacAddress::Random - )); - - val = Some(String::from("stable")); - assert!(matches!( - MacAddress::try_from(&val).unwrap(), - MacAddress::Stable - )); - - val = Some(String::from("This is not a MACAddr")); - assert!(matches!( - MacAddress::try_from(&val), - Err(InvalidMacAddress(_)) - )); - - val = Some(String::from("de:ad:be:ef:2b:ad")); - assert_eq!( - MacAddress::try_from(&val).unwrap().to_string(), - String::from("de:ad:be:ef:2b:ad").to_uppercase() - ); - } - #[test] fn test_add_connection() { let mut state = NetworkState::default(); @@ -461,9 +464,7 @@ mod tests { pub const NOT_COPY_NETWORK_PATH: &str = "/run/agama/not_copy_network"; /// Network state -#[serde_as] -#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] +#[derive(Clone, Debug, Default)] pub struct GeneralState { pub hostname: String, pub connectivity: bool, @@ -472,37 +473,6 @@ pub struct GeneralState { pub networking_enabled: bool, // pub network_state: NMSTATE } -/// Access Point -#[serde_as] -#[derive(Default, Debug, Clone, Serialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct AccessPoint { - #[serde_as(as = "DisplayFromStr")] - pub ssid: SSID, - pub hw_address: String, - pub strength: u8, - pub flags: u32, - pub rsn_flags: u32, - pub wpa_flags: u32, -} - -/// Network device -#[serde_as] -#[skip_serializing_none] -#[derive(Default, Debug, Clone, PartialEq, Deserialize, Serialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct Device { - pub name: String, - #[serde(rename = "type")] - pub type_: DeviceType, - #[serde_as(as = "DisplayFromStr")] - pub mac_address: MacAddress, - pub ip_config: Option, - // Connection.id - pub connection: Option, - pub state: DeviceState, -} - /// Represents a known network connection. #[serde_as] #[skip_serializing_none] @@ -806,274 +776,6 @@ impl From for ConnectionConfig { } } -#[derive(Debug, Error)] -#[error("Invalid MAC address: {0}")] -pub struct InvalidMacAddress(String); - -#[derive(Debug, Default, Clone, PartialEq, Serialize, utoipa::ToSchema)] -pub enum MacAddress { - #[schema(value_type = String, format = "MAC address in EUI-48 format")] - MacAddress(macaddr::MacAddr6), - Preserve, - Permanent, - Random, - Stable, - #[default] - Unset, -} - -impl FromStr for MacAddress { - type Err = InvalidMacAddress; - - fn from_str(s: &str) -> Result { - match s { - "preserve" => Ok(Self::Preserve), - "permanent" => Ok(Self::Permanent), - "random" => Ok(Self::Random), - "stable" => Ok(Self::Stable), - "" => Ok(Self::Unset), - _ => Ok(Self::MacAddress(match macaddr::MacAddr6::from_str(s) { - Ok(mac) => mac, - Err(e) => return Err(InvalidMacAddress(e.to_string())), - })), - } - } -} - -impl TryFrom<&Option> for MacAddress { - type Error = InvalidMacAddress; - - fn try_from(value: &Option) -> Result { - match &value { - Some(str) => MacAddress::from_str(str), - None => Ok(Self::Unset), - } - } -} - -impl fmt::Display for MacAddress { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let output = match &self { - Self::MacAddress(mac) => mac.to_string(), - Self::Preserve => "preserve".to_string(), - Self::Permanent => "permanent".to_string(), - Self::Random => "random".to_string(), - Self::Stable => "stable".to_string(), - Self::Unset => "".to_string(), - }; - write!(f, "{}", output) - } -} - -impl From for zbus::fdo::Error { - fn from(value: InvalidMacAddress) -> Self { - zbus::fdo::Error::Failed(value.to_string()) - } -} - -#[skip_serializing_none] -#[derive(Default, Debug, PartialEq, Clone, Deserialize, Serialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct IpConfig { - pub method4: Ipv4Method, - pub method6: Ipv6Method, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - #[schema(schema_with = schemas::ip_inet_array)] - pub addresses: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - #[schema(schema_with = schemas::ip_addr_array)] - pub nameservers: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub dns_searchlist: Vec, - pub ignore_auto_dns: bool, - #[schema(schema_with = schemas::ip_addr)] - pub gateway4: Option, - #[schema(schema_with = schemas::ip_addr)] - pub gateway6: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub routes4: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub routes6: Vec, - pub dhcp4_settings: Option, - pub dhcp6_settings: Option, - pub ip6_privacy: Option, - pub dns_priority4: Option, - pub dns_priority6: Option, -} - -#[skip_serializing_none] -#[derive(Debug, Default, PartialEq, Clone, Deserialize, Serialize, utoipa::ToSchema)] -pub struct Dhcp4Settings { - pub send_hostname: Option, - pub hostname: Option, - pub send_release: Option, - pub client_id: DhcpClientId, - pub iaid: DhcpIaid, -} - -#[derive(Debug, Default, Clone, PartialEq, Deserialize, Serialize, utoipa::ToSchema)] -pub enum DhcpClientId { - Id(String), - Mac, - PermMac, - Ipv6Duid, - Duid, - Stable, - None, - #[default] - Unset, -} - -impl From<&str> for DhcpClientId { - fn from(s: &str) -> Self { - match s { - "mac" => Self::Mac, - "perm-mac" => Self::PermMac, - "ipv6-duid" => Self::Ipv6Duid, - "duid" => Self::Duid, - "stable" => Self::Stable, - "none" => Self::None, - "" => Self::Unset, - _ => Self::Id(s.to_string()), - } - } -} - -impl From> for DhcpClientId { - fn from(value: Option) -> Self { - match &value { - Some(str) => Self::from(str.as_str()), - None => Self::Unset, - } - } -} - -impl fmt::Display for DhcpClientId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let output = match &self { - Self::Id(id) => id.to_string(), - Self::Mac => "mac".to_string(), - Self::PermMac => "perm-mac".to_string(), - Self::Ipv6Duid => "ipv6-duid".to_string(), - Self::Duid => "duid".to_string(), - Self::Stable => "stable".to_string(), - Self::None => "none".to_string(), - Self::Unset => "".to_string(), - }; - write!(f, "{}", output) - } -} - -#[derive(Debug, Default, Clone, PartialEq, Deserialize, Serialize, utoipa::ToSchema)] -pub enum DhcpIaid { - Id(String), - Mac, - PermMac, - Ifname, - Stable, - #[default] - Unset, -} - -impl From<&str> for DhcpIaid { - fn from(s: &str) -> Self { - match s { - "mac" => Self::Mac, - "perm-mac" => Self::PermMac, - "ifname" => Self::Ifname, - "stable" => Self::Stable, - "" => Self::Unset, - _ => Self::Id(s.to_string()), - } - } -} - -impl From> for DhcpIaid { - fn from(value: Option) -> Self { - match value { - Some(str) => Self::from(str.as_str()), - None => Self::Unset, - } - } -} - -impl fmt::Display for DhcpIaid { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let output = match &self { - Self::Id(id) => id.to_string(), - Self::Mac => "mac".to_string(), - Self::PermMac => "perm-mac".to_string(), - Self::Ifname => "ifname".to_string(), - Self::Stable => "stable".to_string(), - Self::Unset => "".to_string(), - }; - write!(f, "{}", output) - } -} - -#[skip_serializing_none] -#[derive(Debug, Default, PartialEq, Clone, Deserialize, Serialize, utoipa::ToSchema)] -pub struct Dhcp6Settings { - pub send_hostname: Option, - pub hostname: Option, - pub send_release: Option, - pub duid: DhcpDuid, - pub iaid: DhcpIaid, -} - -#[derive(Debug, Default, Clone, PartialEq, Deserialize, Serialize, utoipa::ToSchema)] -pub enum DhcpDuid { - Id(String), - Lease, - Llt, - Ll, - StableLlt, - StableLl, - StableUuid, - #[default] - Unset, -} - -impl From<&str> for DhcpDuid { - fn from(s: &str) -> Self { - match s { - "lease" => Self::Lease, - "llt" => Self::Llt, - "ll" => Self::Ll, - "stable-llt" => Self::StableLlt, - "stable-ll" => Self::StableLl, - "stable-uuid" => Self::StableUuid, - "" => Self::Unset, - _ => Self::Id(s.to_string()), - } - } -} - -impl From> for DhcpDuid { - fn from(value: Option) -> Self { - match &value { - Some(str) => Self::from(str.as_str()), - None => Self::Unset, - } - } -} - -impl fmt::Display for DhcpDuid { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let output = match &self { - Self::Id(id) => id.to_string(), - Self::Lease => "lease".to_string(), - Self::Llt => "llt".to_string(), - Self::Ll => "ll".to_string(), - Self::StableLlt => "stable-llt".to_string(), - Self::StableLl => "stable-ll".to_string(), - Self::StableUuid => "stable-uuid".to_string(), - Self::Unset => "".to_string(), - }; - write!(f, "{}", output) - } -} - #[skip_serializing_none] #[derive(Debug, Default, PartialEq, Clone, Serialize, utoipa::ToSchema)] pub struct MatchConfig { @@ -1087,125 +789,6 @@ pub struct MatchConfig { pub kernel: Vec, } -#[derive(Debug, Error)] -#[error("Unknown IP configuration method name: {0}")] -pub struct UnknownIpMethod(String); - -#[derive(Debug, Default, Copy, Clone, PartialEq, Deserialize, Serialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub enum Ipv4Method { - Disabled = 0, - #[default] - Auto = 1, - Manual = 2, - LinkLocal = 3, -} - -impl fmt::Display for Ipv4Method { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let name = match &self { - Ipv4Method::Disabled => "disabled", - Ipv4Method::Auto => "auto", - Ipv4Method::Manual => "manual", - Ipv4Method::LinkLocal => "link-local", - }; - write!(f, "{}", name) - } -} - -impl FromStr for Ipv4Method { - type Err = UnknownIpMethod; - - fn from_str(s: &str) -> Result { - match s { - "disabled" => Ok(Ipv4Method::Disabled), - "auto" => Ok(Ipv4Method::Auto), - "manual" => Ok(Ipv4Method::Manual), - "link-local" => Ok(Ipv4Method::LinkLocal), - _ => Err(UnknownIpMethod(s.to_string())), - } - } -} - -#[derive(Debug, Default, Copy, Clone, PartialEq, Deserialize, Serialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub enum Ipv6Method { - Disabled = 0, - #[default] - Auto = 1, - Manual = 2, - LinkLocal = 3, - Ignore = 4, - Dhcp = 5, -} - -impl fmt::Display for Ipv6Method { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let name = match &self { - Ipv6Method::Disabled => "disabled", - Ipv6Method::Auto => "auto", - Ipv6Method::Manual => "manual", - Ipv6Method::LinkLocal => "link-local", - Ipv6Method::Ignore => "ignore", - Ipv6Method::Dhcp => "dhcp", - }; - write!(f, "{}", name) - } -} - -impl FromStr for Ipv6Method { - type Err = UnknownIpMethod; - - fn from_str(s: &str) -> Result { - match s { - "disabled" => Ok(Ipv6Method::Disabled), - "auto" => Ok(Ipv6Method::Auto), - "manual" => Ok(Ipv6Method::Manual), - "link-local" => Ok(Ipv6Method::LinkLocal), - "ignore" => Ok(Ipv6Method::Ignore), - "dhcp" => Ok(Ipv6Method::Dhcp), - _ => Err(UnknownIpMethod(s.to_string())), - } - } -} - -impl From for zbus::fdo::Error { - fn from(value: UnknownIpMethod) -> zbus::fdo::Error { - zbus::fdo::Error::Failed(value.to_string()) - } -} - -#[derive(Debug, PartialEq, Clone, Deserialize, Serialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct IpRoute { - #[schema(schema_with = schemas::ip_inet_ref)] - pub destination: IpInet, - #[serde(skip_serializing_if = "Option::is_none")] - #[schema(schema_with = schemas::ip_addr)] - pub next_hop: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub metric: Option, -} - -impl From<&IpRoute> for HashMap<&str, Value<'_>> { - fn from(route: &IpRoute) -> Self { - let mut map: HashMap<&str, Value> = HashMap::from([ - ("dest", Value::new(route.destination.address().to_string())), - ( - "prefix", - Value::new(route.destination.network_length() as u32), - ), - ]); - if let Some(next_hop) = route.next_hop { - map.insert("next-hop", Value::new(next_hop.to_string())); - } - if let Some(metric) = route.metric { - map.insert("metric", Value::new(metric)); - } - map - } -} - #[derive(Debug, Default, PartialEq, Clone, Serialize, utoipa::ToSchema)] pub enum VlanProtocol { #[default] @@ -1742,6 +1325,179 @@ pub struct BondConfig { pub options: BondOptions, } +#[derive(Clone, Debug, Default)] +pub struct ConnectionCollection(pub Vec); + +impl ConnectionCollection { + pub fn ports_for(&self, uuid: Uuid) -> Vec { + self.0 + .iter() + .filter(|c| c.controller == Some(uuid)) + .map(|c| { + if let Some(interface) = c.interface.to_owned() { + interface + } else { + c.clone().id + } + }) + .collect() + } +} + +impl TryFrom for NetworkConnectionsCollection { + type Error = NetworkStateError; + + fn try_from(collection: ConnectionCollection) -> Result { + let network_connections = collection + .0 + .iter() + .filter(|c| c.controller.is_none()) + .map(|c| { + let mut conn = NetworkConnection::try_from(c.clone()).unwrap(); + if let Some(ref mut bond) = conn.bond { + bond.ports = collection.ports_for(c.uuid); + } + if let Some(ref mut bridge) = conn.bridge { + bridge.ports = collection.ports_for(c.uuid); + }; + conn + }) + .collect(); + + Ok(NetworkConnectionsCollection(network_connections)) + } +} + +impl TryFrom for ConnectionCollection { + type Error = NetworkStateError; + + fn try_from(collection: NetworkConnectionsCollection) -> Result { + let mut conns: Vec = vec![]; + let mut controller_ports: HashMap = HashMap::new(); + + for net_conn in &collection.0 { + let mut conn = Connection::try_from(net_conn.clone())?; + conn.uuid = Uuid::new_v4(); + let mut ports = vec![]; + if let Some(bridge) = &net_conn.bridge { + ports = bridge.ports.clone(); + } + if let Some(bond) = &net_conn.bond { + ports = bond.ports.clone(); + } + for port in &ports { + controller_ports.insert(port.to_string(), conn.uuid); + } + + conns.push(conn); + } + + for (port, uuid) in controller_ports { + let default = Connection::new(port.clone(), DeviceType::Ethernet); + let mut conn = conns + .iter() + .find(|&c| c.id == port || c.interface == Some(port.to_string())) + .unwrap_or(&default) + .to_owned(); + conn.controller = Some(uuid); + conns.push(conn); + } + + Ok(ConnectionCollection(conns)) + } +} + +impl TryFrom for NetworkConnectionsCollection { + type Error = NetworkStateError; + + fn try_from(state: NetworkState) -> Result { + let network_connections = state + .connections + .iter() + .filter(|c| c.controller.is_none()) + .map(|c| { + let mut conn = NetworkConnection::try_from(c.clone()).unwrap(); + if let Some(ref mut bond) = conn.bond { + bond.ports = state.ports_for(c.uuid); + } + if let Some(ref mut bridge) = conn.bridge { + bridge.ports = state.ports_for(c.uuid); + }; + conn + }) + .collect(); + + Ok(NetworkConnectionsCollection(network_connections)) + } +} + +impl TryFrom for StateSettings { + type Error = NetworkStateError; + + fn try_from(state: GeneralState) -> Result { + Ok(StateSettings { + connectivity: Some(state.connectivity), + copy_network: Some(state.copy_network), + wireless_enabled: Some(state.wireless_enabled), + networking_enabled: Some(state.networking_enabled), + }) + } +} + +impl TryFrom for NetworkSettings { + type Error = NetworkStateError; + + fn try_from(state: NetworkState) -> Result { + let connections: NetworkConnectionsCollection = state.try_into()?; + + Ok(NetworkSettings { connections }) + } +} + +impl TryFrom for Config { + type Error = NetworkStateError; + + fn try_from(state: NetworkState) -> Result { + let connections: NetworkConnectionsCollection = + ConnectionCollection(state.connections).try_into()?; + + Ok(Config { + connections: Some(connections), + state: Some(state.general_state.try_into()?), + }) + } +} + +impl TryFrom for SystemInfo { + type Error = NetworkStateError; + + fn try_from(state: NetworkState) -> Result { + let connections: NetworkConnectionsCollection = + ConnectionCollection(state.connections).try_into()?; + + Ok(SystemInfo { + access_points: state.access_points, + connections, + devices: state.devices, + state: state.general_state.try_into()?, + }) + } +} + +impl TryFrom for Proposal { + type Error = NetworkStateError; + + fn try_from(state: NetworkState) -> Result { + let connections: NetworkConnectionsCollection = + ConnectionCollection(state.connections).try_into()?; + + Ok(Proposal { + connections, + state: state.general_state.try_into()?, + }) + } +} + impl TryFrom for BondConfig { type Error = NetworkStateError; diff --git a/rust/agama-network/src/nm/builder.rs b/rust/agama-network/src/nm/builder.rs index 3f79fa659e..fa216c1dad 100644 --- a/rust/agama-network/src/nm/builder.rs +++ b/rust/agama-network/src/nm/builder.rs @@ -20,13 +20,12 @@ //! Conversion mechanism between proxies and model structs. -use crate::types::{DeviceState, DeviceType}; use crate::{ - model::{Device, IpConfig, IpRoute, MacAddress}, nm::{ model::NmDeviceType, proxies::{DeviceProxy, IP4ConfigProxy, IP6ConfigProxy}, }, + types::{Device, DeviceState, DeviceType, IpConfig, IpRoute, MacAddress}, }; use cidr::IpInet; use std::{collections::HashMap, net::IpAddr, str::FromStr}; diff --git a/rust/agama-network/src/nm/client.rs b/rust/agama-network/src/nm/client.rs index 3b79dd527c..2268dd014d 100644 --- a/rust/agama-network/src/nm/client.rs +++ b/rust/agama-network/src/nm/client.rs @@ -35,10 +35,9 @@ use super::proxies::{ SettingsProxy, WirelessProxy, }; use crate::model::{ - AccessPoint, Connection, ConnectionConfig, Device, GeneralState, SecurityProtocol, - NOT_COPY_NETWORK_PATH, + Connection, ConnectionConfig, GeneralState, SecurityProtocol, NOT_COPY_NETWORK_PATH, }; -use crate::types::{AddFlags, ConnectionFlags, DeviceType, UpdateFlags, SSID}; +use crate::types::{AccessPoint, AddFlags, ConnectionFlags, Device, DeviceType, UpdateFlags, SSID}; use agama_utils::dbus::get_optional_property; use semver::Version; use uuid::Uuid; @@ -159,6 +158,7 @@ impl<'a> NetworkManagerClient<'a> { .build() .await?; + let device = proxy.interface().await?; let ssid = SSID(wproxy.ssid().await?); let hw_address = wproxy.hw_address().await?; let strength = wproxy.strength().await?; @@ -167,6 +167,7 @@ impl<'a> NetworkManagerClient<'a> { let wpa_flags = wproxy.wpa_flags().await?; points.push(AccessPoint { + device, ssid, hw_address, strength, @@ -439,7 +440,7 @@ impl<'a> NetworkManagerClient<'a> { Ok(()) } - async fn get_connection_proxy(&self, uuid: Uuid) -> Result { + async fn get_connection_proxy(&self, uuid: Uuid) -> Result, NmError> { let proxy = SettingsProxy::new(&self.connection).await?; let uuid_s = uuid.to_string(); let path = proxy.get_connection_by_uuid(uuid_s.as_str()).await?; @@ -453,7 +454,7 @@ impl<'a> NetworkManagerClient<'a> { // Returns the DeviceProxy for the given device name // /// * `name`: Device name. - async fn get_device_proxy(&self, name: String) -> Result { + async fn get_device_proxy(&self, name: String) -> Result, NmError> { let mut device_path: Option = None; for path in &self.nm_proxy.get_all_devices().await? { let proxy = DeviceProxy::builder(&self.connection) diff --git a/rust/agama-network/src/nm/dbus.rs b/rust/agama-network/src/nm/dbus.rs index c983180840..13dd8c66c3 100644 --- a/rust/agama-network/src/nm/dbus.rs +++ b/rust/agama-network/src/nm/dbus.rs @@ -24,7 +24,7 @@ //! with nested hash maps (see [NestedHash] and [OwnedNestedHash]). use super::{error::NmError, model::*}; use crate::model::*; -use crate::types::{BondMode, SSID}; +use crate::types::*; use agama_utils::dbus::{ get_optional_property, get_property, to_owned_hash, NestedHash, OwnedNestedHash, }; @@ -693,13 +693,13 @@ fn wireless_config_to_dbus(config: &'_ WirelessConfig) -> NestedHash<'_> { NestedHash::from([(WIRELESS_KEY, wireless), (WIRELESS_SECURITY_KEY, security)]) } -fn bond_config_to_dbus(config: &BondConfig) -> HashMap<&str, zvariant::Value> { +fn bond_config_to_dbus(config: &BondConfig) -> HashMap<&str, zvariant::Value<'_>> { let mut options = config.options.0.clone(); options.insert("mode".to_string(), config.mode.to_string()); HashMap::from([("options", Value::new(options))]) } -fn bridge_config_to_dbus(bridge: &BridgeConfig) -> HashMap<&str, zvariant::Value> { +fn bridge_config_to_dbus(bridge: &BridgeConfig) -> HashMap<&str, zvariant::Value<'_>> { let mut hash = HashMap::new(); if let Some(stp) = bridge.stp { @@ -739,7 +739,9 @@ fn bridge_config_from_dbus(conn: &OwnedNestedHash) -> Result HashMap<&str, zvariant::Value> { +fn bridge_port_config_to_dbus( + bridge_port: &BridgePortConfig, +) -> HashMap<&str, zvariant::Value<'_>> { let mut hash = HashMap::new(); if let Some(prio) = bridge_port.priority { @@ -765,7 +767,7 @@ fn bridge_port_config_from_dbus( })) } -fn infiniband_config_to_dbus(config: &InfinibandConfig) -> HashMap<&str, zvariant::Value> { +fn infiniband_config_to_dbus(config: &InfinibandConfig) -> HashMap<&str, zvariant::Value<'_>> { let mut infiniband_config: HashMap<&str, zvariant::Value> = HashMap::from([ ( "transport-mode", @@ -801,7 +803,7 @@ fn infiniband_config_from_dbus( Ok(Some(config)) } -fn tun_config_to_dbus(config: &TunConfig) -> HashMap<&str, zvariant::Value> { +fn tun_config_to_dbus(config: &TunConfig) -> HashMap<&str, zvariant::Value<'_>> { let mut tun_config: HashMap<&str, zvariant::Value> = HashMap::from([("mode", Value::new(config.mode.clone() as u32))]); @@ -833,7 +835,7 @@ fn tun_config_from_dbus(conn: &OwnedNestedHash) -> Result, NmE })) } -fn ovs_bridge_config_to_dbus(br: &OvsBridgeConfig) -> HashMap<&str, zvariant::Value> { +fn ovs_bridge_config_to_dbus(br: &OvsBridgeConfig) -> HashMap<&str, zvariant::Value<'_>> { let mut br_config: HashMap<&str, zvariant::Value> = HashMap::new(); if let Some(mcast_snooping) = br.mcast_snooping_enable { @@ -863,7 +865,7 @@ fn ovs_bridge_from_dbus(conn: &OwnedNestedHash) -> Result HashMap<&str, zvariant::Value> { +fn ovs_port_config_to_dbus(config: &OvsPortConfig) -> HashMap<&str, zvariant::Value<'_>> { let mut port_config: HashMap<&str, zvariant::Value> = HashMap::new(); if let Some(tag) = &config.tag { @@ -883,7 +885,7 @@ fn ovs_port_from_dbus(conn: &OwnedNestedHash) -> Result, N })) } -fn ovs_interface_config_to_dbus(config: &OvsInterfaceConfig) -> HashMap<&str, zvariant::Value> { +fn ovs_interface_config_to_dbus(config: &OvsInterfaceConfig) -> HashMap<&str, zvariant::Value<'_>> { let mut ifc_config: HashMap<&str, zvariant::Value> = HashMap::new(); ifc_config.insert("type", config.interface_type.to_string().clone().into()); @@ -905,7 +907,7 @@ fn ovs_interface_from_dbus(conn: &OwnedNestedHash) -> Result HashMap<&str, zvariant::Value> { +fn match_config_to_dbus(match_config: &MatchConfig) -> HashMap<&str, zvariant::Value<'_>> { let drivers: Value = match_config.driver.to_vec().into(); let kernels: Value = match_config.kernel.to_vec().into(); @@ -1374,7 +1376,7 @@ fn bond_config_from_dbus(conn: &OwnedNestedHash) -> Result, N Ok(Some(bond)) } -fn vlan_config_to_dbus(cfg: &VlanConfig) -> NestedHash { +fn vlan_config_to_dbus(cfg: &VlanConfig) -> NestedHash<'_> { let vlan: HashMap<&str, zvariant::Value> = HashMap::from([ ("id", cfg.id.into()), ("parent", cfg.parent.clone().into()), @@ -1401,7 +1403,7 @@ fn vlan_config_from_dbus(conn: &OwnedNestedHash) -> Result, N })) } -fn ieee_8021x_config_to_dbus(config: &IEEE8021XConfig) -> HashMap<&str, zvariant::Value> { +fn ieee_8021x_config_to_dbus(config: &IEEE8021XConfig) -> HashMap<&str, zvariant::Value<'_>> { let mut ieee_8021x_config: HashMap<&str, zvariant::Value> = HashMap::from([( "eap", config @@ -1573,7 +1575,6 @@ mod test { connection_from_dbus, connection_to_dbus, merge_dbus_connections, NestedHash, OwnedNestedHash, }; - use crate::types::{BondMode, SSID}; use crate::{ model::*, nm::{ @@ -1583,6 +1584,7 @@ mod test { }, error::NmError, }, + types::*, }; use cidr::IpInet; use macaddr::MacAddr6; diff --git a/rust/agama-network/src/nm/error.rs b/rust/agama-network/src/nm/error.rs index 6e90c7bd44..be85ef8a8a 100644 --- a/rust/agama-network/src/nm/error.rs +++ b/rust/agama-network/src/nm/error.rs @@ -69,7 +69,7 @@ pub enum NmError { #[error("Invalid infiniband transport mode: '{0}'")] InvalidInfinibandTranportMode(#[from] crate::model::InvalidInfinibandTransportMode), #[error("Invalid MAC address: '{0}'")] - InvalidMACAddress(#[from] crate::model::InvalidMacAddress), + InvalidMACAddress(#[from] crate::types::InvalidMacAddress), #[error("Invalid network prefix: '{0}'")] InvalidNetworkPrefix(#[from] NetworkLengthTooLongError), #[error("Invalid network address: '{0}'")] diff --git a/rust/agama-network/src/nm/model.rs b/rust/agama-network/src/nm/model.rs index 10a6719b2f..75b499e03a 100644 --- a/rust/agama-network/src/nm/model.rs +++ b/rust/agama-network/src/nm/model.rs @@ -27,9 +27,9 @@ /// Using the newtype pattern around an String is enough. For proper support, we might replace this /// struct with an enum. use crate::{ - model::{Ipv4Method, Ipv6Method, SecurityProtocol, WirelessMode}, + model::{SecurityProtocol, WirelessMode}, nm::error::NmError, - types::{ConnectionState, DeviceType}, + types::{ConnectionState, DeviceType, Ipv4Method, Ipv6Method}, }; use std::fmt; use std::str::FromStr; diff --git a/rust/agama-network/src/nm/watcher.rs b/rust/agama-network/src/nm/watcher.rs index 2f446848fc..141ca193e2 100644 --- a/rust/agama-network/src/nm/watcher.rs +++ b/rust/agama-network/src/nm/watcher.rs @@ -25,9 +25,8 @@ use std::collections::{hash_map::Entry, HashMap}; -use crate::{ - adapter::Watcher, model::Device, nm::proxies::DeviceProxy, Action, NetworkAdapterError, -}; +use crate::types::Device; +use crate::{adapter::Watcher, nm::proxies::DeviceProxy, Action, NetworkAdapterError}; use anyhow::anyhow; use async_trait::async_trait; use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender}; @@ -359,14 +358,14 @@ impl<'a> ProxiesRegistry<'a> { pub fn remove_active_connection( &mut self, path: &OwnedObjectPath, - ) -> Option { + ) -> Option> { self.active_connections.remove(path) } /// Removes a device from the registry. /// /// * `path`: D-Bus object path. - pub fn remove_device(&mut self, path: &OwnedObjectPath) -> Option<(String, DeviceProxy)> { + pub fn remove_device(&mut self, path: &OwnedObjectPath) -> Option<(String, DeviceProxy<'_>)> { self.devices.remove(path) } diff --git a/rust/agama-network/src/system.rs b/rust/agama-network/src/system.rs index 64a80cc623..69bf54c5b4 100644 --- a/rust/agama-network/src/system.rs +++ b/rust/agama-network/src/system.rs @@ -21,10 +21,8 @@ use crate::{ action::Action, error::NetworkStateError, - model::{ - AccessPoint, Connection, Device, GeneralState, NetworkChange, NetworkState, StateConfig, - }, - types::DeviceType, + model::{Connection, GeneralState, NetworkChange, NetworkState, StateConfig}, + types::{AccessPoint, Config, Device, DeviceType, Proposal, SystemInfo}, Adapter, NetworkAdapterError, }; use std::error::Error; @@ -163,6 +161,31 @@ impl NetworkSystemClient { self.actions.send(Action::GetConnections(tx))?; Ok(rx.await?) } + pub async fn get_config(&self) -> Result { + let (tx, rx) = oneshot::channel(); + self.actions.send(Action::GetConfig(tx))?; + Ok(rx.await?) + } + + pub async fn get_proposal(&self) -> Result { + let (tx, rx) = oneshot::channel(); + self.actions.send(Action::GetProposal(tx))?; + Ok(rx.await?) + } + + pub async fn update_config(&self, config: Config) -> Result<(), NetworkSystemError> { + let (tx, rx) = oneshot::channel(); + self.actions + .send(Action::UpdateConfig(Box::new(config.clone()), tx))?; + let result = rx.await?; + Ok(result?) + } + + pub async fn get_system_config(&self) -> Result { + let (tx, rx) = oneshot::channel(); + self.actions.send(Action::GetSystemConfig(tx))?; + Ok(rx.await?) + } /// Adds a new connection. pub async fn add_connection(&self, connection: Connection) -> Result<(), NetworkSystemError> { @@ -310,6 +333,23 @@ impl NetworkSystemServer { let conn = self.state.get_connection_by_uuid(uuid); tx.send(conn.cloned()).unwrap(); } + Action::GetSystemConfig(tx) => { + let result = self.read().await?.try_into()?; + tx.send(result).unwrap(); + } + Action::GetConfig(tx) => { + let config: Config = self.state.clone().try_into()?; + tx.send(config).unwrap(); + } + Action::GetProposal(tx) => { + let config: Proposal = self.state.clone().try_into()?; + tx.send(config).unwrap(); + } + Action::UpdateConfig(config, tx) => { + let result = self.state.update_state(*config); + + tx.send(result).unwrap(); + } Action::GetConnections(tx) => { let connections = self .state @@ -424,6 +464,11 @@ impl NetworkSystemServer { Ok((conn, controlled)) } + /// Reads the system network configuration. + pub async fn read(&mut self) -> Result { + self.adapter.read(StateConfig::default()).await + } + /// Writes the network configuration. pub async fn write(&mut self) -> Result<(), NetworkAdapterError> { self.adapter.write(&self.state).await?; diff --git a/rust/agama-network/src/types.rs b/rust/agama-network/src/types.rs index f063d63949..a1b78ad55a 100644 --- a/rust/agama-network/src/types.rs +++ b/rust/agama-network/src/types.rs @@ -1,4 +1,4 @@ -// Copyright (c) [2024] SUSE LLC +// Copyright (c) [2024-2025] SUSE LLC // // All Rights Reserved. // @@ -18,171 +18,10 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use cidr::errors::NetworkParseError; +pub use agama_utils::api::network::*; use serde::{Deserialize, Serialize}; -use std::{ - fmt, - str::{self, FromStr}, -}; +use std::str::{self}; use thiserror::Error; -use zbus; - -use super::settings::NetworkConnection; - -/// Network device -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type")] -pub struct Device { - pub name: String, - pub type_: DeviceType, - pub state: DeviceState, -} - -#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize, utoipa::ToSchema)] -pub struct SSID(pub Vec); - -impl SSID { - pub fn to_vec(&self) -> &Vec { - &self.0 - } -} - -impl fmt::Display for SSID { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", str::from_utf8(&self.0).unwrap()) - } -} - -impl FromStr for SSID { - type Err = NetworkParseError; - - fn from_str(s: &str) -> Result { - Ok(SSID(s.as_bytes().into())) - } -} - -impl From for Vec { - fn from(value: SSID) -> Self { - value.0 - } -} - -#[derive(Default, Debug, PartialEq, Copy, Clone, Serialize, Deserialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub enum DeviceType { - Loopback = 0, - #[default] - Ethernet = 1, - Wireless = 2, - Dummy = 3, - Bond = 4, - Vlan = 5, - Bridge = 6, -} - -/// Network device state. -#[derive( - Default, - Serialize, - Deserialize, - Debug, - PartialEq, - Eq, - Clone, - Copy, - strum::Display, - strum::EnumString, - utoipa::ToSchema, -)] -#[strum(serialize_all = "camelCase")] -#[serde(rename_all = "camelCase")] -pub enum DeviceState { - #[default] - /// The device's state is unknown. - Unknown, - /// The device is recognized but not managed by Agama. - Unmanaged, - /// The device is detected but it cannot be used (wireless switched off, missing firmware, etc.). - Unavailable, - /// The device is connecting to the network. - Connecting, - /// The device is successfully connected to the network. - Connected, - /// The device is disconnecting from the network. - Disconnecting, - /// The device is disconnected from the network. - Disconnected, - /// The device failed to connect to a network. - Failed, -} - -#[derive( - Default, - Serialize, - Deserialize, - Debug, - PartialEq, - Eq, - Clone, - Copy, - strum::Display, - strum::EnumString, - utoipa::ToSchema, -)] -#[strum(serialize_all = "camelCase")] -#[serde(rename_all = "camelCase")] -pub enum ConnectionState { - /// The connection is getting activated. - Activating, - /// The connection is activated. - Activated, - /// The connection is getting deactivated. - Deactivating, - #[default] - /// The connection is deactivated. - Deactivated, -} - -#[derive(Debug, Default, Clone, Copy, PartialEq, Serialize, Deserialize, utoipa::ToSchema)] -#[serde(rename_all = "camelCase")] -pub enum Status { - #[default] - Up, - Down, - Removed, - // Workaound for not modify the connection status - Keep, -} - -impl fmt::Display for Status { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let name = match &self { - Status::Up => "up", - Status::Down => "down", - Status::Keep => "keep", - Status::Removed => "removed", - }; - write!(f, "{}", name) - } -} - -#[derive(Debug, Error, PartialEq)] -#[error("Invalid status: {0}")] -pub struct InvalidStatus(String); - -impl TryFrom<&str> for Status { - type Error = InvalidStatus; - - fn try_from(value: &str) -> Result { - match value { - "up" => Ok(Status::Up), - "down" => Ok(Status::Down), - "keep" => Ok(Status::Keep), - "removed" => Ok(Status::Removed), - _ => Err(InvalidStatus(value.to_string())), - } - } -} // https://networkmanager.dev/docs/api/latest/nm-dbus-types.html#NMSettingsConnectionFlags #[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone, Copy, utoipa::ToSchema)] @@ -232,159 +71,3 @@ pub enum UpdateFlags { BlockAutoconnect = 0x20, NoReapply = 0x40, } - -/// Bond mode -#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone, Copy, utoipa::ToSchema)] -pub enum BondMode { - #[serde(rename = "balance-rr")] - RoundRobin = 0, - #[serde(rename = "active-backup")] - ActiveBackup = 1, - #[serde(rename = "balance-xor")] - BalanceXOR = 2, - #[serde(rename = "broadcast")] - Broadcast = 3, - #[serde(rename = "802.3ad")] - LACP = 4, - #[serde(rename = "balance-tlb")] - BalanceTLB = 5, - #[serde(rename = "balance-alb")] - BalanceALB = 6, -} -impl Default for BondMode { - fn default() -> Self { - Self::RoundRobin - } -} - -impl std::fmt::Display for BondMode { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - "{}", - match self { - BondMode::RoundRobin => "balance-rr", - BondMode::ActiveBackup => "active-backup", - BondMode::BalanceXOR => "balance-xor", - BondMode::Broadcast => "broadcast", - BondMode::LACP => "802.3ad", - BondMode::BalanceTLB => "balance-tlb", - BondMode::BalanceALB => "balance-alb", - } - ) - } -} - -#[derive(Debug, Error, PartialEq)] -#[error("Invalid bond mode: {0}")] -pub struct InvalidBondMode(String); - -impl TryFrom<&str> for BondMode { - type Error = InvalidBondMode; - - fn try_from(value: &str) -> Result { - match value { - "balance-rr" => Ok(BondMode::RoundRobin), - "active-backup" => Ok(BondMode::ActiveBackup), - "balance-xor" => Ok(BondMode::BalanceXOR), - "broadcast" => Ok(BondMode::Broadcast), - "802.3ad" => Ok(BondMode::LACP), - "balance-tlb" => Ok(BondMode::BalanceTLB), - "balance-alb" => Ok(BondMode::BalanceALB), - _ => Err(InvalidBondMode(value.to_string())), - } - } -} -impl TryFrom for BondMode { - type Error = InvalidBondMode; - - fn try_from(value: u8) -> Result { - match value { - 0 => Ok(BondMode::RoundRobin), - 1 => Ok(BondMode::ActiveBackup), - 2 => Ok(BondMode::BalanceXOR), - 3 => Ok(BondMode::Broadcast), - 4 => Ok(BondMode::LACP), - 5 => Ok(BondMode::BalanceTLB), - 6 => Ok(BondMode::BalanceALB), - _ => Err(InvalidBondMode(value.to_string())), - } - } -} - -impl From for zbus::fdo::Error { - fn from(value: InvalidBondMode) -> zbus::fdo::Error { - zbus::fdo::Error::Failed(format!("Network error: {value}")) - } -} - -#[derive(Debug, Error, PartialEq)] -#[error("Invalid device type: {0}")] -pub struct InvalidDeviceType(u8); - -impl TryFrom for DeviceType { - type Error = InvalidDeviceType; - - fn try_from(value: u8) -> Result { - match value { - 0 => Ok(DeviceType::Loopback), - 1 => Ok(DeviceType::Ethernet), - 2 => Ok(DeviceType::Wireless), - 3 => Ok(DeviceType::Dummy), - 4 => Ok(DeviceType::Bond), - 5 => Ok(DeviceType::Vlan), - 6 => Ok(DeviceType::Bridge), - _ => Err(InvalidDeviceType(value)), - } - } -} - -impl From for zbus::fdo::Error { - fn from(value: InvalidDeviceType) -> zbus::fdo::Error { - zbus::fdo::Error::Failed(format!("Network error: {value}")) - } -} - -// FIXME: found a better place for the HTTP types. -// -// TODO: If the client ignores the additional "state" field, this struct -// does not need to be here. -#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] -pub struct NetworkConnectionWithState { - #[serde(flatten)] - pub connection: NetworkConnection, - pub state: ConnectionState, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_display_ssid() { - let ssid = SSID(vec![97, 103, 97, 109, 97]); - assert_eq!(format!("{}", ssid), "agama"); - } - - #[test] - fn test_ssid_to_vec() { - let vec = vec![97, 103, 97, 109, 97]; - let ssid = SSID(vec.clone()); - assert_eq!(ssid.to_vec(), &vec); - } - - #[test] - fn test_device_type_from_u8() { - let dtype = DeviceType::try_from(0); - assert_eq!(dtype, Ok(DeviceType::Loopback)); - - let dtype = DeviceType::try_from(128); - assert_eq!(dtype, Err(InvalidDeviceType(128))); - } - - #[test] - fn test_display_bond_mode() { - let mode = BondMode::try_from(1).unwrap(); - assert_eq!(format!("{}", mode), "active-backup"); - } -} diff --git a/rust/agama-server/Cargo.toml b/rust/agama-server/Cargo.toml index 338df05d2e..42b3976db3 100644 --- a/rust/agama-server/Cargo.toml +++ b/rust/agama-server/Cargo.toml @@ -13,6 +13,7 @@ agama-utils = { path = "../agama-utils" } agama-l10n = { path = "../agama-l10n" } agama-locale-data = { path = "../agama-locale-data" } agama-manager = { path = "../agama-manager" } +agama-network = { path = "../agama-network" } zbus = { version = "5", default-features = false, features = ["tokio"] } uuid = { version = "1.10.0", features = ["v4"] } thiserror = "2.0.12" diff --git a/rust/agama-server/src/lib.rs b/rust/agama-server/src/lib.rs index 3000c9fd87..0339ee0d70 100644 --- a/rust/agama-server/src/lib.rs +++ b/rust/agama-server/src/lib.rs @@ -26,7 +26,6 @@ pub mod files; pub mod hostname; pub mod logs; pub mod manager; -pub mod network; pub mod profile; pub mod scripts; pub mod security; diff --git a/rust/agama-server/src/network/web.rs b/rust/agama-server/src/network/web.rs deleted file mode 100644 index 004f75d231..0000000000 --- a/rust/agama-server/src/network/web.rs +++ /dev/null @@ -1,490 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -//! This module implements the web API for the network module. - -use crate::error::Error; -use anyhow::Context; -use axum::{ - extract::{Path, State}, - http::StatusCode, - response::{IntoResponse, Response}, - routing::{delete, get, post}, - Json, Router, -}; -use uuid::Uuid; - -use agama_lib::{ - error::ServiceError, - event, http, - network::{ - error::NetworkStateError, - model::{AccessPoint, Connection, Device, GeneralState}, - settings::NetworkConnection, - types::NetworkConnectionWithState, - Adapter, NetworkSystem, NetworkSystemClient, NetworkSystemError, - }, -}; - -use serde::Deserialize; -use serde_json::json; -use thiserror::Error; - -#[derive(Error, Debug)] -pub enum NetworkError { - #[error("Unknown connection id: {0}")] - UnknownConnection(String), - #[error("Cannot translate: {0}")] - CannotTranslate(#[from] Error), - #[error("Cannot add new connection: {0}")] - CannotAddConnection(String), - #[error("Cannot update configuration: {0}")] - CannotUpdate(String), - #[error("Cannot apply configuration")] - CannotApplyConfig, - // TODO: to be removed after adapting to the NetworkSystemServer API - #[error("Network state error: {0}")] - Error(#[from] NetworkStateError), - #[error("Network system error: {0}")] - SystemError(#[from] NetworkSystemError), -} - -impl IntoResponse for NetworkError { - fn into_response(self) -> Response { - let body = json!({ - "error": self.to_string() - }); - (StatusCode::BAD_REQUEST, Json(body)).into_response() - } -} - -#[derive(Clone)] -struct NetworkServiceState { - network: NetworkSystemClient, -} - -/// Sets up and returns the axum service for the network module. -/// * `adapter`: networking configuration adapter. -/// * `events`: sending-half of the broadcast channel. -pub async fn network_service( - adapter: T, - events: http::event::OldSender, -) -> Result { - let network = NetworkSystem::new(adapter); - // FIXME: we are somehow abusing ServiceError. The HTTP/JSON API should have its own - // error type. - let client = network - .start() - .await - .context("Could not start the network configuration service.")?; - - let mut changes = client.subscribe(); - tokio::spawn(async move { - loop { - match changes.recv().await { - Ok(message) => { - let change = event!(NetworkChange { change: message }); - if let Err(e) = events.send(change) { - eprintln!("Could not send the event: {}", e); - } - } - Err(e) => { - eprintln!("Could not send the event: {}", e); - } - } - } - }); - - let state = NetworkServiceState { network: client }; - - Ok(Router::new() - .route("/state", get(general_state).put(update_general_state)) - .route("/connections", get(connections).post(add_connection)) - .route( - "/connections/:id", - delete(delete_connection) - .put(update_connection) - .get(connection), - ) - .route("/connections/:id/connect", post(connect)) - .route("/connections/:id/disconnect", post(disconnect)) - .route("/connections/persist", post(persist)) - .route("/devices", get(devices)) - .route("/system/apply", post(apply)) - .route("/wifi", get(wifi_networks)) - .with_state(state)) -} - -#[utoipa::path( - get, - path = "/state", - context_path = "/api/network", - responses( - (status = 200, description = "Get general network config", body = GeneralState) - ) -)] -async fn general_state( - State(state): State, -) -> Result, NetworkError> { - let general_state = state.network.get_state().await?; - Ok(Json(general_state)) -} - -#[utoipa::path( - put, - path = "/state", - context_path = "/api/network", - responses( - (status = 200, description = "Update general network config", body = GeneralState) - ) -)] -async fn update_general_state( - State(state): State, - Json(value): Json, -) -> Result, NetworkError> { - state.network.update_state(value)?; - let state = state.network.get_state().await?; - Ok(Json(state)) -} - -#[utoipa::path( - get, - path = "/wifi", - context_path = "/api/network", - responses( - (status = 200, description = "List of wireless networks", body = Vec) - ) -)] -async fn wifi_networks( - State(state): State, -) -> Result>, NetworkError> { - state.network.wifi_scan().await?; - let access_points = state.network.get_access_points().await?; - - let mut networks = vec![]; - for ap in access_points { - if !ap.ssid.to_string().is_empty() { - networks.push(ap); - } - } - - Ok(Json(networks)) -} - -#[utoipa::path( - get, - path = "/devices", - context_path = "/api/network", - responses( - (status = 200, description = "List of devices", body = Vec) - ) -)] -async fn devices( - State(state): State, -) -> Result>, NetworkError> { - Ok(Json(state.network.get_devices().await?)) -} - -#[utoipa::path( - get, - path = "/connections", - context_path = "/api/network", - responses( - (status = 200, description = "List of known connections", body = Vec) - ) -)] -async fn connections( - State(state): State, -) -> Result>, NetworkError> { - let connections = state.network.get_connections().await?; - - let network_connections = connections - .iter() - .filter(|c| c.controller.is_none()) - .map(|c| { - let state = c.state; - let mut conn = NetworkConnection::try_from(c.clone()).unwrap(); - if let Some(ref mut bond) = conn.bond { - bond.ports = ports_for(connections.to_owned(), c.uuid); - } - if let Some(ref mut bridge) = conn.bridge { - bridge.ports = ports_for(connections.to_owned(), c.uuid); - }; - NetworkConnectionWithState { - connection: conn, - state, - } - }) - .collect(); - - Ok(Json(network_connections)) -} - -fn ports_for(connections: Vec, uuid: Uuid) -> Vec { - return connections - .iter() - .filter(|c| c.controller == Some(uuid)) - .map(|c| { - if let Some(interface) = c.interface.to_owned() { - interface - } else { - c.clone().id - } - }) - .collect(); -} - -#[utoipa::path( - post, - path = "/connections", - context_path = "/api/network", - responses( - (status = 200, description = "Add a new connection", body = Connection) - ) -)] -async fn add_connection( - State(state): State, - Json(net_conn): Json, -) -> Result, NetworkError> { - let bond = net_conn.bond.clone(); - let bridge = net_conn.bridge.clone(); - let conn = Connection::try_from(net_conn)?; - let id = conn.id.clone(); - - state.network.add_connection(conn.clone()).await?; - - match state.network.get_connection(&id).await? { - None => Err(NetworkError::CannotAddConnection(id.clone())), - Some(conn) => { - if let Some(bond) = bond { - state.network.set_ports(conn.uuid, bond.ports).await?; - } - if let Some(bridge) = bridge { - state.network.set_ports(conn.uuid, bridge.ports).await?; - } - Ok(Json(conn)) - } - } -} - -#[utoipa::path( - get, - path = "/connections/:id", - context_path = "/api/network", - responses( - (status = 200, description = "Get connection given by its ID", body = NetworkConnection) - ) -)] -async fn connection( - State(state): State, - Path(id): Path, -) -> Result, NetworkError> { - let conn = state - .network - .get_connection(&id) - .await? - .ok_or_else(|| NetworkError::UnknownConnection(id.clone()))?; - - let conn = NetworkConnection::try_from(conn)?; - - Ok(Json(conn)) -} - -#[utoipa::path( - delete, - path = "/connections/:id", - context_path = "/api/network", - responses( - (status = 200, description = "Delete connection", body = Connection) - ) -)] -async fn delete_connection( - State(state): State, - Path(id): Path, -) -> impl IntoResponse { - if state.network.remove_connection(&id).await.is_ok() { - StatusCode::NO_CONTENT - } else { - StatusCode::NOT_FOUND - } -} - -#[utoipa::path( - put, - path = "/connections/:id", - context_path = "/api/network", - responses( - (status = 204, description = "Update connection", body = Connection) - ) -)] -async fn update_connection( - State(state): State, - Path(id): Path, - Json(conn): Json, -) -> Result { - let orig_conn = state - .network - .get_connection(&id) - .await? - .ok_or_else(|| NetworkError::UnknownConnection(id.clone()))?; - let bond = conn.bond.clone(); - let bridge = conn.bridge.clone(); - - let mut conn = Connection::try_from(conn)?; - conn.uuid = orig_conn.uuid; - - state.network.update_connection(conn.clone()).await?; - - if let Some(bond) = bond { - state.network.set_ports(conn.uuid, bond.ports).await?; - } - if let Some(bridge) = bridge { - state.network.set_ports(conn.uuid, bridge.ports).await?; - } - - Ok(StatusCode::NO_CONTENT) -} - -#[utoipa::path( - post, - path = "/connections/:id/connect", - context_path = "/api/network", - responses( - (status = 204, description = "Connect to the given connection", body = String) - ) -)] -async fn connect( - State(state): State, - Path(id): Path, -) -> Result { - let Some(mut conn) = state.network.get_connection(&id).await? else { - return Err(NetworkError::UnknownConnection(id)); - }; - conn.set_up(); - - state - .network - .update_connection(conn) - .await - .map_err(|_| NetworkError::CannotApplyConfig)?; - - state - .network - .apply() - .await - .map_err(|_| NetworkError::CannotApplyConfig)?; - - Ok(StatusCode::NO_CONTENT) -} - -#[utoipa::path( - post, - path = "/connections/:id/disconnect", - context_path = "/api/network", - responses( - (status = 204, description = "Connect to the given connection", body = String) - ) -)] -async fn disconnect( - State(state): State, - Path(id): Path, -) -> Result { - let Some(mut conn) = state.network.get_connection(&id).await? else { - return Err(NetworkError::UnknownConnection(id)); - }; - conn.set_down(); - - state - .network - .update_connection(conn) - .await - .map_err(|_| NetworkError::CannotApplyConfig)?; - - state - .network - .apply() - .await - .map_err(|_| NetworkError::CannotApplyConfig)?; - - Ok(StatusCode::NO_CONTENT) -} - -#[derive(Deserialize, utoipa::ToSchema)] -pub struct PersistParams { - pub only: Option>, - pub value: bool, -} - -#[utoipa::path( - post, - path = "/connections/persist", - context_path = "/api/network", - responses( - (status = 204, description = "Persist the given connection to disk", body = PersistParams) - ) -)] -async fn persist( - State(state): State, - Json(persist): Json, -) -> Result { - let mut connections = state.network.get_connections().await?; - let ids = persist.only.unwrap_or(vec![]); - - for conn in connections.iter_mut() { - if ids.is_empty() || ids.contains(&conn.id) { - conn.persistent = persist.value; - conn.keep_status(); - - state - .network - .update_connection(conn.to_owned()) - .await - .map_err(|_| NetworkError::CannotApplyConfig)?; - } - } - - state - .network - .apply() - .await - .map_err(|_| NetworkError::CannotApplyConfig)?; - - Ok(StatusCode::NO_CONTENT) -} - -#[utoipa::path( - post, - path = "/system/apply", - context_path = "/api/network", - responses( - (status = 204, description = "Apply configuration") - ) -)] -async fn apply( - State(state): State, -) -> Result { - state - .network - .apply() - .await - .map_err(|_| NetworkError::CannotApplyConfig)?; - - Ok(StatusCode::NO_CONTENT) -} diff --git a/rust/agama-server/src/web.rs b/rust/agama-server/src/web.rs index 5bd9bd4b72..6641c41aba 100644 --- a/rust/agama-server/src/web.rs +++ b/rust/agama-server/src/web.rs @@ -30,7 +30,6 @@ use crate::{ files::web::files_service, hostname::web::hostname_service, manager::web::{manager_service, manager_stream}, - network::{web::network_service, NetworkManagerAdapter}, profile::web::profile_service, scripts::web::scripts_service, security::security_service, @@ -77,10 +76,6 @@ pub async fn service

( where P: AsRef, { - let network_adapter = NetworkManagerAdapter::from_system() - .await - .expect("Could not connect to NetworkManager to read the configuration"); - let progress = ProgressService::start(dbus.clone(), old_events.clone()).await; let router = MainServiceBuilder::new(events.clone(), old_events.clone(), web_ui_dir) @@ -97,10 +92,6 @@ where .add_service("/storage", storage_service(dbus.clone(), progress).await?) .add_service("/iscsi", iscsi_service(dbus.clone()).await?) .add_service("/bootloader", bootloader_service(dbus.clone()).await?) - .add_service( - "/network", - network_service(network_adapter, old_events).await?, - ) .add_service("/users", users_service(dbus.clone()).await?) .add_service("/scripts", scripts_service().await?) .add_service("/files", files_service().await?) diff --git a/rust/agama-server/src/web/docs.rs b/rust/agama-server/src/web/docs.rs index 219a476ed3..87fcffbc08 100644 --- a/rust/agama-server/src/web/docs.rs +++ b/rust/agama-server/src/web/docs.rs @@ -24,8 +24,6 @@ mod config; pub use config::ConfigApiDocBuilder; mod hostname; pub use hostname::HostnameApiDocBuilder; -mod network; -pub use network::NetworkApiDocBuilder; mod storage; pub use storage::StorageApiDocBuilder; mod bootloader; diff --git a/rust/agama-server/src/web/docs/config.rs b/rust/agama-server/src/web/docs/config.rs index ef0c136a18..1ae7c030db 100644 --- a/rust/agama-server/src/web/docs/config.rs +++ b/rust/agama-server/src/web/docs/config.rs @@ -54,30 +54,17 @@ impl ApiDocBuilder for ConfigApiDocBuilder { .schema_from::() .schema_from::() .schema_from::() - .schema_from::() .schema_from::() .schema_from::() .schema_from::() .schema_from::() .schema_from::() .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() .schema_from::() - .schema_from::() .schema_from::() .schema_from::() .schema_from::() .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() .schema_from::() .schema_from::() .schema_from::() @@ -99,16 +86,6 @@ impl ApiDocBuilder for ConfigApiDocBuilder { .schema_from::() .schema_from::() .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() .schema_from::() .schema_from::() .schema_from::() @@ -172,6 +149,30 @@ impl ApiDocBuilder for ConfigApiDocBuilder { .schema_from::() .schema_from::() .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() + .schema_from::() .schema_from::() .schema_from::() .schema_from::() diff --git a/rust/agama-server/src/web/docs/network.rs b/rust/agama-server/src/web/docs/network.rs deleted file mode 100644 index 26661f41fe..0000000000 --- a/rust/agama-server/src/web/docs/network.rs +++ /dev/null @@ -1,119 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -use agama_utils::openapi::schemas; -use utoipa::openapi::{Components, ComponentsBuilder, Paths, PathsBuilder}; - -use super::ApiDocBuilder; - -pub struct NetworkApiDocBuilder; - -impl ApiDocBuilder for NetworkApiDocBuilder { - fn title(&self) -> String { - "Network HTTP API".to_string() - } - - fn paths(&self) -> Paths { - PathsBuilder::new() - .path_from::() - .path_from::() - .path_from::() - .path_from::() - .path_from::() - .path_from::() - .path_from::() - .path_from::() - .path_from::() - .path_from::() - .path_from::() - .path_from::() - .path_from::() - .build() - } - - fn components(&self) -> Components { - ComponentsBuilder::new() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema_from::() - .schema("IpAddr", schemas::ip_addr()) - .schema("IpInet", schemas::ip_inet()) - .schema("macaddr.MacAddr6", schemas::mac_addr6()) - .build() - } -} diff --git a/rust/agama-server/tests/network_service.rs b/rust/agama-server/tests/network_service.rs deleted file mode 100644 index f1714d4e8e..0000000000 --- a/rust/agama-server/tests/network_service.rs +++ /dev/null @@ -1,285 +0,0 @@ -// Copyright (c) [2024] SUSE LLC -// -// All Rights Reserved. -// -// This program is free software; you can redistribute it and/or modify it -// under the terms of the GNU General Public License as published by the Free -// Software Foundation; either version 2 of the License, or (at your option) -// any later version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -// more details. -// -// You should have received a copy of the GNU General Public License along -// with this program; if not, contact SUSE LLC. -// -// To contact SUSE LLC about this file by physical or electronic mail, you may -// find current contact information at www.suse.com. - -pub mod common; - -use agama_lib::error::ServiceError; -use agama_lib::network::settings::{BondSettings, BridgeSettings, NetworkConnection}; -use agama_lib::network::types::{DeviceType, SSID}; -use agama_lib::network::{ - model::{self, AccessPoint, GeneralState, NetworkState, StateConfig}, - Adapter, NetworkAdapterError, -}; -use agama_server::network::web::network_service; - -use async_trait::async_trait; -use axum::http::header; -use axum::{ - body::Body, - http::{Method, Request, StatusCode}, - Router, -}; -use common::body_to_string; -use serde_json::to_string; -use std::error::Error; -use tokio::{sync::broadcast, test}; -use tower::ServiceExt; - -async fn build_state() -> NetworkState { - let general_state = GeneralState::default(); - let device = model::Device { - name: String::from("eth0"), - type_: DeviceType::Ethernet, - ..Default::default() - }; - let eth0 = model::Connection::new("eth0".to_string(), DeviceType::Ethernet); - - NetworkState::new(general_state, vec![], vec![device], vec![eth0]) -} - -async fn build_service(state: NetworkState) -> Result { - let adapter = NetworkTestAdapter(state); - let (tx, _rx) = broadcast::channel(16); - network_service(adapter, tx).await -} - -#[derive(Default)] -pub struct NetworkTestAdapter(NetworkState); - -#[async_trait] -impl Adapter for NetworkTestAdapter { - async fn read(&self, _: StateConfig) -> Result { - Ok(self.0.clone()) - } - - async fn write(&self, _network: &NetworkState) -> Result<(), NetworkAdapterError> { - unimplemented!("Not used in tests"); - } -} - -#[test] -async fn test_network_state() -> Result<(), Box> { - let state = build_state().await; - let network_service = build_service(state).await?; - - let request = Request::builder() - .uri("/state") - .method(Method::GET) - .body(Body::empty()) - .unwrap(); - - let response = network_service.oneshot(request).await?; - assert_eq!(response.status(), StatusCode::OK); - let body = body_to_string(response.into_body()).await; - assert!(body.contains(r#""wirelessEnabled":false"#)); - Ok(()) -} - -#[test] -async fn test_change_network_state() -> Result<(), Box> { - let mut state = build_state().await; - let network_service = build_service(state.clone()).await?; - state.general_state.wireless_enabled = true; - - let request = Request::builder() - .uri("/state") - .method(Method::PUT) - .header(header::CONTENT_TYPE, "application/json") - .body(to_string(&state.general_state)?) - .unwrap(); - - let response = network_service.oneshot(request).await?; - assert_eq!(response.status(), StatusCode::OK); - let body = response.into_body(); - let body = body_to_string(body).await; - assert_eq!(body, to_string(&state.general_state)?); - Ok(()) -} - -#[test] -async fn test_network_connections() -> Result<(), Box> { - let state = build_state().await; - let network_service = build_service(state.clone()).await?; - - let request = Request::builder() - .uri("/connections") - .method(Method::GET) - .body(Body::empty()) - .unwrap(); - - let response = network_service.oneshot(request).await?; - assert_eq!(response.status(), StatusCode::OK); - let body = body_to_string(response.into_body()).await; - assert!(body.contains(r#""id":"eth0""#)); - Ok(()) -} - -#[test] -async fn test_network_devices() -> Result<(), Box> { - let state = build_state().await; - let network_service = build_service(state.clone()).await?; - - let request = Request::builder() - .uri("/devices") - .method(Method::GET) - .body(Body::empty()) - .unwrap(); - - let response = network_service.oneshot(request).await?; - assert_eq!(response.status(), StatusCode::OK); - let body = body_to_string(response.into_body()).await; - assert!(body.contains(r#""name":"eth0""#)); - Ok(()) -} - -#[test] -async fn test_network_wifis() -> Result<(), Box> { - let mut state = build_state().await; - state.access_points = vec![ - AccessPoint { - ssid: SSID("AgamaNetwork".as_bytes().into()), - hw_address: "00:11:22:33:44:00".into(), - ..Default::default() - }, - AccessPoint { - ssid: SSID("AgamaNetwork2".as_bytes().into()), - hw_address: "00:11:22:33:44:01".into(), - ..Default::default() - }, - ]; - let network_service = build_service(state.clone()).await?; - - let request = Request::builder() - .uri("/wifi") - .method(Method::GET) - .body(Body::empty()) - .unwrap(); - - let response = network_service.oneshot(request).await?; - assert_eq!(response.status(), StatusCode::OK); - let body = body_to_string(response.into_body()).await; - assert!(body.contains(r#""ssid":"AgamaNetwork""#)); - assert!(body.contains(r#""ssid":"AgamaNetwork2""#)); - Ok(()) -} - -#[test] -async fn test_add_bond_connection() -> Result<(), Box> { - let state = build_state().await; - let network_service = build_service(state.clone()).await?; - - let eth2 = NetworkConnection { - id: "eth2".to_string(), - ..Default::default() - }; - - let bond0 = NetworkConnection { - id: "bond0".to_string(), - method4: Some("auto".to_string()), - method6: Some("disabled".to_string()), - interface: Some("bond0".to_string()), - bond: Some(BondSettings { - mode: "active-backup".to_string(), - ports: vec!["eth0".to_string()], - options: Some("primary=eth0".to_string()), - }), - ..Default::default() - }; - - let request = Request::builder() - .uri("/connections") - .header("Content-Type", "application/json") - .method(Method::POST) - .body(serde_json::to_string(ð2)?) - .unwrap(); - - let response = network_service.clone().oneshot(request).await?; - assert_eq!(response.status(), StatusCode::OK); - - let request = Request::builder() - .uri("/connections") - .header("Content-Type", "application/json") - .method(Method::POST) - .body(serde_json::to_string(&bond0)?) - .unwrap(); - - let response = network_service.clone().oneshot(request).await?; - assert_eq!(response.status(), StatusCode::OK); - - let request = Request::builder() - .uri("/connections") - .method(Method::GET) - .body(Body::empty()) - .unwrap(); - - let response = network_service.clone().oneshot(request).await?; - assert_eq!(response.status(), StatusCode::OK); - let body = body_to_string(response.into_body()).await; - assert!(body.contains(r#""id":"bond0""#)); - assert!(body.contains(r#""mode":"active-backup""#)); - assert!(body.contains(r#""primary=eth0""#)); - assert!(body.contains(r#""ports":["eth0"]"#)); - - Ok(()) -} - -#[test] -async fn test_add_bridge_connection() -> Result<(), Box> { - let state = build_state().await; - let network_service = build_service(state.clone()).await?; - - let br0 = NetworkConnection { - id: "br0".to_string(), - method4: Some("manual".to_string()), - method6: Some("disabled".to_string()), - interface: Some("br0".to_string()), - bridge: Some(BridgeSettings { - ports: vec!["eth0".to_string()], - stp: Some(false), - ..Default::default() - }), - ..Default::default() - }; - - let request = Request::builder() - .uri("/connections") - .header("Content-Type", "application/json") - .method(Method::POST) - .body(serde_json::to_string(&br0)?) - .unwrap(); - - let response = network_service.clone().oneshot(request).await?; - assert_eq!(response.status(), StatusCode::OK); - - let request = Request::builder() - .uri("/connections") - .method(Method::GET) - .body(Body::empty()) - .unwrap(); - - let response = network_service.clone().oneshot(request).await?; - assert_eq!(response.status(), StatusCode::OK); - let body = body_to_string(response.into_body()).await; - assert!(body.contains(r#""id":"br0""#)); - assert!(body.contains(r#""ports":["eth0"]"#)); - assert!(body.contains(r#""stp":false"#)); - - Ok(()) -} diff --git a/rust/agama-utils/Cargo.toml b/rust/agama-utils/Cargo.toml index 15f4b79fb0..961d044bde 100644 --- a/rust/agama-utils/Cargo.toml +++ b/rust/agama-utils/Cargo.toml @@ -19,6 +19,8 @@ zbus = "5.7.1" zvariant = "5.5.2" gettext-rs = { version = "0.7.2", features = ["gettext-system"] } uuid = { version = "1.10.0", features = ["v4"] } +cidr = { version = "0.3.1", features = ["serde"] } +macaddr = { version = "1.0.1", features = ["serde_std"] } [dev-dependencies] tokio-test = "0.4.4" diff --git a/rust/agama-utils/src/api.rs b/rust/agama-utils/src/api.rs index 89ccd79dcc..9348189faf 100644 --- a/rust/agama-utils/src/api.rs +++ b/rust/agama-utils/src/api.rs @@ -52,5 +52,6 @@ mod action; pub use action::Action; pub mod l10n; +pub mod network; pub mod question; pub mod storage; diff --git a/rust/agama-utils/src/api/config.rs b/rust/agama-utils/src/api/config.rs index c648114f46..31608dc14b 100644 --- a/rust/agama-utils/src/api/config.rs +++ b/rust/agama-utils/src/api/config.rs @@ -18,7 +18,7 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::api::{l10n, question, storage}; +use crate::api::{l10n, network, question, storage}; use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, Default, Deserialize, Serialize, utoipa::ToSchema)] @@ -28,6 +28,8 @@ pub struct Config { #[serde(alias = "localization")] pub l10n: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub network: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub questions: Option, #[serde(skip_serializing_if = "Option::is_none")] #[serde(flatten)] diff --git a/rust/agama-server/src/network.rs b/rust/agama-utils/src/api/network.rs similarity index 70% rename from rust/agama-server/src/network.rs rename to rust/agama-utils/src/api/network.rs index 95e80f2639..75eb23f9a4 100644 --- a/rust/agama-server/src/network.rs +++ b/rust/agama-utils/src/api/network.rs @@ -1,4 +1,4 @@ -// Copyright (c) [2024] SUSE LLC +// Copyright (c) [2025] SUSE LLC // // All Rights Reserved. // @@ -18,8 +18,17 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -pub mod web; +//! This module contains all Agama public types that might be available over +//! the HTTP and WebSocket API. -pub use agama_lib::network::{ - model::NetworkState, Action, Adapter, NetworkAdapterError, NetworkManagerAdapter, NetworkSystem, -}; +mod config; +pub use config::Config; +mod proposal; +pub use proposal::Proposal; +mod settings; +mod system_info; +pub use system_info::SystemInfo; + +mod types; +pub use settings::*; +pub use types::*; diff --git a/rust/agama-utils/src/api/network/config.rs b/rust/agama-utils/src/api/network/config.rs new file mode 100644 index 0000000000..51b7848513 --- /dev/null +++ b/rust/agama-utils/src/api/network/config.rs @@ -0,0 +1,34 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +//! Representation of the network settings + +use crate::api::network::{NetworkConnectionsCollection, StateSettings}; +use serde::{Deserialize, Serialize}; +use std::default::Default; + +/// Network config settings for installation +#[derive(Clone, Debug, Default, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct Config { + /// Connections to use in the installation + pub connections: Option, + pub state: Option, +} diff --git a/rust/agama-utils/src/api/network/proposal.rs b/rust/agama-utils/src/api/network/proposal.rs new file mode 100644 index 0000000000..39bbe548a6 --- /dev/null +++ b/rust/agama-utils/src/api/network/proposal.rs @@ -0,0 +1,34 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +//! Representation of the network settings + +use crate::api::network::{NetworkConnectionsCollection, StateSettings}; +use serde::{Deserialize, Serialize}; +use std::default::Default; + +/// Network proposal settings for installation +#[derive(Clone, Debug, Default, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct Proposal { + /// Connections to use in the installation + pub connections: NetworkConnectionsCollection, + pub state: StateSettings, +} diff --git a/rust/agama-network/src/settings.rs b/rust/agama-utils/src/api/network/settings.rs similarity index 90% rename from rust/agama-network/src/settings.rs rename to rust/agama-utils/src/api/network/settings.rs index db9a4f6120..f4ac92f023 100644 --- a/rust/agama-network/src/settings.rs +++ b/rust/agama-utils/src/api/network/settings.rs @@ -20,19 +20,34 @@ //! Representation of the network settings -use super::types::{DeviceState, DeviceType, Status}; -use agama_utils::openapi::schemas; +use super::types::{ConnectionState, DeviceState, DeviceType, Status}; +use crate::openapi::schemas; use cidr::IpInet; use serde::{Deserialize, Serialize}; use std::default::Default; use std::net::IpAddr; +#[derive(Clone, Debug, Default, Serialize, Deserialize, utoipa::ToSchema)] +pub struct NetworkConnectionsCollection(pub Vec); + /// Network settings for installation #[derive(Clone, Debug, Default, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct NetworkSettings { - /// Connections to use in the installation - pub connections: Vec, + pub connections: NetworkConnectionsCollection, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct StateSettings { + #[serde(skip_serializing_if = "Option::is_none")] + pub connectivity: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub wireless_enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub networking_enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub copy_network: Option, } #[derive(Clone, Debug, Default, Serialize, Deserialize, utoipa::ToSchema)] @@ -196,7 +211,7 @@ pub struct IEEE8021XSettings { pub peap_label: bool, } -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize, utoipa::ToSchema)] pub struct NetworkDevice { pub id: String, pub type_: DeviceType, @@ -302,3 +317,14 @@ impl NetworkConnection { } } } + +// FIXME: found a better place for the HTTP types. +// +// TODO: If the client ignores the additional "state" field, this struct +// does not need to be here. +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct NetworkConnectionWithState { + #[serde(flatten)] + pub connection: NetworkConnection, + pub state: ConnectionState, +} diff --git a/rust/agama-utils/src/api/network/system_info.rs b/rust/agama-utils/src/api/network/system_info.rs new file mode 100644 index 0000000000..f7d9d43b97 --- /dev/null +++ b/rust/agama-utils/src/api/network/system_info.rs @@ -0,0 +1,36 @@ +// Copyright (c) [2025] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +//! Representation of the network settings + +use crate::api::network::{AccessPoint, Device, NetworkConnectionsCollection, StateSettings}; +use serde::{Deserialize, Serialize}; +use std::default::Default; + +/// Network settings for installation +#[derive(Clone, Debug, Default, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct SystemInfo { + pub access_points: Vec, // networks or access_points shold be returned + /// Connections to use in the installation + pub connections: NetworkConnectionsCollection, + pub devices: Vec, + pub state: StateSettings, +} diff --git a/rust/agama-utils/src/api/network/types.rs b/rust/agama-utils/src/api/network/types.rs new file mode 100644 index 0000000000..29115dcc1a --- /dev/null +++ b/rust/agama-utils/src/api/network/types.rs @@ -0,0 +1,790 @@ +// Copyright (c) [2024] SUSE LLC +// +// All Rights Reserved. +// +// This program is free software; you can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the Free +// Software Foundation; either version 2 of the License, or (at your option) +// any later version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +// more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, contact SUSE LLC. +// +// To contact SUSE LLC about this file by physical or electronic mail, you may +// find current contact information at www.suse.com. + +use crate::openapi::schemas; +use cidr::{errors::NetworkParseError, IpInet}; +use serde::{Deserialize, Serialize}; +use serde_with::{serde_as, skip_serializing_none, DisplayFromStr}; +use std::{ + collections::HashMap, + fmt, + net::IpAddr, + str::{self, FromStr}, +}; +use thiserror::Error; +use zbus::zvariant::Value; + +/// Access Point +#[serde_as] +#[derive(Default, Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct AccessPoint { + pub device: String, + #[serde_as(as = "DisplayFromStr")] + pub ssid: SSID, + pub hw_address: String, + pub strength: u8, + pub flags: u32, + pub rsn_flags: u32, + pub wpa_flags: u32, +} + +/// Network device +#[serde_as] +#[skip_serializing_none] +#[derive(Default, Debug, Clone, PartialEq, Deserialize, Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct Device { + pub name: String, + #[serde(rename = "type")] + pub type_: DeviceType, + #[serde_as(as = "DisplayFromStr")] + pub mac_address: MacAddress, + pub ip_config: Option, + // Connection.id + pub connection: Option, + pub state: DeviceState, +} + +#[derive(Debug, Default, Clone, PartialEq, Serialize, utoipa::ToSchema)] +pub enum MacAddress { + #[schema(value_type = String, format = "MAC address in EUI-48 format")] + MacAddress(macaddr::MacAddr6), + Preserve, + Permanent, + Random, + Stable, + #[default] + Unset, +} + +impl fmt::Display for MacAddress { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let output = match &self { + Self::MacAddress(mac) => mac.to_string(), + Self::Preserve => "preserve".to_string(), + Self::Permanent => "permanent".to_string(), + Self::Random => "random".to_string(), + Self::Stable => "stable".to_string(), + Self::Unset => "".to_string(), + }; + write!(f, "{}", output) + } +} + +#[derive(Debug, Error)] +#[error("Invalid MAC address: {0}")] +pub struct InvalidMacAddress(String); + +impl FromStr for MacAddress { + type Err = InvalidMacAddress; + + fn from_str(s: &str) -> Result { + match s { + "preserve" => Ok(Self::Preserve), + "permanent" => Ok(Self::Permanent), + "random" => Ok(Self::Random), + "stable" => Ok(Self::Stable), + "" => Ok(Self::Unset), + _ => Ok(Self::MacAddress(match macaddr::MacAddr6::from_str(s) { + Ok(mac) => mac, + Err(e) => return Err(InvalidMacAddress(e.to_string())), + })), + } + } +} + +impl TryFrom<&Option> for MacAddress { + type Error = InvalidMacAddress; + + fn try_from(value: &Option) -> Result { + match &value { + Some(str) => MacAddress::from_str(str), + None => Ok(Self::Unset), + } + } +} + +impl From for zbus::fdo::Error { + fn from(value: InvalidMacAddress) -> Self { + zbus::fdo::Error::Failed(value.to_string()) + } +} + +#[skip_serializing_none] +#[derive(Default, Debug, PartialEq, Clone, Deserialize, Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct IpConfig { + pub method4: Ipv4Method, + pub method6: Ipv6Method, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + #[schema(schema_with = schemas::ip_inet_array)] + pub addresses: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + #[schema(schema_with = schemas::ip_addr_array)] + pub nameservers: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub dns_searchlist: Vec, + pub ignore_auto_dns: bool, + #[schema(schema_with = schemas::ip_addr)] + pub gateway4: Option, + #[schema(schema_with = schemas::ip_addr)] + pub gateway6: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub routes4: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub routes6: Vec, + pub dhcp4_settings: Option, + pub dhcp6_settings: Option, + pub ip6_privacy: Option, + pub dns_priority4: Option, + pub dns_priority6: Option, +} + +#[skip_serializing_none] +#[derive(Debug, Default, PartialEq, Clone, Deserialize, Serialize, utoipa::ToSchema)] +pub struct Dhcp4Settings { + pub send_hostname: Option, + pub hostname: Option, + pub send_release: Option, + pub client_id: DhcpClientId, + pub iaid: DhcpIaid, +} + +#[skip_serializing_none] +#[derive(Debug, Default, PartialEq, Clone, Deserialize, Serialize, utoipa::ToSchema)] +pub struct Dhcp6Settings { + pub send_hostname: Option, + pub hostname: Option, + pub send_release: Option, + pub duid: DhcpDuid, + pub iaid: DhcpIaid, +} +#[derive(Debug, Default, Clone, PartialEq, Deserialize, Serialize, utoipa::ToSchema)] +pub enum DhcpClientId { + Id(String), + Mac, + PermMac, + Ipv6Duid, + Duid, + Stable, + None, + #[default] + Unset, +} + +impl From<&str> for DhcpClientId { + fn from(s: &str) -> Self { + match s { + "mac" => Self::Mac, + "perm-mac" => Self::PermMac, + "ipv6-duid" => Self::Ipv6Duid, + "duid" => Self::Duid, + "stable" => Self::Stable, + "none" => Self::None, + "" => Self::Unset, + _ => Self::Id(s.to_string()), + } + } +} + +impl From> for DhcpClientId { + fn from(value: Option) -> Self { + match &value { + Some(str) => Self::from(str.as_str()), + None => Self::Unset, + } + } +} + +impl fmt::Display for DhcpClientId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let output = match &self { + Self::Id(id) => id.to_string(), + Self::Mac => "mac".to_string(), + Self::PermMac => "perm-mac".to_string(), + Self::Ipv6Duid => "ipv6-duid".to_string(), + Self::Duid => "duid".to_string(), + Self::Stable => "stable".to_string(), + Self::None => "none".to_string(), + Self::Unset => "".to_string(), + }; + write!(f, "{}", output) + } +} + +#[derive(Debug, Default, Clone, PartialEq, Deserialize, Serialize, utoipa::ToSchema)] +pub enum DhcpDuid { + Id(String), + Lease, + Llt, + Ll, + StableLlt, + StableLl, + StableUuid, + #[default] + Unset, +} + +impl From<&str> for DhcpDuid { + fn from(s: &str) -> Self { + match s { + "lease" => Self::Lease, + "llt" => Self::Llt, + "ll" => Self::Ll, + "stable-llt" => Self::StableLlt, + "stable-ll" => Self::StableLl, + "stable-uuid" => Self::StableUuid, + "" => Self::Unset, + _ => Self::Id(s.to_string()), + } + } +} + +impl From> for DhcpDuid { + fn from(value: Option) -> Self { + match &value { + Some(str) => Self::from(str.as_str()), + None => Self::Unset, + } + } +} + +impl fmt::Display for DhcpDuid { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let output = match &self { + Self::Id(id) => id.to_string(), + Self::Lease => "lease".to_string(), + Self::Llt => "llt".to_string(), + Self::Ll => "ll".to_string(), + Self::StableLlt => "stable-llt".to_string(), + Self::StableLl => "stable-ll".to_string(), + Self::StableUuid => "stable-uuid".to_string(), + Self::Unset => "".to_string(), + }; + write!(f, "{}", output) + } +} + +#[derive(Debug, Default, Clone, PartialEq, Deserialize, Serialize, utoipa::ToSchema)] +pub enum DhcpIaid { + Id(String), + Mac, + PermMac, + Ifname, + Stable, + #[default] + Unset, +} + +impl From<&str> for DhcpIaid { + fn from(s: &str) -> Self { + match s { + "mac" => Self::Mac, + "perm-mac" => Self::PermMac, + "ifname" => Self::Ifname, + "stable" => Self::Stable, + "" => Self::Unset, + _ => Self::Id(s.to_string()), + } + } +} + +impl From> for DhcpIaid { + fn from(value: Option) -> Self { + match value { + Some(str) => Self::from(str.as_str()), + None => Self::Unset, + } + } +} + +impl fmt::Display for DhcpIaid { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let output = match &self { + Self::Id(id) => id.to_string(), + Self::Mac => "mac".to_string(), + Self::PermMac => "perm-mac".to_string(), + Self::Ifname => "ifname".to_string(), + Self::Stable => "stable".to_string(), + Self::Unset => "".to_string(), + }; + write!(f, "{}", output) + } +} + +#[derive(Debug, PartialEq, Clone, Deserialize, Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct IpRoute { + #[schema(schema_with = schemas::ip_inet_ref)] + pub destination: IpInet, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(schema_with = schemas::ip_addr)] + pub next_hop: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub metric: Option, +} + +impl From<&IpRoute> for HashMap<&str, Value<'_>> { + fn from(route: &IpRoute) -> Self { + let mut map: HashMap<&str, Value> = HashMap::from([ + ("dest", Value::new(route.destination.address().to_string())), + ( + "prefix", + Value::new(route.destination.network_length() as u32), + ), + ]); + if let Some(next_hop) = route.next_hop { + map.insert("next-hop", Value::new(next_hop.to_string())); + } + if let Some(metric) = route.metric { + map.insert("metric", Value::new(metric)); + } + map + } +} + +#[derive(Debug, Error)] +#[error("Unknown IP configuration method name: {0}")] +pub struct UnknownIpMethod(String); + +#[derive(Debug, Default, Copy, Clone, PartialEq, Deserialize, Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub enum Ipv4Method { + Disabled = 0, + #[default] + Auto = 1, + Manual = 2, + LinkLocal = 3, +} + +impl fmt::Display for Ipv4Method { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let name = match &self { + Ipv4Method::Disabled => "disabled", + Ipv4Method::Auto => "auto", + Ipv4Method::Manual => "manual", + Ipv4Method::LinkLocal => "link-local", + }; + write!(f, "{}", name) + } +} + +impl FromStr for Ipv4Method { + type Err = UnknownIpMethod; + + fn from_str(s: &str) -> Result { + match s { + "disabled" => Ok(Ipv4Method::Disabled), + "auto" => Ok(Ipv4Method::Auto), + "manual" => Ok(Ipv4Method::Manual), + "link-local" => Ok(Ipv4Method::LinkLocal), + _ => Err(UnknownIpMethod(s.to_string())), + } + } +} + +#[derive(Debug, Default, Copy, Clone, PartialEq, Deserialize, Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub enum Ipv6Method { + Disabled = 0, + #[default] + Auto = 1, + Manual = 2, + LinkLocal = 3, + Ignore = 4, + Dhcp = 5, +} + +impl fmt::Display for Ipv6Method { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let name = match &self { + Ipv6Method::Disabled => "disabled", + Ipv6Method::Auto => "auto", + Ipv6Method::Manual => "manual", + Ipv6Method::LinkLocal => "link-local", + Ipv6Method::Ignore => "ignore", + Ipv6Method::Dhcp => "dhcp", + }; + write!(f, "{}", name) + } +} + +impl FromStr for Ipv6Method { + type Err = UnknownIpMethod; + + fn from_str(s: &str) -> Result { + match s { + "disabled" => Ok(Ipv6Method::Disabled), + "auto" => Ok(Ipv6Method::Auto), + "manual" => Ok(Ipv6Method::Manual), + "link-local" => Ok(Ipv6Method::LinkLocal), + "ignore" => Ok(Ipv6Method::Ignore), + "dhcp" => Ok(Ipv6Method::Dhcp), + _ => Err(UnknownIpMethod(s.to_string())), + } + } +} + +impl From for zbus::fdo::Error { + fn from(value: UnknownIpMethod) -> zbus::fdo::Error { + zbus::fdo::Error::Failed(value.to_string()) + } +} +#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct SSID(pub Vec); + +impl SSID { + pub fn to_vec(&self) -> &Vec { + &self.0 + } +} + +impl fmt::Display for SSID { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", str::from_utf8(&self.0).unwrap()) + } +} + +impl FromStr for SSID { + type Err = NetworkParseError; + + fn from_str(s: &str) -> Result { + Ok(SSID(s.as_bytes().into())) + } +} + +impl From for Vec { + fn from(value: SSID) -> Self { + value.0 + } +} + +#[derive(Default, Debug, PartialEq, Copy, Clone, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub enum DeviceType { + Loopback = 0, + #[default] + Ethernet = 1, + Wireless = 2, + Dummy = 3, + Bond = 4, + Vlan = 5, + Bridge = 6, +} + +/// Network device state. +#[derive( + Default, + Serialize, + Deserialize, + Debug, + PartialEq, + Eq, + Clone, + Copy, + strum::Display, + strum::EnumString, + utoipa::ToSchema, +)] +#[strum(serialize_all = "camelCase")] +#[serde(rename_all = "camelCase")] +pub enum DeviceState { + #[default] + /// The device's state is unknown. + Unknown, + /// The device is recognized but not managed by Agama. + Unmanaged, + /// The device is detected but it cannot be used (wireless switched off, missing firmware, etc.). + Unavailable, + /// The device is connecting to the network. + Connecting, + /// The device is successfully connected to the network. + Connected, + /// The device is disconnecting from the network. + Disconnecting, + /// The device is disconnected from the network. + Disconnected, + /// The device failed to connect to a network. + Failed, +} + +#[derive( + Default, + Serialize, + Deserialize, + Debug, + PartialEq, + Eq, + Clone, + Copy, + strum::Display, + strum::EnumString, + utoipa::ToSchema, +)] +#[strum(serialize_all = "camelCase")] +#[serde(rename_all = "camelCase")] +pub enum ConnectionState { + /// The connection is getting activated. + Activating, + /// The connection is activated. + Activated, + /// The connection is getting deactivated. + Deactivating, + #[default] + /// The connection is deactivated. + Deactivated, +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub enum Status { + #[default] + Up, + Down, + Removed, + // Workaound for not modify the connection status + Keep, +} + +impl fmt::Display for Status { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let name = match &self { + Status::Up => "up", + Status::Down => "down", + Status::Keep => "keep", + Status::Removed => "removed", + }; + write!(f, "{}", name) + } +} + +#[derive(Debug, Error, PartialEq)] +#[error("Invalid status: {0}")] +pub struct InvalidStatus(String); + +impl TryFrom<&str> for Status { + type Error = InvalidStatus; + + fn try_from(value: &str) -> Result { + match value { + "up" => Ok(Status::Up), + "down" => Ok(Status::Down), + "keep" => Ok(Status::Keep), + "removed" => Ok(Status::Removed), + _ => Err(InvalidStatus(value.to_string())), + } + } +} + +/// Bond mode +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone, Copy, utoipa::ToSchema)] +pub enum BondMode { + #[serde(rename = "balance-rr")] + RoundRobin = 0, + #[serde(rename = "active-backup")] + ActiveBackup = 1, + #[serde(rename = "balance-xor")] + BalanceXOR = 2, + #[serde(rename = "broadcast")] + Broadcast = 3, + #[serde(rename = "802.3ad")] + LACP = 4, + #[serde(rename = "balance-tlb")] + BalanceTLB = 5, + #[serde(rename = "balance-alb")] + BalanceALB = 6, +} +impl Default for BondMode { + fn default() -> Self { + Self::RoundRobin + } +} + +impl std::fmt::Display for BondMode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + match self { + BondMode::RoundRobin => "balance-rr", + BondMode::ActiveBackup => "active-backup", + BondMode::BalanceXOR => "balance-xor", + BondMode::Broadcast => "broadcast", + BondMode::LACP => "802.3ad", + BondMode::BalanceTLB => "balance-tlb", + BondMode::BalanceALB => "balance-alb", + } + ) + } +} + +#[derive(Debug, Error, PartialEq)] +#[error("Invalid bond mode: {0}")] +pub struct InvalidBondMode(String); + +impl TryFrom<&str> for BondMode { + type Error = InvalidBondMode; + + fn try_from(value: &str) -> Result { + match value { + "balance-rr" => Ok(BondMode::RoundRobin), + "active-backup" => Ok(BondMode::ActiveBackup), + "balance-xor" => Ok(BondMode::BalanceXOR), + "broadcast" => Ok(BondMode::Broadcast), + "802.3ad" => Ok(BondMode::LACP), + "balance-tlb" => Ok(BondMode::BalanceTLB), + "balance-alb" => Ok(BondMode::BalanceALB), + _ => Err(InvalidBondMode(value.to_string())), + } + } +} +impl TryFrom for BondMode { + type Error = InvalidBondMode; + + fn try_from(value: u8) -> Result { + match value { + 0 => Ok(BondMode::RoundRobin), + 1 => Ok(BondMode::ActiveBackup), + 2 => Ok(BondMode::BalanceXOR), + 3 => Ok(BondMode::Broadcast), + 4 => Ok(BondMode::LACP), + 5 => Ok(BondMode::BalanceTLB), + 6 => Ok(BondMode::BalanceALB), + _ => Err(InvalidBondMode(value.to_string())), + } + } +} + +#[derive(Debug, Error, PartialEq)] +#[error("Invalid device type: {0}")] +pub struct InvalidDeviceType(u8); + +impl TryFrom for DeviceType { + type Error = InvalidDeviceType; + + fn try_from(value: u8) -> Result { + match value { + 0 => Ok(DeviceType::Loopback), + 1 => Ok(DeviceType::Ethernet), + 2 => Ok(DeviceType::Wireless), + 3 => Ok(DeviceType::Dummy), + 4 => Ok(DeviceType::Bond), + 5 => Ok(DeviceType::Vlan), + 6 => Ok(DeviceType::Bridge), + _ => Err(InvalidDeviceType(value)), + } + } +} + +impl From for zbus::fdo::Error { + fn from(value: InvalidBondMode) -> zbus::fdo::Error { + zbus::fdo::Error::Failed(format!("Network error: {value}")) + } +} + +impl From for zbus::fdo::Error { + fn from(value: InvalidDeviceType) -> zbus::fdo::Error { + zbus::fdo::Error::Failed(format!("Network error: {value}")) + } +} +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_display_ssid() { + let ssid = SSID(vec![97, 103, 97, 109, 97]); + assert_eq!(format!("{}", ssid), "agama"); + } + + #[test] + fn test_ssid_to_vec() { + let vec = vec![97, 103, 97, 109, 97]; + let ssid = SSID(vec.clone()); + assert_eq!(ssid.to_vec(), &vec); + } + + #[test] + fn test_device_type_from_u8() { + let dtype = DeviceType::try_from(0); + assert_eq!(dtype, Ok(DeviceType::Loopback)); + + let dtype = DeviceType::try_from(128); + assert_eq!(dtype, Err(InvalidDeviceType(128))); + } + + #[test] + fn test_display_bond_mode() { + let mode = BondMode::try_from(1).unwrap(); + assert_eq!(format!("{}", mode), "active-backup"); + } + + #[test] + fn test_macaddress() { + let mut val: Option = None; + assert!(matches!( + MacAddress::try_from(&val).unwrap(), + MacAddress::Unset + )); + + val = Some(String::from("")); + assert!(matches!( + MacAddress::try_from(&val).unwrap(), + MacAddress::Unset + )); + + val = Some(String::from("preserve")); + assert!(matches!( + MacAddress::try_from(&val).unwrap(), + MacAddress::Preserve + )); + + val = Some(String::from("permanent")); + assert!(matches!( + MacAddress::try_from(&val).unwrap(), + MacAddress::Permanent + )); + + val = Some(String::from("random")); + assert!(matches!( + MacAddress::try_from(&val).unwrap(), + MacAddress::Random + )); + + val = Some(String::from("stable")); + assert!(matches!( + MacAddress::try_from(&val).unwrap(), + MacAddress::Stable + )); + + val = Some(String::from("This is not a MACAddr")); + assert!(matches!( + MacAddress::try_from(&val), + Err(InvalidMacAddress(_)) + )); + + val = Some(String::from("de:ad:be:ef:2b:ad")); + assert_eq!( + MacAddress::try_from(&val).unwrap().to_string(), + String::from("de:ad:be:ef:2b:ad").to_uppercase() + ); + } +} diff --git a/rust/agama-utils/src/api/proposal.rs b/rust/agama-utils/src/api/proposal.rs index 4b184c0913..7a66f5d050 100644 --- a/rust/agama-utils/src/api/proposal.rs +++ b/rust/agama-utils/src/api/proposal.rs @@ -18,7 +18,7 @@ // To contact SUSE LLC about this file by physical or electronic mail, you may // find current contact information at www.suse.com. -use crate::api::l10n; +use crate::api::{l10n, network}; use serde::Serialize; use serde_json::Value; @@ -27,6 +27,7 @@ use serde_json::Value; pub struct Proposal { #[serde(skip_serializing_if = "Option::is_none")] pub l10n: Option, + pub network: network::Proposal, #[serde(skip_serializing_if = "Option::is_none")] pub storage: Option, } diff --git a/rust/agama-utils/src/api/system_info.rs b/rust/agama-utils/src/api/system_info.rs index 0ae7e5b8b2..7bc787077e 100644 --- a/rust/agama-utils/src/api/system_info.rs +++ b/rust/agama-utils/src/api/system_info.rs @@ -19,6 +19,7 @@ // find current contact information at www.suse.com. use crate::api::l10n; +use crate::api::network; use serde::Serialize; use serde_json::Value; @@ -29,4 +30,5 @@ pub struct SystemInfo { #[serde(default)] #[serde(skip_serializing_if = "Option::is_none")] pub storage: Option, + pub network: network::SystemInfo, } diff --git a/rust/xtask/src/main.rs b/rust/xtask/src/main.rs index 92cf1d7a1f..0e31d8df2b 100644 --- a/rust/xtask/src/main.rs +++ b/rust/xtask/src/main.rs @@ -6,8 +6,8 @@ mod tasks { use agama_cli::Cli; use agama_server::web::docs::{ ApiDocBuilder, ConfigApiDocBuilder, HostnameApiDocBuilder, ManagerApiDocBuilder, - MiscApiDocBuilder, NetworkApiDocBuilder, ProfileApiDocBuilder, ScriptsApiDocBuilder, - SoftwareApiDocBuilder, StorageApiDocBuilder, UsersApiDocBuilder, + MiscApiDocBuilder, ProfileApiDocBuilder, ScriptsApiDocBuilder, SoftwareApiDocBuilder, + StorageApiDocBuilder, UsersApiDocBuilder, }; use clap::CommandFactory; use clap_complete::aot; @@ -68,7 +68,6 @@ mod tasks { write_openapi(HostnameApiDocBuilder {}, out_dir.join("hostname.json"))?; write_openapi(ManagerApiDocBuilder {}, out_dir.join("manager.json"))?; write_openapi(MiscApiDocBuilder {}, out_dir.join("misc.json"))?; - write_openapi(NetworkApiDocBuilder {}, out_dir.join("network.json"))?; write_openapi(ProfileApiDocBuilder {}, out_dir.join("profile.json"))?; write_openapi(ScriptsApiDocBuilder {}, out_dir.join("scripts.json"))?; write_openapi(SoftwareApiDocBuilder {}, out_dir.join("software.json"))?; From 3689f020f04862d4f651535d2ad319b8d20d05e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Knut=20Alejandro=20Anderssen=20Gonz=C3=A1lez?= Date: Tue, 11 Nov 2025 09:24:59 +0000 Subject: [PATCH 09/11] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Imobach González Sosa --- rust/agama-lib/share/profile.schema.json | 4 ++-- rust/agama-network/src/action.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/rust/agama-lib/share/profile.schema.json b/rust/agama-lib/share/profile.schema.json index a40f9da479..8c5df5597e 100644 --- a/rust/agama-lib/share/profile.schema.json +++ b/rust/agama-lib/share/profile.schema.json @@ -337,11 +337,11 @@ "additionalProperties": false, "properties": { "state": { - "title": "Network general state settings", + "title": "Network general settings", "type": "object", "properties": { "connectivity": { - "title": "Determines whether the user is able to access the Internet", + "title": "Whether the user is able to access the Internet", "type": "boolean", "readOnly": true }, diff --git a/rust/agama-network/src/action.rs b/rust/agama-network/src/action.rs index 791bcb3622..b465528f8b 100644 --- a/rust/agama-network/src/action.rs +++ b/rust/agama-network/src/action.rs @@ -47,7 +47,7 @@ pub enum Action { GetConfig(Responder), /// Gets the internal state of the network configuration proposal GetProposal(Responder), - /// Updates th internal state of the network configuration + /// Updates the internal state of the network configuration UpdateConfig(Box, Responder>), /// Gets the current network configuration containing connections, devices, access_points and /// also the general state From 1b02ac938f171311ffdc4f1ed0333865b6fa201d Mon Sep 17 00:00:00 2001 From: Knut Anderssen Date: Tue, 11 Nov 2025 13:14:11 +0000 Subject: [PATCH 10/11] More suggestions from code review --- rust/agama-manager/src/service.rs | 7 +-- rust/agama-manager/src/start.rs | 8 +--- rust/agama-network/src/action.rs | 6 +-- rust/agama-network/src/lib.rs | 2 + rust/agama-network/src/model.rs | 77 ++++++------------------------- rust/agama-network/src/start.rs | 8 ++++ rust/agama-network/src/system.rs | 17 +++++-- 7 files changed, 46 insertions(+), 79 deletions(-) create mode 100644 rust/agama-network/src/start.rs diff --git a/rust/agama-manager/src/service.rs b/rust/agama-manager/src/service.rs index 62873e87eb..eaacf002f3 100644 --- a/rust/agama-manager/src/service.rs +++ b/rust/agama-manager/src/service.rs @@ -29,7 +29,7 @@ use agama_utils::{ }; use async_trait::async_trait; use merge_struct::merge; -use network::{NetworkSystemClient, NetworkSystemError}; +use network::NetworkSystemClient; use serde_json::Value; use tokio::sync::broadcast; @@ -52,7 +52,7 @@ pub enum Error { #[error(transparent)] Progress(#[from] progress::service::Error), #[error(transparent)] - NetworkSystemError(#[from] NetworkSystemError), + Network(#[from] network::NetworkSystemError), } pub struct Service { @@ -153,7 +153,8 @@ impl MessageHandler for Service { async fn handle(&mut self, _message: message::GetSystem) -> Result { let l10n = self.l10n.call(l10n::message::GetSystem).await?; let storage = self.storage.call(storage::message::GetSystem).await?; - let network = self.network.get_system_config().await?; + let network = self.network.get_system().await?; + Ok(SystemInfo { l10n, network, diff --git a/rust/agama-manager/src/start.rs b/rust/agama-manager/src/start.rs index 8a3f034e7f..60537f10da 100644 --- a/rust/agama-manager/src/start.rs +++ b/rust/agama-manager/src/start.rs @@ -36,7 +36,7 @@ pub enum Error { #[error(transparent)] Storage(#[from] storage::start::Error), #[error(transparent)] - NetworkSystem(#[from] network::NetworkSystemError), + Network(#[from] network::start::Error), } /// Starts the manager service. @@ -53,11 +53,7 @@ pub async fn start( let progress = progress::start(events.clone()).await?; let l10n = l10n::start(issues.clone(), events.clone()).await?; let storage = storage::start(progress.clone(), issues.clone(), events.clone(), dbus).await?; - let network_adapter = network::NetworkManagerAdapter::from_system() - .await - .expect("Could not connect to NetworkManager"); - let network = network::NetworkSystem::new(network_adapter).start().await?; - + let network = network::start().await?; let service = Service::new(l10n, network, storage, issues, progress, questions, events); let handler = actor::spawn(service); Ok(handler) diff --git a/rust/agama-network/src/action.rs b/rust/agama-network/src/action.rs index b465528f8b..4d4462f460 100644 --- a/rust/agama-network/src/action.rs +++ b/rust/agama-network/src/action.rs @@ -47,11 +47,11 @@ pub enum Action { GetConfig(Responder), /// Gets the internal state of the network configuration proposal GetProposal(Responder), - /// Updates the internal state of the network configuration + /// Updates the internal state of the network configuration applying the changes to the system UpdateConfig(Box, Responder>), - /// Gets the current network configuration containing connections, devices, access_points and + /// Gets the current network system configuration containing connections, devices, access_points and /// also the general state - GetSystemConfig(Responder), + GetSystem(Responder), /// Gets a connection GetConnections(Responder>), /// Gets a controller connection diff --git a/rust/agama-network/src/lib.rs b/rust/agama-network/src/lib.rs index 0cf9b47e59..735ca6ca96 100644 --- a/rust/agama-network/src/lib.rs +++ b/rust/agama-network/src/lib.rs @@ -27,6 +27,8 @@ pub mod adapter; pub mod error; pub mod model; mod nm; +pub mod start; +pub use start::start; mod system; pub mod types; diff --git a/rust/agama-network/src/model.rs b/rust/agama-network/src/model.rs index 0e9372a04f..5aeb3b9d29 100644 --- a/rust/agama-network/src/model.rs +++ b/rust/agama-network/src/model.rs @@ -161,7 +161,7 @@ impl NetworkState { pub fn update_state(&mut self, config: Config) -> Result<(), NetworkStateError> { if let Some(connections) = config.connections { let mut collection: ConnectionCollection = connections.clone().try_into()?; - for conn in collection.0.iter_mut() { + for conn in collection.iter_mut() { if let Some(current_conn) = self.get_connection(conn.id.as_str()) { // Replaced the UUID with a real one conn.uuid = current_conn.uuid; @@ -292,20 +292,6 @@ impl NetworkState { )), } } - - pub fn ports_for(&self, uuid: Uuid) -> Vec { - self.connections - .iter() - .filter(|c| c.controller == Some(uuid)) - .map(|c| { - if let Some(interface) = c.interface.to_owned() { - interface - } else { - c.clone().id - } - }) - .collect() - } } #[cfg(test)] @@ -1330,18 +1316,19 @@ pub struct ConnectionCollection(pub Vec); impl ConnectionCollection { pub fn ports_for(&self, uuid: Uuid) -> Vec { - self.0 - .iter() + self.iter() .filter(|c| c.controller == Some(uuid)) - .map(|c| { - if let Some(interface) = c.interface.to_owned() { - interface - } else { - c.clone().id - } - }) + .map(|c| c.interface.as_ref().unwrap_or(&c.id).clone()) .collect() } + + fn iter(&self) -> impl Iterator { + self.0.iter() + } + + fn iter_mut(&mut self) -> impl Iterator { + self.0.iter_mut() + } } impl TryFrom for NetworkConnectionsCollection { @@ -1349,7 +1336,6 @@ impl TryFrom for NetworkConnectionsCollection { fn try_from(collection: ConnectionCollection) -> Result { let network_connections = collection - .0 .iter() .filter(|c| c.controller.is_none()) .map(|c| { @@ -1393,12 +1379,11 @@ impl TryFrom for ConnectionCollection { } for (port, uuid) in controller_ports { - let default = Connection::new(port.clone(), DeviceType::Ethernet); let mut conn = conns .iter() - .find(|&c| c.id == port || c.interface == Some(port.to_string())) - .unwrap_or(&default) - .to_owned(); + .find(|c| c.id == port || c.interface.as_ref() == Some(&port)) + .cloned() + .unwrap_or_else(|| Connection::new(port, DeviceType::Ethernet)); conn.controller = Some(uuid); conns.push(conn); } @@ -1407,30 +1392,6 @@ impl TryFrom for ConnectionCollection { } } -impl TryFrom for NetworkConnectionsCollection { - type Error = NetworkStateError; - - fn try_from(state: NetworkState) -> Result { - let network_connections = state - .connections - .iter() - .filter(|c| c.controller.is_none()) - .map(|c| { - let mut conn = NetworkConnection::try_from(c.clone()).unwrap(); - if let Some(ref mut bond) = conn.bond { - bond.ports = state.ports_for(c.uuid); - } - if let Some(ref mut bridge) = conn.bridge { - bridge.ports = state.ports_for(c.uuid); - }; - conn - }) - .collect(); - - Ok(NetworkConnectionsCollection(network_connections)) - } -} - impl TryFrom for StateSettings { type Error = NetworkStateError; @@ -1444,16 +1405,6 @@ impl TryFrom for StateSettings { } } -impl TryFrom for NetworkSettings { - type Error = NetworkStateError; - - fn try_from(state: NetworkState) -> Result { - let connections: NetworkConnectionsCollection = state.try_into()?; - - Ok(NetworkSettings { connections }) - } -} - impl TryFrom for Config { type Error = NetworkStateError; diff --git a/rust/agama-network/src/start.rs b/rust/agama-network/src/start.rs new file mode 100644 index 0000000000..5f27c7f8b2 --- /dev/null +++ b/rust/agama-network/src/start.rs @@ -0,0 +1,8 @@ +pub use crate::error::Error; +use crate::{NetworkManagerAdapter, NetworkSystem, NetworkSystemClient}; + +pub async fn start() -> Result { + let system = NetworkSystem::::for_network_manager().await; + + Ok(system.start().await?) +} diff --git a/rust/agama-network/src/system.rs b/rust/agama-network/src/system.rs index 69bf54c5b4..f0cb6dfddd 100644 --- a/rust/agama-network/src/system.rs +++ b/rust/agama-network/src/system.rs @@ -23,7 +23,7 @@ use crate::{ error::NetworkStateError, model::{Connection, GeneralState, NetworkChange, NetworkState, StateConfig}, types::{AccessPoint, Config, Device, DeviceType, Proposal, SystemInfo}, - Adapter, NetworkAdapterError, + Adapter, NetworkAdapterError, NetworkManagerAdapter, }; use std::error::Error; use tokio::sync::{ @@ -85,6 +85,15 @@ impl NetworkSystem { Self { adapter } } + /// Returns a new instance of the network configuration system using the [NetworkManagerAdapter] for the system. + pub async fn for_network_manager() -> NetworkSystem> { + let adapter = NetworkManagerAdapter::from_system() + .await + .expect("Could not connect to NetworkManager"); + + NetworkSystem::new(adapter) + } + /// Starts the network configuration service and returns a client for communication purposes. /// /// This function starts the server (using [NetworkSystemServer]) on a separate @@ -181,9 +190,9 @@ impl NetworkSystemClient { Ok(result?) } - pub async fn get_system_config(&self) -> Result { + pub async fn get_system(&self) -> Result { let (tx, rx) = oneshot::channel(); - self.actions.send(Action::GetSystemConfig(tx))?; + self.actions.send(Action::GetSystem(tx))?; Ok(rx.await?) } @@ -333,7 +342,7 @@ impl NetworkSystemServer { let conn = self.state.get_connection_by_uuid(uuid); tx.send(conn.cloned()).unwrap(); } - Action::GetSystemConfig(tx) => { + Action::GetSystem(tx) => { let result = self.read().await?.try_into()?; tx.send(result).unwrap(); } From f5f25e78d932afa55534c2cd51a33f5968b7756e Mon Sep 17 00:00:00 2001 From: Knut Anderssen Date: Tue, 11 Nov 2025 14:48:17 +0000 Subject: [PATCH 11/11] Added some doc --- rust/agama-network/src/model.rs | 10 +++++++++- rust/agama-network/src/system.rs | 5 +++++ rust/agama-utils/src/api/network/config.rs | 1 + rust/agama-utils/src/api/network/proposal.rs | 1 + rust/agama-utils/src/api/network/settings.rs | 3 +++ 5 files changed, 19 insertions(+), 1 deletion(-) diff --git a/rust/agama-network/src/model.rs b/rust/agama-network/src/model.rs index 5aeb3b9d29..061a814c8a 100644 --- a/rust/agama-network/src/model.rs +++ b/rust/agama-network/src/model.rs @@ -66,7 +66,8 @@ pub struct NetworkState { } impl NetworkState { - /// Returns a NetworkState struct with the given devices and connections. + /// Returns a NetworkState struct with the given general_state, access_points, devices + /// and connections. /// /// * `general_state`: General network configuration /// * `access_points`: Access points to include in the state. @@ -138,6 +139,7 @@ impl NetworkState { self.devices.iter_mut().find(|c| c.name == name) } + /// Returns the controller's connection for the givne connection Uuid. pub fn get_controlled_by(&mut self, uuid: Uuid) -> Vec<&Connection> { let uuid = Some(uuid); self.connections @@ -158,6 +160,12 @@ impl NetworkState { Ok(()) } + /// Updates the current [NetworkState] with the configuration provided. + /// + /// The config could contain a [NetworkConnectionsCollection] to be updated, in case of + /// provided it will iterate over the connections adding or updating them. + /// + /// If the general state is provided it will sets the options given. pub fn update_state(&mut self, config: Config) -> Result<(), NetworkStateError> { if let Some(connections) = config.connections { let mut collection: ConnectionCollection = connections.clone().try_into()?; diff --git a/rust/agama-network/src/system.rs b/rust/agama-network/src/system.rs index f0cb6dfddd..a987457be6 100644 --- a/rust/agama-network/src/system.rs +++ b/rust/agama-network/src/system.rs @@ -170,18 +170,22 @@ impl NetworkSystemClient { self.actions.send(Action::GetConnections(tx))?; Ok(rx.await?) } + + /// Returns the cofiguration from the current network state as a [Config]. pub async fn get_config(&self) -> Result { let (tx, rx) = oneshot::channel(); self.actions.send(Action::GetConfig(tx))?; Ok(rx.await?) } + /// Returns the cofiguration from the current network state as a [Proposal]. pub async fn get_proposal(&self) -> Result { let (tx, rx) = oneshot::channel(); self.actions.send(Action::GetProposal(tx))?; Ok(rx.await?) } + /// Updates the current network state based on the configuration given. pub async fn update_config(&self, config: Config) -> Result<(), NetworkSystemError> { let (tx, rx) = oneshot::channel(); self.actions @@ -190,6 +194,7 @@ impl NetworkSystemClient { Ok(result?) } + /// Reads the current system network configuration returning it directly pub async fn get_system(&self) -> Result { let (tx, rx) = oneshot::channel(); self.actions.send(Action::GetSystem(tx))?; diff --git a/rust/agama-utils/src/api/network/config.rs b/rust/agama-utils/src/api/network/config.rs index 51b7848513..f7f9d1d791 100644 --- a/rust/agama-utils/src/api/network/config.rs +++ b/rust/agama-utils/src/api/network/config.rs @@ -30,5 +30,6 @@ use std::default::Default; pub struct Config { /// Connections to use in the installation pub connections: Option, + /// Network general settings pub state: Option, } diff --git a/rust/agama-utils/src/api/network/proposal.rs b/rust/agama-utils/src/api/network/proposal.rs index 39bbe548a6..75d93c25f3 100644 --- a/rust/agama-utils/src/api/network/proposal.rs +++ b/rust/agama-utils/src/api/network/proposal.rs @@ -30,5 +30,6 @@ use std::default::Default; pub struct Proposal { /// Connections to use in the installation pub connections: NetworkConnectionsCollection, + /// General network settings pub state: StateSettings, } diff --git a/rust/agama-utils/src/api/network/settings.rs b/rust/agama-utils/src/api/network/settings.rs index f4ac92f023..be354de3d9 100644 --- a/rust/agama-utils/src/api/network/settings.rs +++ b/rust/agama-utils/src/api/network/settings.rs @@ -37,6 +37,9 @@ pub struct NetworkSettings { pub connections: NetworkConnectionsCollection, } +/// Network general settings for the installation like enabling wireless, networking and +/// allowing to enable or disable the copy of the network settings to the +/// target system #[derive(Clone, Debug, Default, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct StateSettings {