Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions crates/config/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,6 @@ url = { workspace = true }
tower-api = { workspace = true }
tower-package = { workspace = true }
tower-telemetry = { workspace = true }

[dev-dependencies]
tempfile = { workspace = true }
4 changes: 3 additions & 1 deletion crates/config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ pub use error::Error;
pub use session::{default_tower_url, Session, Team, Token, User};
pub use tower_package::{Parameter, Towerfile};

pub use session::{get_last_version_check_timestamp, set_last_version_check_timestamp};
pub use session::{
claim_notice, get_last_version_check_timestamp, set_last_version_check_timestamp,
};

#[derive(Clone, Serialize, Deserialize)]
pub struct Config {
Expand Down
104 changes: 102 additions & 2 deletions crates/config/src/session.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use chrono::{DateTime, TimeZone, Utc};
use serde::{Deserialize, Serialize};
use std::fs;
use std::fs::{self, OpenOptions};
use std::future::Future;
use std::path::PathBuf;
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
use url::Url;

use crate::error::Error;
Expand Down Expand Up @@ -87,6 +88,43 @@ fn find_or_create_config_dir() -> Result<PathBuf, Error> {
Ok(config_dir)
}

/// Atomically claims a persistent, user-level CLI notice.
///
/// Returns `true` only for the first process to claim this notice ID. Notice
/// IDs are internal constants and may contain ASCII letters, digits, `-`, and
/// `_` only.
pub fn claim_notice(id: &str) -> std::io::Result<bool> {
let config_dir = find_or_create_config_dir()
.map_err(|err| std::io::Error::other(format!("finding Tower config directory: {err}")))?;
claim_notice_at(&config_dir, id)
}

fn claim_notice_at(base_dir: &Path, id: &str) -> std::io::Result<bool> {
if id.is_empty()
|| !id
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
{
return Err(std::io::Error::new(
ErrorKind::InvalidInput,
"invalid notice ID",
));
}

let notices_dir = base_dir.join("notices");
fs::create_dir_all(&notices_dir)?;

match OpenOptions::new()
.write(true)
.create_new(true)
.open(notices_dir.join(id))
{
Ok(_) => Ok(true),
Err(err) if err.kind() == ErrorKind::AlreadyExists => Ok(false),
Err(err) => Err(err),
}
}

pub fn get_last_version_check_timestamp() -> DateTime<Utc> {
let default: DateTime<Utc> = Utc.timestamp_opt(0, 0).unwrap();

Expand Down Expand Up @@ -346,3 +384,65 @@ where
{
tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(future))
}

#[cfg(test)]
mod tests {
use std::{fs, sync::Arc, thread};

use tempfile::TempDir;

use super::claim_notice_at;

#[test]
fn notice_can_only_be_claimed_once() {
let temp_dir = TempDir::new().expect("temporary directory should be created");

assert!(claim_notice_at(temp_dir.path(), "storage-beta-v1").unwrap());
assert!(!claim_notice_at(temp_dir.path(), "storage-beta-v1").unwrap());
}

#[test]
fn notice_generations_are_independent() {
let temp_dir = TempDir::new().expect("temporary directory should be created");

assert!(claim_notice_at(temp_dir.path(), "storage-beta-v1").unwrap());
assert!(claim_notice_at(temp_dir.path(), "storage-beta-v2").unwrap());
}

#[test]
fn concurrent_notice_claims_have_one_winner() {
let temp_dir = TempDir::new().expect("temporary directory should be created");
let base_dir = Arc::new(temp_dir.path().to_path_buf());
let handles = (0..8)
.map(|_| {
let base_dir = Arc::clone(&base_dir);
thread::spawn(move || claim_notice_at(&base_dir, "storage-beta-v1").unwrap())
})
.collect::<Vec<_>>();

let winners = handles
.into_iter()
.map(|handle| handle.join().expect("claim thread should finish"))
.filter(|claimed| *claimed)
.count();

assert_eq!(winners, 1);
}

#[test]
fn notice_claim_rejects_invalid_ids() {
let temp_dir = TempDir::new().expect("temporary directory should be created");

assert!(claim_notice_at(temp_dir.path(), "../outside").is_err());
assert!(claim_notice_at(temp_dir.path(), "").is_err());
}

#[test]
fn notice_claim_reports_unavailable_base_directory() {
let temp_dir = TempDir::new().expect("temporary directory should be created");
let base_dir = temp_dir.path().join("config-file");
fs::write(&base_dir, "not a directory").expect("fixture should be written");

assert!(claim_notice_at(&base_dir, "storage-beta-v1").is_err());
}
}
103 changes: 103 additions & 0 deletions crates/tower-cmd/src/beta.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
use std::io::{self, IsTerminal};

use tower_telemetry::debug;

use crate::output::{self, OutputMode};

pub(crate) struct BetaFeature {
id: &'static str,
message: &'static str,
docs_url: Option<&'static str>,
}

impl BetaFeature {
pub fn short_about(&self, description: &str) -> String {
format!("{description} [beta]")
}

pub fn notice(&self) -> String {
match self.docs_url {
Some(url) => format!("{} Learn more: {url}", self.message),
None => self.message.to_string(),
}
}
}

pub(crate) const STORAGE_BETA_MESSAGE: &str = "Tower Storage is in beta. Core functionality is stable, but some featues and interfaces might change before general availability.";

pub(crate) const STORAGE: BetaFeature = BetaFeature {
id: "storage-beta-v1",
message: STORAGE_BETA_MESSAGE,
docs_url: None,
};
Comment thread
konstantinoscs marked this conversation as resolved.

pub(crate) fn notify_once(feature: &BetaFeature) {
let output_mode = output::get_output_mode();
let stdout_is_terminal = io::stdout().is_terminal();
let stderr_is_terminal = io::stderr().is_terminal();

if !should_notify(output_mode, stdout_is_terminal, stderr_is_terminal) {
return;
}

match config::claim_notice(feature.id) {
Ok(true) => output::notice_to_stderr("Beta:", &feature.notice()),
Ok(false) => {}
Err(err) => debug!("Failed to persist CLI notice {}: {}", feature.id, err),
}
}

fn should_notify(
output_mode: OutputMode,
stdout_is_terminal: bool,
stderr_is_terminal: bool,
) -> bool {
output_mode.is_normal() && stdout_is_terminal && stderr_is_terminal
}
Comment thread
konstantinoscs marked this conversation as resolved.

#[cfg(test)]
mod tests {
use super::{should_notify, BetaFeature, STORAGE, STORAGE_BETA_MESSAGE};
use crate::output::OutputMode;

#[test]
fn short_about_has_one_beta_suffix() {
let about = STORAGE.short_about("Use Tower Storage");

assert_eq!(about, "Use Tower Storage [beta]");
assert_eq!(about.matches("[beta]").count(), 1);
}

#[test]
fn notice_omits_docs_sentence_without_a_url() {
let notice = STORAGE.notice();

assert_eq!(notice, STORAGE_BETA_MESSAGE);
assert!(!notice.contains("Learn more:"));
}

#[test]
fn notice_includes_docs_url_when_configured() {
let feature = BetaFeature {
id: "example-beta-v1",
message: "Example is in beta. Its interface may change.",
docs_url: Some("https://example.com/beta"),
};

assert_eq!(
feature.notice(),
"Example is in beta. Its interface may change. Learn more: https://example.com/beta"
);
}

#[test]
fn notice_requires_normal_output_and_two_terminals() {
assert!(should_notify(OutputMode::Normal, true, true));
assert!(!should_notify(OutputMode::Normal, false, true));
assert!(!should_notify(OutputMode::Normal, true, false));
assert!(!should_notify(OutputMode::Normal, false, false));
assert!(!should_notify(OutputMode::Json, true, true));
assert!(!should_notify(OutputMode::McpStdio, true, true));
assert!(!should_notify(OutputMode::McpStreaming, true, true));
}
}
70 changes: 65 additions & 5 deletions crates/tower-cmd/src/catalogs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,17 @@ use tower_api::models::{
vend_catalog_credentials_body, CatalogCredentials, DescribeCatalogResponse,
};

use crate::{api, output, util::cmd};
use crate::{api, beta, output, util::cmd};

const STORAGE_CATALOG_TYPE: &str = "tower-catalog";

pub fn catalogs_cmd() -> Command {
Command::new("catalogs")
.about("Interact with the catalogs in your Tower account")
.about(format!(
"Interact with the catalogs in your Tower account (includes {})",
beta::STORAGE.short_about("Storage")
))
.after_help(beta::STORAGE.notice())
.arg_required_else_help(true)
.subcommand(
Command::new("list")
Expand Down Expand Up @@ -41,7 +45,7 @@ pub fn catalogs_cmd() -> Command {
.arg(
Arg::new("storage")
.long("storage")
.help("List Tower-managed storage catalogs")
.help(beta::STORAGE.short_about("List Tower-managed storage catalogs"))
.conflicts_with("type")
.action(ArgAction::SetTrue),
)
Expand Down Expand Up @@ -107,7 +111,10 @@ pub fn catalogs_cmd() -> Command {
.help("Print the vended OAuth token in normal output")
.action(ArgAction::SetTrue),
)
.about("Vend short-lived catalog credentials for external tools"),
.about(
beta::STORAGE
.short_about("Vend short-lived catalog credentials for external tools"),
),
)
}

Expand All @@ -120,6 +127,10 @@ pub async fn do_list(config: Config, args: &ArgMatches) {
args.get_one::<String>("type").map(String::as_str)
};

if is_storage_catalog_type(catalog_type) {
beta::notify_once(&beta::STORAGE);
}

let catalogs = output::with_spinner(
"Listing catalogs",
api::list_catalogs(&config, &env, all, catalog_type),
Expand All @@ -144,6 +155,8 @@ pub async fn do_list(config: Config, args: &ArgMatches) {
}

pub async fn do_credentials(config: Config, args: &ArgMatches) {
beta::notify_once(&beta::STORAGE);

let name = args
.get_one::<String>("catalog_name")
.expect("catalog_name is required");
Expand Down Expand Up @@ -184,13 +197,20 @@ pub async fn do_show(config: Config, args: &ArgMatches) {

match api::describe_catalog(&config, name, &env).await {
Ok(response) => {
if is_storage_catalog_type(Some(&response.catalog.r#type)) {
beta::notify_once(&beta::STORAGE);
}
let human = catalog_details_text(&response);
output::text(&human, &response);
}
Err(err) => output::tower_error_and_die(err, "Fetching catalog details failed"),
}
}

fn is_storage_catalog_type(catalog_type: Option<&str>) -> bool {
catalog_type == Some(STORAGE_CATALOG_TYPE)
}

fn parse_mode(mode: &str) -> vend_catalog_credentials_body::Mode {
match mode {
"read-write" => vend_catalog_credentials_body::Mode::ReadWrite,
Expand Down Expand Up @@ -420,7 +440,9 @@ fn snippets(

#[cfg(test)]
mod tests {
use super::{catalogs_cmd, parse_mode, snippets, token_export_command};
use super::{
catalogs_cmd, is_storage_catalog_type, parse_mode, snippets, token_export_command,
};
use tower_api::models::{vend_catalog_credentials_body, CatalogCredentials};

#[test]
Expand Down Expand Up @@ -495,6 +517,44 @@ mod tests {
assert!(result.is_err());
}

#[test]
fn storage_catalog_type_detection_is_scoped() {
assert!(is_storage_catalog_type(Some("tower-catalog")));
assert!(!is_storage_catalog_type(Some("snowflake-open-catalog")));
assert!(!is_storage_catalog_type(None));
}

#[test]
fn catalog_help_marks_storage_beta_in_short_and_long_help() {
let short_help = catalogs_cmd().render_help().to_string();
let long_help = catalogs_cmd().render_long_help().to_string();

for help in [short_help, long_help] {
assert!(help.contains("includes Storage [beta]"));
assert!(help.contains("Tower Storage is in beta."));
}
}

#[test]
fn storage_specific_command_and_flag_are_marked_beta() {
let mut command = catalogs_cmd();
let credentials_help = command
.find_subcommand_mut("credentials")
.expect("credentials command should exist")
.render_help()
.to_string();
assert!(credentials_help
.contains("Vend short-lived catalog credentials for external tools [beta]"));

let mut command = catalogs_cmd();
let list_help = command
.find_subcommand_mut("list")
.expect("list command should exist")
.render_help()
.to_string();
assert!(list_help.contains("List Tower-managed storage catalogs [beta]"));
}

#[test]
fn show_requires_catalog_name() {
let result = catalogs_cmd().try_get_matches_from(["catalogs", "show"]);
Expand Down
Loading
Loading