-
Notifications
You must be signed in to change notification settings - Fork 74
Expand file tree
/
Copy pathconfig.rs
More file actions
384 lines (347 loc) · 13.5 KB
/
config.rs
File metadata and controls
384 lines (347 loc) · 13.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
// 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 std::{io::Write, path::PathBuf, process::Command};
use agama_lib::{
context::InstallationContext,
http::{BaseHTTPClient, BaseHTTPClientError},
install_settings::InstallSettings,
monitor::MonitorClient,
profile::ValidationOutcome,
utils::FileFormat,
Store as SettingsStore,
};
use anyhow::{anyhow, Context};
use clap::Subcommand;
use console::style;
use fluent_uri::Uri;
use tempfile::Builder;
use crate::{cli_input::CliInput, cli_output::CliOutput, show_progress, GlobalOpts};
const DEFAULT_EDITOR: &str = "/usr/bin/vi";
#[derive(Subcommand, Debug)]
pub enum ConfigCommands {
/// Generate an installation profile with the current settings.
///
/// It is possible that many configuration settings do not have a value. Those settings
/// are not included in the output.
///
/// The output of command can be used as input for the "agama config load".
Show {
/// Save the output here (goes to stdout if not given)
#[arg(short, long, value_name = "FILE_PATH")]
output: Option<CliOutput>,
},
/// Read and load a profile
Load {
/// JSON file: URL or path or `-` for standard input
url_or_path: Option<CliInput>,
},
/// Validate a profile using JSON Schema
///
/// Schema is available at /usr/share/agama-cli/profile.schema.json
/// Note: validation is always done as part of all other "agama config" commands.
Validate {
/// JSON file, URL or path or `-` for standard input
url_or_path: CliInput,
},
/// Generate and print a native Agama JSON configuration from any kind and location.
///
/// Kinds:
/// - JSON
/// - Jsonnet, injecting the hardware information
/// - AutoYaST profile, including ERB and rules/classes
///
/// Locations:
/// - path
/// - URL (including AutoYaST specific schemes)
///
/// For an example of Jsonnet-based profile, see
/// https://github.com/openSUSE/agama/blob/master/rust/agama-lib/share/examples/profile.jsonnet
#[command(verbatim_doc_comment)]
Generate {
/// JSON file: URL or path or `-` for standard input
url_or_path: Option<CliInput>,
},
/// Edit and update installation option using an external editor.
///
/// The changes are not applied if the editor exits with an error code.
///
/// If an editor is not specified, it honors the EDITOR environment variable. It falls back to
/// `/usr/bin/vi` as a last resort.
Edit {
/// Editor command (including additional arguments if needed)
#[arg(short, long)]
editor: Option<String>,
},
}
pub async fn run(
http_client: BaseHTTPClient,
monitor: MonitorClient,
subcommand: ConfigCommands,
opts: GlobalOpts,
) -> anyhow::Result<()> {
let store = SettingsStore::new(http_client.clone()).await?;
match subcommand {
ConfigCommands::Show { output } => {
let model = store.load().await?;
let json = serde_json::to_string_pretty(&model)?;
let destination = output.unwrap_or(CliOutput::Stdout);
destination.write(&json)?;
eprintln!();
validate(&http_client, CliInput::Full(json.clone())).await?;
Ok(())
}
ConfigCommands::Load { url_or_path } => {
let url_or_path = url_or_path.unwrap_or(CliInput::Stdin);
let contents = url_or_path.read_to_string(opts.insecure)?;
// FIXME: invalid profile still gets loaded
validate(&http_client, CliInput::Full(contents.clone())).await?;
let result = InstallSettings::from_json(&contents, &InstallationContext::from_env()?)?;
tokio::spawn(async move {
show_progress(monitor, true).await;
});
store.store(&result).await?;
Ok(())
}
ConfigCommands::Validate { url_or_path } => validate(&http_client, url_or_path).await,
ConfigCommands::Generate { url_or_path } => {
let url_or_path = url_or_path.unwrap_or(CliInput::Stdin);
generate(&http_client, url_or_path, opts.insecure).await
}
ConfigCommands::Edit { editor } => {
let model = store.load().await?;
let editor = editor
.or_else(|| std::env::var("EDITOR").ok())
.unwrap_or(DEFAULT_EDITOR.to_string());
let result = edit(&http_client, &model, &editor).await?;
tokio::spawn(async move {
show_progress(monitor, true).await;
});
store.store(&result).await?;
Ok(())
}
}
}
/// Validate a JSON profile, by doing a HTTP client request.
async fn validate_client(
client: &BaseHTTPClient,
url_or_path: CliInput,
) -> anyhow::Result<ValidationOutcome> {
// unwrap OK: joining a parsable constant to a valid Url
let mut url = client.base_url.join("profile/validate").unwrap();
url_or_path.add_query(&mut url)?;
let body = url_or_path.body_for_web()?;
// we use plain text .body instead of .json
let response = client
.client
.request(reqwest::Method::POST, url)
.body(body)
.send()
.await?;
Ok(client.deserialize_or_error(response).await?)
}
async fn validate(client: &BaseHTTPClient, url_or_path: CliInput) -> anyhow::Result<()> {
let validity = validate_client(client, url_or_path).await?;
match validity {
ValidationOutcome::Valid => {
eprintln!("{} {}", style("\u{2713}").bold().green(), validity);
}
ValidationOutcome::NotValid(_) => {
eprintln!("{} {}", style("\u{2717}").bold().red(), validity);
}
}
Ok(())
}
fn is_autoyast(url_or_path: &CliInput) -> bool {
let path = match url_or_path {
CliInput::Path(pathbuf) => pathbuf.as_os_str().to_str().unwrap_or_default().to_string(),
CliInput::Url(url_string) => {
let url = Uri::parse(url_string.as_str()).unwrap_or_default();
let path = url.path().to_string();
path
}
_ => {
return false;
}
};
path.ends_with(".xml") || path.ends_with(".erb") || path.ends_with('/')
}
async fn generate(
client: &BaseHTTPClient,
url_or_path: CliInput,
insecure: bool,
) -> anyhow::Result<()> {
let context = match &url_or_path {
CliInput::Stdin | CliInput::Full(_) => InstallationContext::from_env()?,
CliInput::Url(url_str) => InstallationContext::from_url_str(url_str)?,
CliInput::Path(pathbuf) => InstallationContext::from_file(pathbuf.as_path())?,
};
// the AutoYaST profile is always downloaded insecurely
// (https://github.com/yast/yast-installation/blob/960c66658ab317007d2e241aab7b224657970bf9/src/lib/transfer/file_from_url.rb#L188)
// we can ignore the insecure option value in that case
let profile_json = if is_autoyast(&url_or_path) {
// AutoYaST specific download and convert to JSON
let config_string = match url_or_path {
CliInput::Url(url_string) => {
let url = Uri::parse(url_string)?;
autoyast_client(client, &url).await?
}
CliInput::Path(pathbuf) => {
let canon_path = pathbuf.canonicalize()?;
let url_string = format!("file://{}", canon_path.display());
let url = Uri::parse(url_string)?;
autoyast_client(client, &url).await?
}
_ => panic!("is_autoyast returned true on unnamed input"),
};
config_string
} else {
from_json_or_jsonnet(client, url_or_path, insecure).await?
};
let validity = validate_client(client, CliInput::Full(profile_json.clone())).await?;
match validity {
ValidationOutcome::NotValid(_) => {
// invalid before InstallSettings processing: print profile and validation result
println!("{}", &profile_json);
eprintln!("{} {}", style("\u{2717}").bold().red(), validity);
return Ok(());
}
ValidationOutcome::Valid => {}
}
// resolves relative URL references
let model = InstallSettings::from_json(&profile_json, &context)?;
let config_json = serde_json::to_string_pretty(&model)?;
println!("{}", &config_json);
let validity = validate_client(client, CliInput::Full(config_json.clone())).await?;
match validity {
ValidationOutcome::Valid => {
eprintln!("{} {}", style("\u{2713}").bold().green(), validity);
}
ValidationOutcome::NotValid(_) => {
let red_x = style("\u{2717}").bold().red();
eprintln!("{} {}", red_x, validity);
eprintln!(
"{} Internal error: the profile was made invalid by InstallSettings round trip",
red_x
);
}
}
Ok(())
}
/// Process AutoYaST profile (*url* ending with .xml, .erb, or dir/) by doing a HTTP client request.
/// Note that this client does not act on this *url*, it passes it as a parameter
/// to our web backend.
/// Return well-formed Agama JSON on success.
async fn autoyast_client(
client: &BaseHTTPClient,
url: &Uri<String>,
) -> Result<String, BaseHTTPClientError> {
// FIXME: how to escape it?
let api_url = format!("/profile/autoyast?url={}", url);
let output: Box<serde_json::value::RawValue> = client.post(&api_url, &()).await?;
let config_string = format!("{}", output);
Ok(config_string)
}
// Retrieve and preprocess the profile.
//
// The profile can be a JSON or a Jsonnet file.
//
// * If it is a JSON file, no preprocessing is needed.
// * If it is a Jsonnet file, it is converted to JSON.
async fn from_json_or_jsonnet(
client: &BaseHTTPClient,
url_or_path: CliInput,
insecure: bool,
) -> anyhow::Result<String> {
let any_profile = url_or_path.read_to_string(insecure)?;
match FileFormat::from_string(&any_profile) {
FileFormat::Jsonnet => {
let json_string = evaluate_client(client, CliInput::Full(any_profile)).await?;
Ok(json_string)
}
FileFormat::Json => Ok(any_profile),
FileFormat::Unknown => Err(anyhow::Error::msg(
"Unsupported file format. Expected JSON, or Jsonnet",
)),
}
}
/// Evaluate a Jsonnet profile, by doing a HTTP client request.
/// Return well-formed Agama JSON on success.
async fn evaluate_client(client: &BaseHTTPClient, url_or_path: CliInput) -> anyhow::Result<String> {
// unwrap OK: joining a parsable constant to a valid Url
let mut url = client.base_url.join("profile/evaluate").unwrap();
url_or_path.add_query(&mut url)?;
let body = url_or_path.body_for_web()?;
// we use plain text .body instead of .json
let response: Result<reqwest::Response, agama_lib::error::ServiceError> = client
.client
.request(reqwest::Method::POST, url)
.body(body)
.send()
.await
.map_err(|e| e.into());
let output: Box<serde_json::value::RawValue> = client.deserialize_or_error(response?).await?;
Ok(output.to_string())
}
/// Edit the installation settings using an external editor.
///
/// If the editor does not return a successful error code, it returns an error.
///
/// * `http_client`: for invoking validation of the edited text
/// * `model`: current installation settings.
/// * `editor`: editor command.
async fn edit(
http_client: &BaseHTTPClient,
model: &InstallSettings,
editor: &str,
) -> anyhow::Result<InstallSettings> {
let content = serde_json::to_string_pretty(model)?;
let mut file = Builder::new().suffix(".json").tempfile()?;
let path = PathBuf::from(file.path());
write!(file, "{}", content)?;
let mut base_command = editor_command(editor);
let command = base_command.arg(path.as_os_str());
let status = command.status().context(format!("Running {:?}", command))?;
// TODO: do nothing if the content of the file is unchanged
if status.success() {
// FIXME: invalid profile still gets loaded
let contents =
std::fs::read_to_string(&path).context(format!("Reading from file {:?}", path))?;
validate(&http_client, CliInput::Full(contents)).await?;
return Ok(InstallSettings::from_file(
path,
&InstallationContext::from_env()?,
)?);
}
Err(anyhow!(
"Ignoring the changes becase the editor was closed with an error code."
))
}
/// Return the Command to run the editor.
///
/// Separate the program and the arguments and build a Command struct.
///
/// * `command`: command to run as editor.
fn editor_command(command: &str) -> Command {
let mut parts = command.split_whitespace();
let program = parts.next().unwrap_or(DEFAULT_EDITOR);
let mut command = Command::new(program);
command.args(parts.collect::<Vec<&str>>());
command
}