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
5 changes: 0 additions & 5 deletions .changeset/eso-manifests-parity.md

This file was deleted.

5 changes: 0 additions & 5 deletions .changeset/eso-refresher-parity.md

This file was deleted.

7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# @smooai/library-template

## 6.7.0

### Minor Changes

- ad2c77f: SMOODEV-1526: Port the ESO manifest generator (`buildClusterSecretStore` + `buildExternalSecret`) to the Go, Python, Rust, and C# SDKs for language parity with the TypeScript reference. Each emits the same ClusterSecretStore (webhook → real api.smoo.ai config-values endpoint) and per-workload ExternalSecret (secret-tier config keys → UPPER_SNAKE_CASE env vars, with overrides + duplicate guard), using each language's native snakecase util. Epic SMOODEV-1522.
- ad2c77f: SMOODEV-1526: Port the ESO bearer-token refresher core (the refresh algorithm + `SecretWriter` abstraction) to the Go, Python, Rust, and C# SDKs for parity with the TypeScript reference. Each mirrors the same behavior — invalidate-then-mint each cycle so the bootstrap Secret always holds a near-full-TTL token, fail-loud initial write, non-fatal loop-tick retries — driven by the language's own TokenProvider and unit-tested with a fake writer (no live cluster). The k8s-backed writer is intentionally an optional adapter so base SDK consumers don't pull a heavy k8s client; the TypeScript sidecar remains the canonical deployable. Epic SMOODEV-1522.

## 6.6.0

### Minor Changes
Expand Down
2 changes: 1 addition & 1 deletion dotnet/src/SmooAI.Config/SmooAI.Config.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

<PropertyGroup>
<PackageId>SmooAI.Config</PackageId>
<Version>6.6.0</Version>
<Version>6.7.0</Version>
<Authors>SmooAI</Authors>
<Company>SmooAI</Company>
<Product>SmooAI.Config</Product>
Expand Down
2 changes: 1 addition & 1 deletion go/config/version.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package config

