Skip to content

Commit 8fd0ff7

Browse files
committedMar 14, 2024·
Merge branch 'master' into storage-ui
2 parents 380dbb6 + 704b693 commit 8fd0ff7

32 files changed

+1019
-147
lines changed
 

‎products.d/tumbleweed.yaml

+5-3
Original file line numberDiff line numberDiff line change
@@ -154,10 +154,12 @@ storage:
154154
- mount_path: "swap"
155155
filesystem: swap
156156
size:
157-
auto: false
158-
min: 1 GiB
159-
max: 2 GiB
157+
auto: true
160158
outline:
159+
auto_size:
160+
base_min: 1 GiB
161+
base_max: 2 GiB
162+
adjust_by_ram: true
161163
required: false
162164
filesystems:
163165
- swap

‎rust/Cargo.lock

+390-67
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎rust/agama-cli/Cargo.toml

+2
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ nix = { version = "0.27.1", features = ["user"] }
2525
zbus = { version = "3", default-features = false, features = ["tokio"] }
2626
tokio = { version = "1.33.0", features = ["macros", "rt-multi-thread"] }
2727
async-trait = "0.1.77"
28+
reqwest = { version = "0.11", features = ["json"] }
29+
home = "0.5.9"
2830

2931
[[bin]]
3032
name = "agama"

‎rust/agama-cli/src/auth.rs

