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
83 changes: 83 additions & 0 deletions crates/rattler_conda_types/src/channel_notice.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
//! Data types for [CEP-6] channel notices.
//!
//! [CEP-6]: https://github.com/conda/ceps/blob/main/cep-0006.md

use jiff::Timestamp;
use serde::{Deserialize, Serialize};

/// The importance of a channel notice.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum ChannelNoticeLevel {
/// General information.
#[default]
Info,
/// A warning that may require action from the user.
Warning,
/// A critical notice, such as a security advisory.
Critical,
}

/// A notice published by a channel in its `notices.json` file.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
pub struct ChannelNotice {
/// A stable identifier for the notice.
pub id: String,
/// The message to display to users.
pub message: String,
/// The importance of the notice.
#[serde(default)]
pub level: ChannelNoticeLevel,
/// When the notice was created.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created_at: Option<Timestamp>,
/// When the notice expires.
///
/// `expired_at` is accepted as an alias for compatibility with older
/// conda implementations, but CEP-6 calls this field `expires_at`.
#[serde(default, alias = "expired_at", skip_serializing_if = "Option::is_none")]
pub expires_at: Option<Timestamp>,
/// The requested interval between displaying the notice.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub interval: Option<u64>,
}

/// The contents of a CEP-6 `notices.json` file.
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
pub struct ChannelNotices {
/// Notices published by the channel.
#[serde(default)]
pub notices: Vec<ChannelNotice>,
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn parse_cep6_notices() {
let notices: ChannelNotices = serde_json::from_str(
r#"{
"notices": [{
"id": "security-1",
"message": "Please update demo",
"level": "critical",
"created_at": "2025-01-01T12:00:00+00:00",
"expires_at": "2025-02-01T12:00:00+00:00",
"interval": 24
}]
}"#,
)
.unwrap();

assert_eq!(notices.notices.len(), 1);
assert_eq!(notices.notices[0].level, ChannelNoticeLevel::Critical);
assert!(notices.notices[0].expires_at.is_some());
assert_eq!(notices.notices[0].interval, Some(24));
assert!(
serde_json::to_value(&notices).unwrap()["notices"][0]
.get("expires_at")
.is_some()
);
}
}
2 changes: 2 additions & 0 deletions crates/rattler_conda_types/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ pub mod backup;
mod build_spec;
mod channel;
mod channel_data;
mod channel_notice;
mod explicit_environment_spec;
mod flags;
pub mod match_spec;
Expand Down Expand Up @@ -38,6 +39,7 @@ use std::path::{Path, PathBuf};
pub use build_spec::{BuildNumber, BuildNumberSpec, OrdOperator, ParseBuildNumberSpecError};
pub use channel::{Channel, ChannelConfig, ChannelUrl, NamedChannelOrUrl, ParseChannelError};
pub use channel_data::{ChannelData, ChannelDataPackage};
pub use channel_notice::{ChannelNotice, ChannelNoticeLevel, ChannelNotices};
pub use environment_yaml::{EnvironmentYaml, MatchSpecOrSubSection};
pub use explicit_environment_spec::{
ExplicitEnvironmentEntry, ExplicitEnvironmentSpec, PackageArchiveHash,
Expand Down
40 changes: 39 additions & 1 deletion crates/rattler_config/src/config/index.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,21 @@
//! [index-config."s3://my-bucket/staging".channel-relations]
//! base = "../conda-forge"
//!
//! [[index-config."s3://my-bucket/staging".notices]]
//! id = "security-1"
//! message = "Please update the affected package"
//! level = "critical"
//! created_at = "2025-01-01T12:00:00Z"
//! expires_at = "2025-02-01T12:00:00Z"
//!
//! [index-config."/srv/conda/internal"]
//! base-url = "../packages/"
//! ```
use std::{collections::HashMap, str::FromStr};

use rattler_conda_types::{ChannelRelations, RepodataRevision, RepodataRevisionInfo};
use rattler_conda_types::{
ChannelNotice, ChannelRelations, RepodataRevision, RepodataRevisionInfo,
};
use serde::{Deserialize, Deserializer, Serialize, de::Error as DeError};

use crate::config::{Config, MergeError, ValidationError};
Expand Down Expand Up @@ -92,6 +101,13 @@ pub struct IndexChannelConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_url: Option<String>,

/// CEP-6 notices to write to the channel's root `notices.json`.
///
/// When unset, an existing notices file is left untouched. An empty list
/// explicitly writes a notices file with no notices.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub notices: Option<Vec<ChannelNotice>>,

/// `info.channel_relations` value written to generated repodata.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub channel_relations: Option<ChannelRelations>,
Expand All @@ -105,6 +121,7 @@ impl IndexChannelConfig {
&& self.repodata_revisions.is_none()
&& self.package_revision_assignment.is_none()
&& self.base_url.is_none()
&& self.notices.is_none()
&& self.channel_relations.is_none()
}

Expand All @@ -120,6 +137,7 @@ impl IndexChannelConfig {
.package_revision_assignment
.or(self.package_revision_assignment),
base_url: other.base_url.or_else(|| self.base_url.clone()),
notices: other.notices.or_else(|| self.notices.clone()),
channel_relations: other
.channel_relations
.or_else(|| self.channel_relations.clone()),
Expand Down Expand Up @@ -307,6 +325,26 @@ base = "../conda-forge"
assert!(cfg.per_channel.is_empty());
}

#[test]
fn parses_channel_notices() {
let cfg = parse(
r#"
[[notices]]
id = "security-1"
message = "Please update demo"
level = "critical"
created_at = "2025-01-01T12:00:00Z"
expires_at = "2025-02-01T12:00:00Z"
interval = 24
"#,
);

let notices = cfg.default.notices.unwrap();
assert_eq!(notices.len(), 1);
assert_eq!(notices[0].id, "security-1");
assert_eq!(notices[0].interval, Some(24));
}

#[test]
fn parses_per_channel_entries() {
let cfg = parse(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ expression: "(unused, normalized(config))"
repodata_revisions: None,
package_revision_assignment: None,
base_url: None,
notices: None,
channel_relations: None,
},
per_channel: {},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,7 @@ expression: "(unused, normalized(config))"
repodata_revisions: None,
package_revision_assignment: None,
base_url: None,
notices: None,
channel_relations: None,
},
per_channel: {
Expand All @@ -228,6 +229,7 @@ expression: "(unused, normalized(config))"
base_url: Some(
"../packages/",
),
notices: None,
channel_relations: None,
},
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ expression: "(unused, normalized(config))"
repodata_revisions: None,
package_revision_assignment: None,
base_url: None,
notices: None,
channel_relations: None,
},
per_channel: {},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ expression: "(unused, normalized(config))"
repodata_revisions: None,
package_revision_assignment: None,
base_url: None,
notices: None,
channel_relations: None,
},
per_channel: {},
Expand Down
63 changes: 57 additions & 6 deletions crates/rattler_index/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,9 @@ use opendal::layers::RetryLayer;
use opendal::services::S3Config;
use opendal::{Configurator, Operator, services::FsConfig};
use rattler_conda_types::{
ChannelInfo, ChannelRelations, PackageRecord, PatchInstructions, Platform, RepoData, Shard,
ShardedRepodata, ShardedSubdirInfo, UrlOrPath, V3Packages, WhlPackageRecord,
ChannelInfo, ChannelNotice, ChannelNotices, ChannelRelations, PackageRecord, PatchInstructions,
Platform, RepoData, Shard, ShardedRepodata, ShardedSubdirInfo, UrlOrPath, V3Packages,
WhlPackageRecord,
package::{
CondaArchiveType, DistArchiveIdentifier, DistArchiveType, IndexJson, PackageFile,
RunExportsJson, WheelArchiveType,
Expand All @@ -57,17 +58,22 @@ use tracing::Instrument;
#[cfg(feature = "s3")]
use url::Url;

/// Channel metadata written into generated repodata.
/// Metadata published while indexing a channel.
///
/// Distinct from [`IndexChannelConfig`] — that type describes the indexer's
/// behavior knobs (zst, shards, revisions, ...). `ChannelMetadata` is just the
/// data that ends up under `info` in the generated repodata.
/// Distinct from [`IndexChannelConfig`] — that type also describes indexer
/// behavior knobs (zst, shards, revisions, ...). This type contains metadata
/// written to generated repodata and the channel-root `notices.json` file.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ChannelMetadata {
/// The `info.base_url` value written to `repodata.json`.
pub base_url: Option<String>,
/// The `info.channel_relations` value written to `repodata.json`.
pub channel_relations: Option<ChannelRelations>,
/// CEP-6 notices to write to the channel root.
///
/// `None` leaves an existing `notices.json` untouched, while `Some` writes
/// the supplied notices (including an explicitly empty list).
pub notices: Option<Vec<ChannelNotice>>,
}

impl ChannelMetadata {
Expand All @@ -79,6 +85,7 @@ impl ChannelMetadata {
.channel_relations
.clone()
.filter(|relations| !relations.is_empty()),
notices: config.notices.clone(),
}
}
}
Expand Down Expand Up @@ -134,6 +141,7 @@ pub struct IndexStats {
const REPODATA_FROM_PACKAGES: &str = "repodata_from_packages.json";
const REPODATA: &str = "repodata.json";
const REPODATA_SHARDS: &str = "repodata_shards.msgpack.zst";
const CHANNEL_NOTICES: &str = "notices.json";
const ZSTD_REPODATA_COMPRESSION_LEVEL: i32 = 19;
const CACHE_CONTROL_IMMUTABLE: &str = "public, max-age=31536000, immutable";
const CACHE_CONTROL_REPODATA: &str = "public, max-age=300"; // 5 minutes
Expand Down Expand Up @@ -1577,6 +1585,11 @@ pub async fn index_with_channel_metadata(
precondition_checks: PreconditionChecks,
channel_metadata: ChannelMetadata,
) -> anyhow::Result<IndexStats> {
let notices_metadata = if channel_metadata.notices.is_some() {
Some(RepodataFileMetadata::new(&op, CHANNEL_NOTICES, precondition_checks).await?)
} else {
None
};
let entries = op.list_with("").await?;

// If requested `target_platform` subdir does not exist, we create it.
Expand Down Expand Up @@ -1681,9 +1694,47 @@ pub async fn index_with_channel_metadata(
}
}
}

