Skip to main content

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        .unwrap_or_else(|| config_file.profile().to_string());
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    // Fail before logging in, so that an unwritable configuration file doesn't
174    // leave behind an app password that no profile records.
175    config_file.ensure_writable().await?;
176
177    let app_password = match no_browser {
178        true => init_without_browser(admin_endpoint.clone()).await?,
179        false => init_with_browser(cloud_endpoint.clone()).await?,
180    };
181
182    let new_profile = TomlProfile {
183        app_password: Some(app_password.to_string()),
184        vault: None,
185        region: None,
186        admin_endpoint: admin_endpoint.map(|url| url.to_string()),
187        cloud_endpoint: cloud_endpoint.map(|url| url.to_string()),
188    };
189
190    config_file.add_profile(profile, new_profile).await?;
191
192    Ok(())
193}
194
195/// List all the possible config values for the profile.
196pub fn list(cx: &Context) -> Result<(), Error> {
197    if let Some(profiles) = cx.config_file().profiles() {
198        let output = cx.output_formatter();
199
200        // Output formatting structure.
201        #[derive(Clone, Serialize, Deserialize, Tabled)]
202        struct ProfileName<'a> {
203            #[tabled(rename = "Name")]
204            name: &'a str,
205        }
206        output.output_table(profiles.keys().map(|name| ProfileName { name }))?;
207    }
208
209    Ok(())
210}
211
212/// Removes the profile from the configuration file.
213pub async fn remove(cx: &Context) -> Result<(), Error> {
214    cx.config_file()
215        .remove_profile(
216            &cx.get_global_profile()
217                .unwrap_or_else(|| cx.config_file().profile().to_string()),
218        )
219        .await
220}
221
222/// Represents the args to retrieve a profile configuration value.
223pub struct ConfigGetArgs<'a> {
224    /// Represents the configuration field name to retrieve the value.
225    pub name: &'a str,
226}
227
228/// Represents the possible fields in a profile configuration.
229#[derive(Clone, Debug)]
230pub enum ConfigArg {
231    /// Represents `[TomlProfile::admin_endpoint]`
232    AdminAPI,
233    /// Represents `[TomlProfile::app_password]`
234    AppPassword,
235    /// Represents `[TomlProfile::cloud_endpoint]`
236    CloudAPI,
237    /// Represents `[TomlProfile::region]`
238    Region,
239    /// Represents `[TomlProfile::vault]`
240    Vault,
241}
242
243impl FromStr for ConfigArg {
244    type Err = String;
245
246    fn from_str(s: &str) -> Result<Self, Self::Err> {
247        match s.to_lowercase().as_str() {
248            "admin-api" => Ok(ConfigArg::AdminAPI),
249            "app-password" => Ok(ConfigArg::AppPassword),
250            "cloud-api" => Ok(ConfigArg::CloudAPI),
251            "region" => Ok(ConfigArg::Region),
252            "vault" => Ok(ConfigArg::Vault),
253            _ => Err("Invalid profile configuration parameter.".to_string()),
254        }
255    }
256}
257
258impl ToString for ConfigArg {
259    fn to_string(&self) -> String {
260        match self {
261            ConfigArg::AdminAPI => "admin-api".to_string(),
262            ConfigArg::AppPassword => "app-password".to_string(),
263            ConfigArg::CloudAPI => "cloud-api".to_string(),
264            ConfigArg::Region => "region".to_string(),
265            ConfigArg::Vault => "vault".to_string(),
266        }
267    }
268}
269
270/// Shows the value of a profile configuration field.
271pub fn config_get(
272    cx: &ProfileContext,
273    ConfigGetArgs { name }: ConfigGetArgs<'_>,
274) -> Result<(), Error> {
275    let profile = cx.get_profile();
276    let value = cx.config_file().get_profile_param(name, &profile)?;
277    cx.output_formatter().output_scalar(value)?;
278    Ok(())
279}
280
281/// Shows all the possible field and its values in the profile configuration.
282pub fn config_list(cx: &ProfileContext) -> Result<(), Error> {
283    let profile_params = cx.config_file().list_profile_params(&cx.get_profile())?;
284    let output = cx.output_formatter();
285
286    // Structure to format the output. The name of the field equals the column name.
287    #[derive(Serialize, Deserialize, Tabled)]
288    struct ProfileParam<'a> {
289        #[tabled(rename = "Name")]
290        name: &'a str,
291        #[tabled(rename = "Value")]
292        value: OptionalStr<'a>,
293    }
294
295    output.output_table(profile_params.iter().map(|(name, value)| ProfileParam {
296        name,
297        value: OptionalStr(value.as_deref()),
298    }))?;
299    Ok(())
300}
301
302/// Represents the args to set the value of a profile configuration field.
303pub struct ConfigSetArgs<'a> {
304    /// Represents the name of the field to set the value.
305    pub name: &'a str,
306    /// Represents the new value of the field.
307    pub value: &'a str,
308}
309
310/// Sets a value in the profile configuration.
311pub async fn config_set(
312    cx: &ProfileContext,
313    ConfigSetArgs { name, value }: ConfigSetArgs<'_>,
314) -> Result<(), Error> {
315    cx.config_file()
316        .set_profile_param(&cx.get_profile(), name, Some(value))
317        .await
318}
319
320/// Represents the args to remove the value from a profile configuration field.
321pub struct ConfigRemoveArgs<'a> {
322    /// Represents the name of the field to remove.
323    pub name: &'a str,
324}
325
326/// Removes the value from a profile configuration field.
327pub async fn config_remove(
328    cx: &ProfileContext,
329    ConfigRemoveArgs { name }: ConfigRemoveArgs<'_>,
330) -> Result<(), Error> {
331    cx.config_file()
332        .set_profile_param(&cx.get_profile(), name, None)
333        .await
334}