+243
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
use clap::{arg, Args, Subcommand};
2+
use home;
3+
use reqwest::header::{HeaderMap, HeaderValue, CONTENT_TYPE};
4+
use std::fs;
5+
use std::fs::File;
6+
use std::io;
7+
use std::io::{BufRead, BufReader};
8+
use std::os::unix::fs::PermissionsExt;
9+
use std::path::{Path, PathBuf};
10+
11+
const DEFAULT_JWT_FILE: &str = ".agama/agama-jwt";
12+
const DEFAULT_AUTH_URL: &str = "http://localhost:3000/api/authenticate";
13+
const DEFAULT_FILE_MODE: u32 = 0o600;
14+
15+
#[derive(Subcommand, Debug)]
16+
pub enum AuthCommands {
17+
/// Login with defined server. Result is JWT stored locally and made available to
18+
/// further use. Password can be provided by commandline option, from a file or it fallbacks
19+
/// into an interactive prompt.
20+
Login(LoginArgs),
21+
/// Release currently stored JWT
22+
Logout,
23+
/// Prints currently stored JWT to stdout
24+
Show,
25+
}
26+
27+
/// Main entry point called from agama CLI main loop
28+
pub async fn run(subcommand: AuthCommands) -> anyhow::Result<()> {
29+
match subcommand {
30+
AuthCommands::Login(options) => login(LoginArgs::proceed(options).password()?).await,
31+
AuthCommands::Logout => logout(),
32+
AuthCommands::Show => show(),
33+
}
34+
}
35+
36+
/// Reads stored token and returns it
37+
fn jwt() -> anyhow::Result<String> {
38+
if let Some(file) = jwt_file() {
39+
if let Ok(token) = read_line_from_file(&file.as_path()) {
40+
return Ok(token);
41+
}
42+
}
43+
44+
Err(anyhow::anyhow!("Authentication token not available"))
45+
}
46+
47+
/// Stores user provided configuration for login command
48+
#[derive(Args, Debug)]
49+
pub struct LoginArgs {
50+
#[arg(long, short = 'p')]
51+
password: Option<String>,
52+
#[arg(long, short = 'f')]
53+
file: Option<PathBuf>,
54+
}
55+
56+
impl LoginArgs {
57+
/// Transforms user provided options into internal representation
58+
/// See Credentials trait
59+
fn proceed(options: LoginArgs) -> Box<dyn Credentials> {
60+
if let Some(password) = options.password {
61+
Box::new(KnownCredentials { password })
62+
} else if let Some(path) = options.file {
63+
Box::new(FileCredentials { path })
64+
} else {
65+
Box::new(MissingCredentials {})
66+
}
67+
}
68+
}
69+
70+
/// Placeholder for no configuration provided by user
71+
struct MissingCredentials;
72+
73+
/// Stores whatever is needed for reading credentials from a file
74+
struct FileCredentials {
75+
path: PathBuf,
76+
}
77+
78+
/// Stores credentials as provided by the user directly
79+
struct KnownCredentials {
80+
password: String,
81+
}
82+
83+
/// Transforms credentials from user's input into format used internaly
84+
trait Credentials {
85+
fn password(&self) -> io::Result<String>;
86+
}
87+
88+
impl Credentials for KnownCredentials {
89+
fn password(&self) -> io::Result<String> {
90+
Ok(self.password.clone())
91+
}
92+
}
93+
94+
impl Credentials for FileCredentials {
95+
fn password(&self) -> io::Result<String> {
96+
read_line_from_file(&self.path.as_path())
97+
}
98+
}
99+
100+
impl Credentials for MissingCredentials {
101+
fn password(&self) -> io::Result<String> {
102+
let password = read_credential("Password".to_string())?;
103+
104+
Ok(password)
105+
}
106+
}
107+
108+
/// Path to file where JWT is stored
109+
fn jwt_file() -> Option<PathBuf> {
110+
Some(home::home_dir()?.join(DEFAULT_JWT_FILE))
111+
}
112+
113+
/// Reads first line from given file
114+
fn read_line_from_file(path: &Path) -> io::Result<String> {
115+
if !path.exists() {
116+
return Err(io::Error::new(
117+
io::ErrorKind::Other,
118+
"Cannot find the file containing the credentials.",
119+
));
120+
}
121+
122+
if let Ok(file) = File::open(&path) {
123+
// cares only of first line, take everything. No comments
124+
// or something like that supported
125+
let raw = BufReader::new(file).lines().next();
126+
127+
if let Some(line) = raw {
128+
return line;
129+
}
130+
}
131+
132+
Err(io::Error::new(
133+
io::ErrorKind::Other,
134+
"Failed to open the file",
135+
))
136+
}
137+
138+
/// Asks user to provide a line of input. Displays a prompt.
139+
fn read_credential(caption: String) -> io::Result<String> {
140+
let mut cred = String::new();
141+
142+
println!("{}: ", caption);
143+
144+
io::stdin().read_line(&mut cred)?;
145+
if cred.pop().is_none() || cred.is_empty() {
146+
return Err(io::Error::new(
147+
io::ErrorKind::Other,
148+
format!("Failed to read {}", caption),
149+
));
150+
}
151+
152+
Ok(cred)
153+
}
154+
155+
/// Sets the archive owner to root:root. Also sets the file permissions to read/write for the
156+
/// owner only.
157+
fn set_file_permissions(file: &Path) -> io::Result<()> {
158+
let attr = fs::metadata(file)?;
159+
let mut permissions = attr.permissions();
160+
161+
// set the file file permissions to -rw-------
162+
permissions.set_mode(DEFAULT_FILE_MODE);
163+
fs::set_permissions(file, permissions)?;
164+
165+
Ok(())
166+
}
167+
168+
/// Necessary http request header for authenticate
169+
fn authenticate_headers() -> HeaderMap {
170+
let mut headers = HeaderMap::new();
171+
172+
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
173+
174+
headers
175+
}
176+
177+
/// Query web server for JWT
178+
async fn get_jwt(url: String, password: String) -> anyhow::Result<String> {
179+
let client = reqwest::Client::new();
180+
let response = client
181+
.post(url)
182+
.headers(authenticate_headers())
183+
.body(format!("{{\"password\": \"{}\"}}", password))
184+
.send()
185+
.await?;
186+
let body = response
187+
.json::<std::collections::HashMap<String, String>>()
188+
.await?;
189+
let value = body.get(&"token".to_string());
190+
191+
if let Some(token) = value {
192+
return Ok(token.clone());
193+
}
194+
195+
Err(anyhow::anyhow!("Failed to get authentication token"))
196+
}
197+
198+
/// Logs into the installation web server and stores JWT for later use.
199+
async fn login(password: String) -> anyhow::Result<()> {
200+
// 1) ask web server for JWT
201+
let res = get_jwt(DEFAULT_AUTH_URL.to_string(), password).await?;
202+
203+
// 2) if successful store the JWT for later use
204+
if let Some(path) = jwt_file() {
205+
if let Some(dir) = path.parent() {
206+
fs::create_dir_all(dir)?;
207+
} else {
208+
return Err(anyhow::anyhow!("Cannot store the authentication token"));
209+
}
210+
211+
fs::write(path.as_path(), res)?;
212+
set_file_permissions(path.as_path())?;
213+
}
214+
215+
Ok(())
216+
}
217+
218+
/// Releases JWT
219+
fn logout() -> anyhow::Result<()> {
220+
let path = jwt_file();
221+
222+
if !&path.clone().is_some_and(|p| p.exists()) {
223+
// mask if the file with the JWT doesn't exist (most probably no login before logout)
224+
return Ok(());
225+
}
226+
227+
// panicking is right thing to do if expect fails, becase it was already checked twice that
228+
// the path exists
229+
let file = path.expect("Cannot locate stored JWT");
230+
231+
Ok(fs::remove_file(file)?)
232+
}
233+
234+
/// Shows stored JWT on stdout
235+
fn show() -> anyhow::Result<()> {
236+
// we do not care if jwt() fails or not. If there is something to print, show it otherwise
237+
// stay silent
238+
if let Ok(token) = jwt() {
239+
println!("{}", token);
240+
}
241+
242+
Ok(())
243+
}

‎rust/agama-cli/src/commands.rs

+4
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use crate::auth::AuthCommands;
12
use crate::config::ConfigCommands;
23
use crate::logs::LogsCommands;
34
use crate::profile::ProfileCommands;
@@ -35,4 +36,7 @@ pub enum Commands {
3536
/// Collects logs
3637
#[command(subcommand)]
3738
Logs(LogsCommands),
39+
/// Request an action on the web server like Login / Logout
40+
#[command(subcommand)]
41+
Auth(AuthCommands),
3842
}