// Publish notices only after all repodata updates succeeded, so a failed
// indexing operation cannot partially update channel-level messaging.
if let (Some(notices), Some(metadata)) = (&channel_metadata.notices, notices_metadata.as_ref())
{
write_channel_notices_with_metadata(&op, notices, metadata).await?;
}

Ok(stats)
}

/// Write CEP-6 channel notices to the channel root.
pub async fn write_channel_notices(op: &Operator, notices: &[ChannelNotice]) -> anyhow::Result<()> {
let metadata =
RepodataFileMetadata::new(op, CHANNEL_NOTICES, PreconditionChecks::Disabled).await?;
write_channel_notices_with_metadata(op, notices, &metadata).await
}

async fn write_channel_notices_with_metadata(
op: &Operator,
notices: &[ChannelNotice],
metadata: &RepodataFileMetadata,
) -> anyhow::Result<()> {
let bytes = serde_json::to_vec_pretty(&ChannelNotices {
notices: notices.to_vec(),
})?;
let mut writer = op
.write_with(CHANNEL_NOTICES, bytes)
.content_type("application/json")
.cache_control(CACHE_CONTROL_REPODATA);
if metadata.precondition_checks.is_enabled() {
if let Some(etag) = &metadata.etag {
writer = writer.if_match(etag);
} else if !metadata.file_existed {
writer = writer.if_not_exists(true);
}
}
writer.await?;
Ok(())
}

