mz/command/
profile.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License in the LICENSE file at the
6// root of this repository, or online at
7//
8//     http://www.apache.org/licenses/LICENSE-2.0
9//
10// Unless required by applicable law or agreed to in writing, software
11// distributed under the License is distributed on an "AS IS" BASIS,
12// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13// See the License for the specific language governing permissions and
14// limitations under the License.
15
16//! Implementation of the `mz profile` command.
17//!
18//! Consult the user-facing documentation for details.
19
20use std::{io::Write, str::FromStr};
21
22use mz_frontegg_auth::AppPassword;
23use mz_frontegg_client::client::app_password::CreateAppPasswordRequest;
24use mz_frontegg_client::client::{Client as AdminClient, Credentials};
25use mz_frontegg_client::config::{
26    ClientBuilder as AdminClientBuilder, ClientConfig as AdminClientConfig,
27};
28
29use mz_cloud_api::config::DEFAULT_ENDPOINT;
30use serde::{Deserialize, Serialize};
31use tabled::Tabled;
32use tokio::{select, sync::mpsc};
33use url::Url;
34
35use crate::ui::OptionalStr;
36use crate::{
37    config_file::TomlProfile,
38    context::{Context, ProfileContext},
39    error::Error,
40    error::Error::ProfileNameAlreadyExistsError,
41    server::server,
42};
43
44/// Strips the `.api` from the login endpoint.
45/// The `.api` prefix will cause a failure during login
46/// in the browser.
47fn strip_api_from_endpoint(endpoint: Url) -> Url {
48    if let Some(domain) = endpoint.domain() {
49        if let Some(corrected_domain) = domain.strip_prefix("api.") {
50            let mut new_endpoint = endpoint.clone();
51            let _ = new_endpoint.set_host(Some(corrected_domain));
52
53            return new_endpoint;
54        }
55    };
56
57    endpoint
58}
59
60/// Opens the default web browser in the host machine
61/// and awaits a single request containing the profile's app password.
62pub async fn init_with_browser(cloud_endpoint: Option<Url>) -> Result<AppPassword, Error> {
63    // Bind a web server to a local port to receive the app password.
64    let (tx, mut rx) = mpsc::unbounded_channel();
65    let (server, port) = server(tx).await;
66
67    // Build the login URL
68    let mut url =
69        strip_api_from_endpoint(cloud_endpoint.unwrap_or_else(|| DEFAULT_ENDPOINT.clone()));
70
71    url.path_segments_mut()
72        .expect("constructor validated URL can be a base")
73        .extend(&["account", "login"]);
74
75    let mut query_pairs = url.query_pairs_mut();
76    query_pairs.append_pair(
77        "redirectUrl",
78        &format!("/access/cli?redirectUri=http://localhost:{port}&tokenDescription=Materialize%20CLI%20%28mz%29"),
79    );
80    // The replace is a little hack to avoid asking an additional parameter
81    // for a custom login.
82    let open_url = &query_pairs.finish().as_str().replace("cloud", "console");
83
84    // Open the browser to login user.
85    if let Err(_err) = open::that(open_url) {
86        println!(
87            "Error: Unable to launch a web browser. Access the login page using this link: '{:?}', or execute `mz profile init --no-browser` in your command line.",
88            open_url
89        )
90    }
91
92    // Wait for the browser to send the app password to our server.
93    select! {
94        _ = server => unreachable!("server should not shut down"),
95        result = rx.recv() => {
96            match result {
97                Some(app_password_result) => app_password_result,
98                None => { panic!("failed to login via browser") },
99            }
100        }
101    }
102}
103
104/// Prompts the user for the profile email and passowrd in Materialize.
105/// Notice that the password is the same as the user uses to log into
106/// the console, and not the app-password.
107pub async fn init_without_browser(admin_endpoint: Option<Url>) -> Result<AppPassword, Error> {
108    // Handle interactive user input
109    let mut email = String::new();
110
111    print!("Email: ");
112    let _ = std::io::stdout().flush();
113    std::io::stdin().read_line(&mut email).unwrap();
114
115    // Trim lines
116    if email.ends_with('\n') {
117        email.pop();
118        if email.ends_with('\r') {
119            email.pop();
120        }
121    }
122
123    print!("Password: ");
124    let _ = std::io::stdout().flush();
125    let password = rpassword::read_password().unwrap();
126
127    // Build client
128    let mut admin_client_builder = AdminClientBuilder::default();
129
130    if let Some(admin_endpoint) = admin_endpoint {
131        admin_client_builder = admin_client_builder.endpoint(admin_endpoint);
132    }
133
134    let admin_client: AdminClient = admin_client_builder.build(AdminClientConfig {
135        authentication: mz_frontegg_client::client::Authentication::Credentials(Credentials {
136            email,
137            password,
138        }),
139    });
140
141    let app_password = admin_client
142        .create_app_password(CreateAppPasswordRequest {
143            description: "Materialize CLI (mz)",
144        })
145        .await?;
146
147    Ok(app_password)
148}
149
150/// Initiates the profile creation process.
151///
152/// There are only two ways to create a profile:
153/// 1. By prompting your user and email.
154/// 2. By opening the browser and creating the credentials in the console.
155pub async fn init(
156    scx: &Context,
157    no_browser: bool,
158    force: bool,
159    admin_endpoint: Option<Url>,
160    cloud_endpoint: Option<Url>,
161) -> Result<(), Error> {
162    let config_file = scx.config_file();
163    let profile = scx
164        .get_global_profile()
165        .map_or(config_file.profile().to_string(), |n| n);
166
167    if let Some(profiles) = scx.config_file().profiles() {
168        if profiles.contains_key(&profile) && !force {
169            return Err(ProfileNameAlreadyExistsError(profile));
170        }
171    }
172
173    let app_password = match no_browser {
174        true => init_without_browser(admin_endpoint.clone()).await?,
175        false => init_with_browser(cloud_endpoint.clone()).await?,
176    };
177
178    let new_profile = TomlProfile {
179        app_password: Some(app_password.to_string()),
180        vault: None,
181        region: None,
182        admin_endpoint: admin_endpoint.map(|url| url.to_string()),
183        cloud_endpoint: cloud_endpoint.map(|url| url.to_string()),
184    };
185
186    config_file.add_profile(profile, new_profile).await?;
187
188    Ok(())
189}
190
191/// List all the possible config values for the profile.
192pub fn list(cx: &Context) -> Result<(), Error> {
193    if let Some(profiles) = cx.config_file().profiles() {
194        let output = cx.output_formatter();
195
196        // Output formatting structure.
197        #[derive(Clone, Serialize, Deserialize, Tabled)]
198        struct ProfileName<'a> {
199            #[tabled(rename = "Name")]
200            name: &'a str,
201        }
202        output.output_table(profiles.keys().map(|name| ProfileName { name }))?;
203    }
204
205    Ok(())
206}
207
208/// Removes the profile from the configuration file.
209pub async fn remove(cx: &Context) -> Result<(), Error> {
210    cx.config_file()
211        .remove_profile(
212            &cx.get_global_profile()
213                .unwrap_or_else(|| cx.config_file().profile().to_string()),
214        )
215        .await
216}
217
218/// Represents the args to retrieve a profile configuration value.
219pub struct ConfigGetArgs<'a> {
220    /// Represents the configuration field name to retrieve the value.
221    pub name: &'a str,
222}
223
224/// Represents the possible fields in a profile configuration.
225#[derive(Clone, Debug)]
226pub enum ConfigArg {
227    /// Represents `[TomlProfile::admin_endpoint]`
228    AdminAPI,
229    /// Represents `[TomlProfile::app_password]`
230    AppPassword,
231    /// Represents `[TomlProfile::cloud_endpoint]`
232    CloudAPI,
233    /// Represents `[TomlProfile::region]`
234    Region,
235    /// Represents `[TomlProfile::vault]`
236    Vault,
237}
238
239impl FromStr for ConfigArg {
240    type Err = String;
241
242    fn from_str(s: &str) -> Result<Self, Self::Err> {
243        match s.to_lowercase().as_str() {
244            "admin-api" => Ok(ConfigArg::AdminAPI),
245            "app-password" => Ok(ConfigArg::AppPassword),
246            "cloud-api" => Ok(ConfigArg::CloudAPI),
247            "region" => Ok(ConfigArg::Region),
248            "vault" => Ok(ConfigArg::Vault),
249            _ => Err("Invalid profile configuration parameter.".to_string()),
250        }
251    }
252}
253
254impl ToString for ConfigArg {
255    fn to_string(&self) -> String {
256        match self {
257            ConfigArg::AdminAPI => "admin-api".to_string(),
258            ConfigArg::AppPassword => "app-password".to_string(),
259            ConfigArg::CloudAPI => "cloud-api".to_string(),
260            ConfigArg::Region => "region".to_string(),
261            ConfigArg::Vault => "vault".to_string(),
262        }
263    }
264}
265
266/// Shows the value of a profile configuration field.
267pub fn config_get(
268    cx: &ProfileContext,
269    ConfigGetArgs { name }: ConfigGetArgs<'_>,
270) -> Result<(), Error> {
271    let profile = cx.get_profile();
272    let value = cx.config_file().get_profile_param(name, &profile)?;
273    cx.output_formatter().output_scalar(value)?;
274    Ok(())
275}
276
277/// Shows all the possible field and its values in the profile configuration.
278pub fn config_list(cx: &ProfileContext) -> Result<(), Error> {
279    let profile_params = cx.config_file().list_profile_params(&cx.get_profile())?;
280    let output = cx.output_formatter();
281
282    // Structure to format the output. The name of the field equals the column name.
283    #[derive(Serialize, Deserialize, Tabled)]
284    struct ProfileParam<'a> {
285        #[tabled(rename = "Name")]
286        name: &'a str,
287        #[tabled(rename = "Value")]
288        value: OptionalStr<'a>,
289    }
290
291    output.output_table(profile_params.iter().map(|(name, value)| ProfileParam {
292        name,
293        value: OptionalStr(value.as_deref()),
294    }))?;
295    Ok(())
296}
297
298/// Represents the args to set the value of a profile configuration field.
299pub struct ConfigSetArgs<'a> {
300    /// Represents the name of the field to set the value.
301    pub name: &'a str,
302    /// Represents the new value of the field.
303    pub value: &'a str,
304}
305
306/// Sets a value in the profile configuration.
307pub async fn config_set(
308    cx: &ProfileContext,
309    ConfigSetArgs { name, value }: ConfigSetArgs<'_>,
310) -> Result<(), Error> {
311    cx.config_file()
312        .set_profile_param(&cx.get_profile(), name, Some(value))
313        .await
314}
315
316/// Represents the args to remove the value from a profile configuration field.
317pub struct ConfigRemoveArgs<'a> {
318    /// Represents the name of the field to remove.
319    pub name: &'a str,
320}
321
322/// Removes the value from a profile configuration field.
323pub async fn config_remove(
324    cx: &ProfileContext,
325    ConfigRemoveArgs { name }: ConfigRemoveArgs<'_>,
326) -> Result<(), Error> {
327    cx.config_file()
328        .set_profile_param(&cx.get_profile(), name, None)
329        .await
330}