‎rust/agama-cli/src/main.rs

+3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use clap::Parser;
22

3+
mod auth;
34
mod commands;
45
mod config;
56
mod error;
@@ -13,6 +14,7 @@ use crate::error::CliError;
1314
use agama_lib::error::ServiceError;
1415
use agama_lib::manager::ManagerClient;
1516
use agama_lib::progress::ProgressMonitor;
17+
use auth::run as run_auth_cmd;
1618
use commands::Commands;
1719
use config::run as run_config_cmd;
1820
use logs::run as run_logs_cmd;
@@ -135,6 +137,7 @@ async fn run_command(cli: Cli) -> anyhow::Result<()> {
135137
}
136138
Commands::Questions(subcommand) => run_questions_cmd(subcommand).await,
137139
Commands::Logs(subcommand) => run_logs_cmd(subcommand).await,
140+
Commands::Auth(subcommand) => run_auth_cmd(subcommand).await,
138141
_ => unimplemented!(),
139142
}
140143
}

‎rust/agama-server/Cargo.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[package]
2-
name = "agama-dbus-server"
2+
name = "agama-server"
33
version = "0.1.0"
44
edition = "2021"
55
rust-version.workspace = true

‎rust/agama-server/src/agama-dbus-server.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use agama_dbus_server::{
1+
use agama_server::{
22
l10n::{self, helpers},
33
network, questions,
44
};

‎rust/agama-server/src/agama-web-server.rs

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
use std::process::{ExitCode, Termination};
22

3-
use agama_dbus_server::{
3+
use agama_lib::connection_to;
4+
use agama_server::{
45
l10n::helpers,
56
web::{self, run_monitor},
67
};
7-
use agama_lib::connection_to;
88
use clap::{Args, Parser, Subcommand};
99
use tokio::sync::broadcast::channel;
1010
use tracing_subscriber::prelude::*;

‎rust/agama-server/tests/l10n.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
pub mod common;
22

3-
use agama_dbus_server::l10n::web::l10n_service;
3+
use agama_server::l10n::web::l10n_service;
44
use axum::{
55
body::Body,
66
http::{Request, StatusCode},

‎rust/agama-server/tests/network.rs

+5-5
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
11
pub mod common;
22

33
use self::common::{async_retry, DBusServer};
4-
use agama_dbus_server::network::{
5-
self,
6-
model::{self, Ipv4Method, Ipv6Method},
7-
Adapter, NetworkAdapterError, NetworkService, NetworkState,
8-
};
94
use agama_lib::network::{
105
settings::{self},
116
types::DeviceType,
127
NetworkClient,
138
};
9+
use agama_server::network::{
10+
self,
11+
model::{self, Ipv4Method, Ipv6Method},
12+
Adapter, NetworkAdapterError, NetworkService, NetworkState,
13+
};
1414
use async_trait::async_trait;
1515
use cidr::IpInet;
1616
use std::error::Error;

‎rust/agama-server/tests/service.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
pub mod common;
22

3-
use agama_dbus_server::{
3+
use agama_server::{
44
service,
55
web::{generate_token, MainServiceBuilder, ServiceConfig},
66
};

‎rust/package/agama.changes

+7
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
-------------------------------------------------------------------
2+
Thu Mar 7 10:52:58 UTC 2024 - Michal Filka <mfilka@suse.com>
3+
4+
- CLI: added auth command with login / logout / show subcommands
5+
for handling authentication token management with new agama web
6+
server
7+
18
-------------------------------------------------------------------
29
Tue Feb 27 15:55:28 UTC 2024 - Imobach Gonzalez Sosa <igonzalezsosa@suse.com>
310

‎service/lib/agama/dbus/storage/volume_conversion/to_dbus.rb

+1
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ def outline_conversion(target)
7070
"Required" => outline.required?,
7171
"FsTypes" => outline.filesystems.map(&:to_human_string),
7272
"SupportAutoSize" => outline.adaptive_sizes?,
73+
"AdjustByRam" => outline.adjust_by_ram?,
7374
"SnapshotsConfigurable" => outline.snapshots_configurable?,
7475
"SnapshotsAffectSizes" => outline.snapshots_affect_sizes?,
7576
"SizeRelevantVolumes" => outline.size_relevant_volumes

‎service/test/agama/dbus/storage/proposal_settings_conversion/to_dbus_test.rb

+1
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@
9292
"SupportAutoSize" => false,
9393
"SnapshotsConfigurable" => false,
9494
"SnapshotsAffectSizes" => false,
95+
"AdjustByRam" => false,
9596
"SizeRelevantVolumes" => []
9697
}
9798
}

‎service/test/agama/dbus/storage/volume_conversion/to_dbus_test.rb

+3
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
outline.snapshots_configurable = true
3939
outline.snapshots_size = Y2Storage::DiskSize.new(1000)
4040
outline.snapshots_percentage = 10
41+
outline.adjust_by_ram = true
4142
end
4243

4344
Agama::Storage::Volume.new("/test").tap do |volume|
@@ -70,6 +71,7 @@
7071
"Required" => false,
7172
"FsTypes" => [],
7273
"SupportAutoSize" => false,
74+
"AdjustByRam" => false,
7375
"SnapshotsConfigurable" => false,
7476
"SnapshotsAffectSizes" => false,
7577
"SizeRelevantVolumes" => []
@@ -90,6 +92,7 @@
9092
"Outline" => {
9193
"Required" => true,
9294
"FsTypes" => ["Ext3", "Ext4"],
95+
"AdjustByRam" => true,
9396
"SupportAutoSize" => true,
9497
"SnapshotsConfigurable" => true,
9598
"SnapshotsAffectSizes" => true,

‎web/src/assets/styles/blocks.scss

+21
Original file line numberDiff line numberDiff line change
@@ -469,6 +469,27 @@ ul[data-type="agama/list"][role="grid"] {
469469
}
470470
}
471471

472+
[data-type="agama/reminder"] {
473+
--accent-color: var(--color-primary-lighter);
474+
--inline-margin: calc(var(--header-icon-size) + var(--spacer-small));
475+
476+
display: flex;
477+
gap: var(--spacer-small);
478+
margin-inline: var(--inline-margin);
479+
margin-block-end: var(--spacer-normal);
480+
padding: var(--spacer-smaller) var(--spacer-small);
481+
border-inline-start: 3px solid var(--accent-color);
482+
483+
svg {
484+
fill: var(--accent-color);
485+
}
486+
487+
h4 {
488+
color: var(--accent-color);
489+
margin-block-end: var(--spacer-smaller);
490+
}
491+
}
492+
472493
[role="dialog"] {
473494
section:not([class^="pf-c"]) {
474495
> svg:first-child {

‎web/src/client/storage.js

+2
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,7 @@ class ProposalManager {
359359
* @property {boolean} required
360360
* @property {string[]} fsTypes
361361
* @property {boolean} supportAutoSize
362+
* @property {boolean} adjustByRam
362363
* @property {boolean} snapshotsConfigurable
363364
* @property {boolean} snapshotsAffectSizes
364365
* @property {string[]} sizeRelevantVolumes
@@ -603,6 +604,7 @@ class ProposalManager {
603604
required: dbusOutline.Required.v,
604605
fsTypes: dbusOutline.FsTypes.v.map(val => val.v),
605606
supportAutoSize: dbusOutline.SupportAutoSize.v,
607+
adjustByRam: dbusOutline.AdjustByRam.v,
606608
snapshotsConfigurable: dbusOutline.SnapshotsConfigurable.v,
607609
snapshotsAffectSizes: dbusOutline.SnapshotsAffectSizes.v,
608610
sizeRelevantVolumes: dbusOutline.SizeRelevantVolumes.v.map(val => val.v)

‎web/src/client/storage.test.js

+6
Original file line numberDiff line numberDiff line change
@@ -478,6 +478,7 @@ const contexts = {
478478
SupportAutoSize: { t: "b", v: true },
479479
SnapshotsConfigurable: { t: "b", v: true },
480480
SnapshotsAffectSizes: { t: "b", v: true },
481+
AdjustByRam: { t: "b", v: false },
481482
SizeRelevantVolumes: { t: "as", v: [{ t: "s", v: "/home" }] }
482483
}
483484
}
@@ -498,6 +499,7 @@ const contexts = {
498499
SupportAutoSize: { t: "b", v: false },
499500
SnapshotsConfigurable: { t: "b", v: false },
500501
SnapshotsAffectSizes: { t: "b", v: false },
502+
AdjustByRam: { t: "b", v: false },
501503
SizeRelevantVolumes: { t: "as", v: [] }
502504
}
503505
}
@@ -1390,6 +1392,7 @@ describe("#proposal", () => {
13901392
SupportAutoSize: { t: "b", v: false },
13911393
SnapshotsConfigurable: { t: "b", v: false },
13921394
SnapshotsAffectSizes: { t: "b", v: false },
1395+
AdjustByRam: { t: "b", v: false },
13931396
SizeRelevantVolumes: { t: "as", v: [] }
13941397
}
13951398
}
@@ -1410,6 +1413,7 @@ describe("#proposal", () => {
14101413
SupportAutoSize: { t: "b", v: false },
14111414
SnapshotsConfigurable: { t: "b", v: false },
14121415
SnapshotsAffectSizes: { t: "b", v: false },
1416+
AdjustByRam: { t: "b", v: false },
14131417
SizeRelevantVolumes: { t: "as", v: [] }
14141418
}
14151419
}
@@ -1437,6 +1441,7 @@ describe("#proposal", () => {
14371441
supportAutoSize: false,
14381442
snapshotsConfigurable: false,
14391443
snapshotsAffectSizes: false,
1444+
adjustByRam: false,
14401445
sizeRelevantVolumes: []
14411446
}
14421447
});
@@ -1457,6 +1462,7 @@ describe("#proposal", () => {
14571462
supportAutoSize: false,
14581463
snapshotsConfigurable: false,
14591464
snapshotsAffectSizes: false,
1465+
adjustByRam: false,
14601466
sizeRelevantVolumes: []
14611467
}
14621468
});

‎web/src/components/core/Reminder.jsx

+84
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/*
2+
* Copyright (c) [2024] SUSE LLC
3+
*
4+
* All Rights Reserved.
5+
*
6+
* This program is free software; you can redistribute it and/or modify it
7+
* under the terms of version 2 of the GNU General Public License as published
8+
* by the Free Software Foundation.
9+
*
10+
* This program is distributed in the hope that it will be useful, but WITHOUT
11+
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
12+
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
13+
* more details.
14+
*
15+
* You should have received a copy of the GNU General Public License along
16+
* with this program; if not, contact SUSE LLC.
17+
*
18+
* To contact SUSE LLC about this file by physical or electronic mail, you may
19+
* find current contact information at www.suse.com.
20+
*/
21+
22+
// @ts-check
23+
24+
import React from "react";
25+
import { Icon } from "~/components/layout";
26+
27+
/**
28+
* Internal component for rendering the icon
29+
*
30+
* @param {object} props
31+
* @params {string} [props.name] - The icon name.
32+
*/
33+
const ReminderIcon = ({ name }) => {
34+
if (!name?.length) return;
35+
36+
return (
37+
<div>
38+
<Icon name={name} size="xs" />
39+
</div>
40+
);
41+
};
42+
43+
/**
44+
* Internal component for rendering the title
45+
*
46+
* @param {object} props
47+
* @params {JSX.Element|string} [props.children] - The title content.
48+
*/
49+
const ReminderTitle = ({ children }) => {
50+
if (!children) return;
51+
if (typeof children === "string" && !children.length) return;
52+
53+
return (
54+
<h4>{children}</h4>
55+
);
56+
};
57+
58+
/**
59+
* Renders a reminder with given role, status by default
60+
* @component
61+
*
62+
* @param {object} props
63+
* @param {string} [props.icon] - The name of desired icon.
64+
* @param {JSX.Element|string} [props.title] - The content for the title.
65+
* @param {string} [props.role="status"] - The reminder's role, "status" by
66+
* default. See {@link https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/status_role}
67+
* @param {JSX.Element} [props.children] - The content for the description.
68+
*/
69+
export default function Reminder ({
70+
icon,
71+
title,
72+
role = "status",
73+
children
74+
}) {
75+
return (
76+
<div role={role} data-type="agama/reminder">
77+
<ReminderIcon name={icon} />
78+
<div>
79+
<ReminderTitle>{title}</ReminderTitle>
80+
{ children }
81+
</div>
82+
</div>
83+
);
84+
}
+65
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/*
2+
* Copyright (c) [2023] SUSE LLC
3+
*
4+
* All Rights Reserved.
5+
*
6+
* This program is free software; you can redistribute it and/or modify it
7+
* under the terms of version 2 of the GNU General Public License as published
8+
* by the Free Software Foundation.
9+
*
10+
* This program is distributed in the hope that it will be useful, but WITHOUT
11+
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
12+
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
13+
* more details.
14+
*
15+
* You should have received a copy of the GNU General Public License along
16+
* with this program; if not, contact SUSE LLC.
17+
*
18+
* To contact SUSE LLC about this file by physical or electronic mail, you may
19+
* find current contact information at www.suse.com.
20+
*/
21+
22+
import React from "react";
23+
import { screen, within } from "@testing-library/react";
24+
import { plainRender } from "~/test-utils";
25+
import { Reminder } from "~/components/core";
26+
27+
describe("Reminder", () => {
28+
it("renders a status region by default", () => {
29+
plainRender(<Reminder>Example</Reminder>);
30+
const reminder = screen.getByRole("status");
31+
within(reminder).getByText("Example");
32+
});
33+
34+
it("renders a region with given role", () => {
35+
plainRender(<Reminder role="alert">Example</Reminder>);
36+
const reminder = screen.getByRole("alert");
37+
within(reminder).getByText("Example");
38+
});
39+
40+
it("renders given title", () => {
41+
plainRender(
42+
<Reminder title={<span><strong>Kindly</strong> reminder</span>}>
43+
<a href="#">Visit the settings section</a>
44+
</Reminder>
45+
);
46+
screen.getByRole("heading", { name: "Kindly reminder", level: 4 });
47+
});
48+
49+
it("does not render a heading if title is not given", () => {
50+
plainRender(<Reminder>Without title</Reminder>);
51+
expect(screen.queryByRole("heading")).toBeNull();
52+
});
53+
54+
it("does not render a heading if title is an empty string", () => {
55+
plainRender(<Reminder title="">Without title</Reminder>);
56+
expect(screen.queryByRole("heading")).toBeNull();
57+
});
58+
59+
it("renders given children", () => {
60+
plainRender(
61+
<Reminder><a href="#">Visit the settings section</a></Reminder>
62+
);
63+
screen.getByRole("link", { name: "Visit the settings section" });
64+
});
65+
});

‎web/src/components/core/index.js

+1
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,4 @@ export { default as PasswordInput } from "./PasswordInput";
5757
export { default as DevelopmentInfo } from "./DevelopmentInfo";
5858
export { default as Selector } from "./Selector";
5959
export { default as OptionsPicker } from "./OptionsPicker";
60+
export { default as Reminder } from "./Reminder";

‎web/src/components/storage/ProposalPage.jsx

+5-1
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ import {
3131
ProposalSettingsSection,
3232
ProposalSpacePolicySection,
3333
ProposalDeviceSection,
34-
ProposalFileSystemsSection
34+
ProposalFileSystemsSection,
35+
ProposalTransactionalInfo
3536
} from "~/components/storage";
3637
import { IDLE } from "~/client/status";
3738

@@ -220,6 +221,9 @@ export default function ProposalPage() {
220221
const PageContent = () => {
221222
return (
222223
<>
224+
<ProposalTransactionalInfo
225+
settings={state.settings}
226+
/>
223227
<ProposalDeviceSection
224228
settings={state.settings}
225229
availableDevices={state.availableDevices}

‎web/src/components/storage/ProposalSettingsSection.jsx

+16-47
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,12 @@
2121

2222
import React, { useEffect, useState } from "react";
2323
import { Checkbox, Form, Skeleton, Switch, Tooltip } from "@patternfly/react-core";
24-
import { sprintf } from "sprintf-js";
2524

2625
import { _ } from "~/i18n";
2726
import { If, PasswordAndConfirmationInput, Section, Popup } from "~/components/core";
2827
import { Icon } from "~/components/layout";
2928
import { noop } from "~/utils";
30-
import { hasFS, isTransactionalSystem } from "~/components/storage/utils";
31-
import { useProduct } from "~/context/product";
29+
import { hasFS } from "~/components/storage/utils";
3230

3331
/**
3432
* @typedef {import ("~/client/storage").ProposalManager.ProposalSettings} ProposalSettings
@@ -145,33 +143,23 @@ const SnapshotsField = ({
145143
onChange({ active: checked, settings });
146144
};
147145

148-
const configurableSnapshots = rootVolume.outline.snapshotsConfigurable;
149-
const forcedSnapshots = !configurableSnapshots && hasFS(rootVolume, "Btrfs") && rootVolume.snapshots;
146+
if (!rootVolume.outline.snapshotsConfigurable) return;
150147

151-
const SnapshotsToggle = () => {
152-
const explanation = _("Uses Btrfs for the root file system allowing to boot to a previous \
148+
const explanation = _("Uses Btrfs for the root file system allowing to boot to a previous \
153149
version of the system after configuration changes or software upgrades.");
154150

155-
return (
156-
<>
157-
<Switch
158-
id="snapshots"
159-
label={_("Use Btrfs Snapshots")}
160-
isReversed
161-
isChecked={isChecked}
162-
onChange={switchState}
163-
/>
164-
<div>
165-
{explanation}
166-
</div>
167-
</>
168-
);
169-
};
170-
171151
return (
172152
<div>
173-
<If condition={forcedSnapshots} then={_("Btrfs snapshots required by product.")} />
174-
<If condition={configurableSnapshots} then={<SnapshotsToggle />} />
153+
<Switch
154+
id="snapshots"
155+
label={_("Use Btrfs Snapshots")}
156+
isReversed
157+
isChecked={isChecked}
158+
onChange={switchState}
159+
/>
160+
<div>
161+
{explanation}
162+
</div>
175163
</div>
176164
);
177165
};
@@ -297,8 +285,6 @@ export default function ProposalSettingsSection({
297285
encryptionMethods = [],
298286
onChange = noop
299287
}) {
300-
const { selectedProduct } = useProduct();
301-
302288
const changeEncryption = ({ password, method }) => {
303289
onChange({ encryptionPassword: password, encryptionMethod: method });
304290
};
@@ -318,29 +304,12 @@ export default function ProposalSettingsSection({
318304

319305
const encryption = settings.encryptionPassword !== undefined && settings.encryptionPassword.length > 0;
320306

321-
const transactional = isTransactionalSystem(settings?.volumes || []);
322-
323307
return (
324308
<>
325309
<Section title={_("Settings")}>
326-
<If
327-
condition={transactional}
328-
then={
329-
<div>
330-
<label>{_("Transactional system")}</label>
331-
<div>
332-
{/* TRANSLATORS: %s is replaced by a product name (e.g., openSUSE Tumbleweed) */}
333-
{sprintf(_("%s is an immutable system with atomic updates using a read-only Btrfs \
334-
root file system."), selectedProduct.name)}
335-
</div>
336-
</div>
337-
}
338-
else={
339-
<SnapshotsField
340-
settings={settings}
341-
onChange={changeBtrfsSnapshots}
342-
/>
343-
}
310+
<SnapshotsField
311+
settings={settings}
312+
onChange={changeBtrfsSnapshots}
344313
/>
345314
<EncryptionField
346315
password={settings.encryptionPassword || ""}

‎web/src/components/storage/ProposalSettingsSection.test.jsx

+5-12
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,6 @@ jest.mock("@patternfly/react-core", () => {
3333
};
3434
});
3535

36-
jest.mock("~/context/product", () => ({
37-
...jest.requireActual("~/context/product"),
38-
useProduct: () => ({
39-
selectedProduct : { name: "Test" }
40-
})
41-
}));
42-
4336
let props;
4437

4538
beforeEach(() => {
@@ -48,7 +41,7 @@ beforeEach(() => {
4841

4942
const rootVolume = { mountPath: "/", fsType: "Btrfs", outline: { snapshotsConfigurable: true } };
5043

51-
describe("if the system is not transactional", () => {
44+
describe("if snapshots are configurable", () => {
5245
beforeEach(() => {
5346
props.settings = { volumes: [rootVolume] };
5447
});
@@ -60,15 +53,15 @@ describe("if the system is not transactional", () => {
6053
});
6154
});
6255

63-
describe("if the system is transactional", () => {
56+
describe("if snapshots are not configurable", () => {
6457
beforeEach(() => {
65-
props.settings = { volumes: [{ ...rootVolume, transactional: true }] };
58+
props.settings = { volumes: [{ ...rootVolume, outline: { ...rootVolume.outline, snapshotsConfigurable: false } }] };
6659
});
6760

68-
it("renders explanation about transactional system", () => {
61+
it("does not render the snapshots switch", () => {
6962
plainRender(<ProposalSettingsSection {...props} />);
7063

71-
screen.getByText("Transactional system");
64+
expect(screen.queryByRole("checkbox", { name: "Use Btrfs Snapshots" })).toBeNull();
7265
});
7366
});
7467

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/*
2+
* Copyright (c) [2024] SUSE LLC
3+
*
4+
* All Rights Reserved.
5+
*
6+
* This program is free software; you can redistribute it and/or modify it
7+
* under the terms of version 2 of the GNU General Public License as published
8+
* by the Free Software Foundation.
9+
*
10+
* This program is distributed in the hope that it will be useful, but WITHOUT
11+
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
12+
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
13+
* more details.
14+
*
15+
* You should have received a copy of the GNU General Public License along
16+
* with this program; if not, contact SUSE LLC.
17+
*
18+
* To contact SUSE LLC about this file by physical or electronic mail, you may
19+
* find current contact information at www.suse.com.
20+
*/
21+
22+
import React from "react";
23+
24+
import { sprintf } from "sprintf-js";
25+
import { _ } from "~/i18n";
26+
import { Reminder } from "~/components/core";
27+
import { isTransactionalSystem } from "~/components/storage/utils";
28+
import { useProduct } from "~/context/product";
29+
30+
/**
31+
* @typedef {import ("~/client/storage").ProposalManager.ProposalSettings} ProposalSettings
32+
*/
33+
34+
/**
35+
* Information about the system being transactional, if needed
36+
* @component
37+
*
38+
* @param {object} props
39+
* @param {ProposalSettings} props.settings - Settings used for calculating a proposal.
40+
*/
41+
export default function ProposalTransactionalInfo({ settings }) {
42+
const { selectedProduct } = useProduct();
43+
44+
if (!isTransactionalSystem(settings?.volumes)) return;
45+
46+
const title = _("Transactional root file system");
47+
/* TRANSLATORS: %s is replaced by a product name (e.g., openSUSE Tumbleweed) */
48+
const description = sprintf(
49+
_("%s is an immutable system with atomic updates. It uses a read-only Btrfs file system updated via snapshots."),
50+
selectedProduct.name
51+
);
52+
53+
return <Reminder title={title}>{description}</Reminder>;
54+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/*
2+
* Copyright (c) [2024] SUSE LLC
3+
*
4+
* All Rights Reserved.
5+
*
6+
* This program is free software; you can redistribute it and/or modify it
7+
* under the terms of version 2 of the GNU General Public License as published
8+
* by the Free Software Foundation.
9+
*
10+
* This program is distributed in the hope that it will be useful, but WITHOUT
11+
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
12+
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
13+
* more details.
14+
*
15+
* You should have received a copy of the GNU General Public License along
16+
* with this program; if not, contact SUSE LLC.
17+
*
18+
* To contact SUSE LLC about this file by physical or electronic mail, you may
19+
* find current contact information at www.suse.com.
20+
*/
21+
22+
import React from "react";
23+
import { screen } from "@testing-library/react";
24+
import { plainRender } from "~/test-utils";
25+
import { ProposalTransactionalInfo } from "~/components/storage";
26+
27+
jest.mock("~/context/product", () => ({
28+
...jest.requireActual("~/context/product"),
29+
useProduct: () => ({
30+
selectedProduct : { name: "Test" }
31+
})
32+
}));
33+
34+
let props;
35+
36+
beforeEach(() => {
37+
props = {};
38+
});
39+
40+
const rootVolume = { mountPath: "/", fsType: "Btrfs" };
41+
42+
describe("if the system is not transactional", () => {
43+
beforeEach(() => {
44+
props.settings = { volumes: [rootVolume] };
45+
});
46+
47+
it("renders nothing", () => {
48+
const { container } = plainRender(<ProposalTransactionalInfo {...props} />);
49+
expect(container).toBeEmptyDOMElement();
50+
});
51+
});
52+
53+
describe("if the system is transactional", () => {
54+
beforeEach(() => {
55+
props.settings = { volumes: [{ ...rootVolume, transactional: true }] };
56+
});
57+
58+
it("renders an explanation about the transactional system", () => {
59+
plainRender(<ProposalTransactionalInfo {...props} />);
60+
61+
screen.getByText("Transactional root file system");
62+
});
63+
});

‎web/src/components/storage/ProposalVolumes.jsx

+8-4
Original file line numberDiff line numberDiff line change
@@ -46,16 +46,16 @@ import { noop } from "~/utils";
4646
* @returns {(ReactComponent|null)} component to display (can be `null`)
4747
*/
4848
const AutoCalculatedHint = (volume) => {
49-
// no hint, the size is not affected by snapshots or other volumes
50-
const { snapshotsAffectSizes = false, sizeRelevantVolumes = [] } = volume.outline;
49+
const { snapshotsAffectSizes = false, sizeRelevantVolumes = [], adjustByRam } = volume.outline;
5150

52-
if (!snapshotsAffectSizes && sizeRelevantVolumes.length === 0) {
51+
// no hint, the size is not affected by known criteria
52+
if (!snapshotsAffectSizes && !adjustByRam && sizeRelevantVolumes.length === 0) {
5353
return null;
5454
}
5555

5656
return (
5757
<>
58-
{/* TRANSLATORS: header for a list of items */}
58+
{/* TRANSLATORS: header for a list of items referring to size limits for file systems */}
5959
{_("These limits are affected by:")}
6060
<List>
6161
{snapshotsAffectSizes &&
@@ -65,6 +65,10 @@ const AutoCalculatedHint = (volume) => {
6565
// TRANSLATORS: list item, this affects the computed partition size limits
6666
// %s is replaced by a list of the volumes (like "/home, /boot")
6767
<ListItem>{sprintf(_("Presence of other volumes (%s)"), sizeRelevantVolumes.join(", "))}</ListItem>}
68+
{adjustByRam &&
69+
// TRANSLATORS: list item, describes a factor that affects the computed size of a
70+
// file system; eg. adjusting the size of the swap
71+
<ListItem>{_("The amount of RAM in the system")}</ListItem>}
6872
</List>
6973
</>
7074
);

‎web/src/components/storage/VolumeForm.jsx

+4
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,10 @@ const SizeAuto = ({ volume }) => {
310310
// TRANSLATORS: conjunction for merging two list items
311311
volume.outline.sizeRelevantVolumes.join(_(", "))));
312312

313+
if (volume.outline.adjustByRam)
314+
// TRANSLATORS: item which affects the final computed partition size
315+
conditions.push(_("the amount of RAM in the system"));
316+
313317
// TRANSLATORS: the %s is replaced by the items which affect the computed size
314318
const conditionsText = sprintf(_("The final size depends on %s."),
315319
// TRANSLATORS: conjunction for merging two texts

‎web/src/components/storage/index.js

+1
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export { default as ProposalSpacePolicySection } from "./ProposalSpacePolicySect
2626
export { default as ProposalDeviceSection } from "./ProposalDeviceSection";
2727
export { default as ProposalFileSystemsSection } from "./ProposalFileSystemsSection";
2828
export { default as ProposalActionsSection } from "./ProposalActionsSection";
29+
export { default as ProposalTransactionalInfo } from "./ProposalTransactionalInfo";
2930
export { default as ProposalVolumes } from "./ProposalVolumes";
3031
export { default as DASDPage } from "./DASDPage";
3132
export { default as DASDTable } from "./DASDTable";

‎web/src/components/storage/utils.js

+6-2
Original file line numberDiff line numberDiff line change
@@ -180,8 +180,12 @@ const isTransactionalRoot = (volume) => {
180180
* @param {Volume[]} volumes
181181
* @returns {boolean}
182182
*/
183-
const isTransactionalSystem = (volumes) => {
184-
return volumes.find(v => isTransactionalRoot(v)) !== undefined;
183+
const isTransactionalSystem = (volumes = []) => {
184+
try {
185+
return volumes?.find(v => isTransactionalRoot(v)) !== undefined;
186+
} catch {
187+
return false;
188+
}
185189
};
186190

187191
export {

‎web/src/components/storage/utils.test.js

+8
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,14 @@ describe("isTransactionalRoot", () => {
139139
});
140140

141141
describe("isTransactionalSystem", () => {
142+
it("returns false when a list of volumes is not given", () => {
143+
expect(isTransactionalSystem(false)).toBe(false);
144+
expect(isTransactionalSystem(undefined)).toBe(false);
145+
expect(isTransactionalSystem(null)).toBe(false);
146+
expect(isTransactionalSystem([])).toBe(false);
147+
expect(isTransactionalSystem("fake")).toBe(false);
148+
});
149+
142150
it("returns false if volumes does not include a transactional root", () => {
143151
expect(isTransactionalSystem([])).toBe(false);
144152

0 commit comments

Comments
 (0)
Please sign in to comment.