/// Ensures that a channel has a valid `noarch/repodata.json` file.
///
/// If `noarch/repodata.json` doesn't exist, creates an empty one.
Expand Down
17 changes: 16 additions & 1 deletion crates/rattler_index/tests/integration/basic_indexing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ use std::{
};

use rattler_conda_types::{
ChannelRelations, Platform, ShardedRepodata, compression_level::CompressionLevel,
ChannelNotice, ChannelNoticeLevel, ChannelRelations, Platform, ShardedRepodata,
compression_level::CompressionLevel,
};
use rattler_index::{
ChannelMetadata, IndexFsConfig, PackageRevisionAssignment, RepodataRevision,
Expand Down Expand Up @@ -388,6 +389,14 @@ async fn test_index_writes_channel_metadata() {
base: Some("../conda-forge".to_string()),
overrides: Some("../fallback".to_string()),
}),
notices: Some(vec![ChannelNotice {
id: "security-1".to_string(),
message: "Please update demo".to_string(),
level: ChannelNoticeLevel::Critical,
created_at: None,
expires_at: None,
interval: Some(24),
}]),
};

index_fs_with_channel_metadata(
Expand Down Expand Up @@ -458,6 +467,12 @@ async fn test_index_writes_channel_metadata() {
shard_index.info.repodata_revisions[&RepodataRevision::V3].n_packages,
Some(0)
);

let notices_json: Value =
serde_json::from_reader(File::open(temp_dir.path().join("notices.json")).unwrap()).unwrap();
assert_eq!(notices_json["notices"][0]["id"], "security-1");
assert_eq!(notices_json["notices"][0]["level"], "critical");
assert_eq!(notices_json["notices"][0]["interval"], 24);
}

/// Regression test: sharded repodata must be reproducible.
Expand Down
2 changes: 2 additions & 0 deletions crates/rattler_repodata_gateway/src/gateway/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,8 @@ impl GatewayBuilder {
subdirs: CoalescedMap::new(),
client,
channel_config: self.channel_config,
notices: dashmap::DashMap::new(),
notice_fetch_locks: dashmap::DashMap::new(),
#[cfg(not(target_arch = "wasm32"))]
cache,
#[cfg(not(target_arch = "wasm32"))]
Expand Down
Loading
Loading