// Version is the current version of the smooai-config Go package.
const Version = "6.6.0"
const Version = "6.7.0"
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@smooai/config",
"version": "6.6.0",
"version": "6.7.0",
"description": "Type-safe multi-language configuration management with schema validation, three-tier config (public, secret, feature flags), and runtime client support for TypeScript, Python, Rust, and Go.",
"homepage": "https://github.com/SmooAI/config#readme",
"bugs": {
Expand Down
2 changes: 1 addition & 1 deletion python/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "smooai-config"
version = "6.6.0"
version = "6.7.0"
description = "Smoo AI Configuration Management Library"
requires-python = ">=3.13"
dependencies = ["pydantic>=2.0.0", "httpx>=0.27.0", "cryptography>=42.0.0"]
Expand Down
2 changes: 1 addition & 1 deletion python/uv.lock

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

2 changes: 1 addition & 1 deletion rust/config/Cargo.lock

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

2 changes: 1 addition & 1 deletion rust/config/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "smooai-config"
version = "6.6.0"
version = "6.7.0"
edition = "2021"
description = "Type-safe three-tier configuration management (public, secret, feature flags) with schema validation and a runtime client for the Smoo AI config platform."
license = "MIT"
Expand Down
41 changes: 13 additions & 28 deletions rust/config/src/eso_manifests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,18 +63,14 @@ pub struct ClusterSecretStoreOptions {
///
/// org + environment are baked into the URL because ESO's webhook only templates
/// `{{ .remoteRef.key }}` per-secret — so a store is scoped to one (org, env).
pub fn build_cluster_secret_store(
opts: &ClusterSecretStoreOptions,
) -> Result<Value, SmooaiConfigError> {
pub fn build_cluster_secret_store(opts: &ClusterSecretStoreOptions) -> Result<Value, SmooaiConfigError> {
if opts.api_url.is_empty() {
return Err(SmooaiConfigError::new(
"build_cluster_secret_store: api_url is required",
));
}
if opts.org_id.is_empty() {
return Err(SmooaiConfigError::new(
"build_cluster_secret_store: org_id is required",
));
return Err(SmooaiConfigError::new("build_cluster_secret_store: org_id is required"));
}
if opts.environment.is_empty() {
return Err(SmooaiConfigError::new(
Expand Down Expand Up @@ -153,14 +149,9 @@ impl SecretMapping {
/// Returns `(config_key, env_var)`.
pub fn resolve_secret_mapping(m: &SecretMapping) -> Result<(String, String), SmooaiConfigError> {
if m.config_key.is_empty() {
return Err(SmooaiConfigError::new(
"resolve_secret_mapping: config_key is required",
));
return Err(SmooaiConfigError::new("resolve_secret_mapping: config_key is required"));
}
let env_var = m
.env_var
.clone()
.unwrap_or_else(|| camel_to_upper_snake(&m.config_key));
let env_var = m.env_var.clone().unwrap_or_else(|| camel_to_upper_snake(&m.config_key));
Ok((m.config_key.clone(), env_var))
}

Expand All @@ -181,14 +172,10 @@ pub struct ExternalSecretOptions {
/// @smooai/config key).
pub fn build_external_secret(opts: &ExternalSecretOptions) -> Result<Value, SmooaiConfigError> {
if opts.name.is_empty() {
return Err(SmooaiConfigError::new(
"build_external_secret: name is required",
));
return Err(SmooaiConfigError::new("build_external_secret: name is required"));
}
if opts.namespace.is_empty() {
return Err(SmooaiConfigError::new(
"build_external_secret: namespace is required",
));
return Err(SmooaiConfigError::new("build_external_secret: namespace is required"));
}
if opts.secrets.is_empty() {
return Err(SmooaiConfigError::new(
Expand All @@ -208,10 +195,7 @@ pub fn build_external_secret(opts: &ExternalSecretOptions) -> Result<Value, Smoo
data.push(json!({ "secretKey": env_var, "remoteRef": { "key": config_key } }));
}

let target_name = opts
.target_secret_name
.clone()
.unwrap_or_else(|| opts.name.clone());
let target_name = opts.target_secret_name.clone().unwrap_or_else(|| opts.name.clone());
let store_name = opts
.cluster_secret_store_name
.clone()
Expand Down Expand Up @@ -246,9 +230,7 @@ fn encode_query_component(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char)
}
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => out.push(b as char),
b' ' => out.push_str("%20"),
_ => {
out.push('%');
Expand Down Expand Up @@ -311,8 +293,11 @@ mod tests {
fn resolve_mapping_defaults_and_override() {
let (_, env) = resolve_secret_mapping(&SecretMapping::new("mimoApiKey")).unwrap();
assert_eq!(env, "MIMO_API_KEY");
let (_, env2) =
resolve_secret_mapping(&SecretMapping::with_env_var("alibabaModelStudioApiKey", "DASHSCOPE_API_KEY")).unwrap();
let (_, env2) = resolve_secret_mapping(&SecretMapping::with_env_var(
"alibabaModelStudioApiKey",
"DASHSCOPE_API_KEY",
))
.unwrap();
assert_eq!(env2, "DASHSCOPE_API_KEY");
}

Expand Down
33 changes: 24 additions & 9 deletions rust/config/src/eso_refresher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,7 @@ pub const ESO_REFRESHER_DEFAULT_INTERVAL_SECONDS: u64 = 900;
/// Writes the freshly-minted bearer token into the target Secret. Abstracted so
/// the refresh loop is unit-testable without a live cluster.
pub trait SecretWriter {
fn patch_bearer_token(
&self,
token: &str,
) -> impl Future<Output = Result<(), SmooaiConfigError>>;
fn patch_bearer_token(&self, token: &str) -> impl Future<Output = Result<(), SmooaiConfigError>>;
}

/// The slice of `TokenProvider` the refresher needs. The real `TokenProvider`
Expand Down Expand Up @@ -160,31 +157,49 @@ mod tests {

#[tokio::test]
async fn refresh_once_writes_fresh_token() {
let r = EsoRefresher::new(FakeTokenSource::new(&["tok-1"]), RecordingWriter::new(0), Duration::ZERO);
let r = EsoRefresher::new(
FakeTokenSource::new(&["tok-1"]),
RecordingWriter::new(0),
Duration::ZERO,
);
r.refresh_once().await.unwrap();
assert_eq!(*r.token_source.invalidations.borrow(), 1);
assert_eq!(r.secret_writer.written.borrow().clone(), vec!["tok-1".to_string()]);
}

#[tokio::test]
async fn forces_fresh_each_cycle() {
let r = EsoRefresher::new(FakeTokenSource::new(&["tok-1", "tok-2"]), RecordingWriter::new(0), Duration::ZERO);
let r = EsoRefresher::new(
FakeTokenSource::new(&["tok-1", "tok-2"]),
RecordingWriter::new(0),
Duration::ZERO,
);
r.refresh_once().await.unwrap();
r.refresh_once().await.unwrap();
assert_eq!(*r.token_source.calls.borrow(), 2);
assert_eq!(*r.token_source.invalidations.borrow(), 2);
assert_eq!(r.secret_writer.written.borrow().clone(), vec!["tok-1".to_string(), "tok-2".to_string()]);
assert_eq!(
r.secret_writer.written.borrow().clone(),
vec!["tok-1".to_string(), "tok-2".to_string()]
);
}

#[tokio::test]
async fn refresh_once_propagates_write_failure() {
let r = EsoRefresher::new(FakeTokenSource::new(&["tok-1"]), RecordingWriter::new(1), Duration::ZERO);
let r = EsoRefresher::new(
FakeTokenSource::new(&["tok-1"]),
RecordingWriter::new(1),
Duration::ZERO,
);
assert!(r.refresh_once().await.is_err());
}

#[tokio::test]
async fn defaults_interval_when_zero() {
let r = EsoRefresher::new(FakeTokenSource::new(&["t"]), RecordingWriter::new(0), Duration::ZERO);
assert_eq!(r.interval(), Duration::from_secs(ESO_REFRESHER_DEFAULT_INTERVAL_SECONDS));
assert_eq!(
r.interval(),
Duration::from_secs(ESO_REFRESHER_DEFAULT_INTERVAL_SECONDS)
);
}
}
Loading