251 lines
8.2 KiB
Rust
251 lines
8.2 KiB
Rust
use api::{
|
|
AppState, AuditStore, AuthConfig, ConfigLocks, ConfigRegistry, JobStore, PlacementStore,
|
|
SwarmStore, TenantLocks, config_registry::NatsKvSource,
|
|
};
|
|
use axum::{
|
|
Router,
|
|
body::Body,
|
|
http::{Request, StatusCode, header},
|
|
};
|
|
use jsonwebtoken::{EncodingKey, Header, encode};
|
|
use metrics_exporter_prometheus::PrometheusBuilder;
|
|
use serde::Serialize;
|
|
use std::{path::PathBuf, sync::OnceLock, time::Duration};
|
|
use tower::ServiceExt;
|
|
use uuid::Uuid;
|
|
|
|
fn enabled() -> bool {
|
|
std::env::var("CONTROL_TEST_NATS").ok().as_deref() == Some("1")
|
|
&& std::env::var("CONTROL_TEST_NATS_URL").is_ok()
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct TestClaims {
|
|
sub: String,
|
|
session_id: String,
|
|
permissions: Vec<String>,
|
|
exp: usize,
|
|
}
|
|
|
|
fn make_token(secret: &[u8], perms: &[&str]) -> String {
|
|
let exp = (std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_secs()
|
|
+ 60) as usize;
|
|
encode(
|
|
&Header::default(),
|
|
&TestClaims {
|
|
sub: "user_1".to_string(),
|
|
session_id: "sess_1".to_string(),
|
|
permissions: perms.iter().map(|p| (*p).to_string()).collect(),
|
|
exp,
|
|
},
|
|
&EncodingKey::from_secret(secret),
|
|
)
|
|
.unwrap()
|
|
}
|
|
|
|
static HANDLE: OnceLock<metrics_exporter_prometheus::PrometheusHandle> = OnceLock::new();
|
|
|
|
fn repo_root() -> PathBuf {
|
|
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
|
.parent()
|
|
.and_then(|p| p.parent())
|
|
.expect("api crate should live under repo root")
|
|
.to_path_buf()
|
|
}
|
|
|
|
async fn wait_done(app: Router, job_id: Uuid, token: &str) -> serde_json::Value {
|
|
let start = tokio::time::Instant::now();
|
|
loop {
|
|
let res = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.uri(format!("/admin/v1/jobs/{job_id}"))
|
|
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(res.status(), StatusCode::OK);
|
|
let body = axum::body::to_bytes(res.into_body(), 1024 * 1024)
|
|
.await
|
|
.unwrap();
|
|
let job: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
|
let status = job
|
|
.get("status")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("unknown");
|
|
if status != "pending" && status != "running" {
|
|
return job;
|
|
}
|
|
|
|
if start.elapsed() > Duration::from_secs(2) {
|
|
return job;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(25)).await;
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn config_jobs_with_nats_kv_are_env_gated() {
|
|
if !enabled() {
|
|
eprintln!(
|
|
"skipping: set CONTROL_TEST_NATS=1 and CONTROL_TEST_NATS_URL=nats://... to enable nats config tests"
|
|
);
|
|
return;
|
|
}
|
|
|
|
let nats_url = std::env::var("CONTROL_TEST_NATS_URL").unwrap();
|
|
unsafe {
|
|
std::env::set_var("CONTROL_CONFIG_NATS_URL", &nats_url);
|
|
}
|
|
|
|
let bucket = format!("cloudlysis-test-config-{}", Uuid::new_v4());
|
|
let routing_key = format!("routing/{}", Uuid::new_v4());
|
|
let placement_key = format!("placement/{}", Uuid::new_v4());
|
|
|
|
let routing_src = NatsKvSource::connect(nats_url.clone(), bucket.clone(), routing_key)
|
|
.await
|
|
.expect("connect routing kv");
|
|
let placement_src = NatsKvSource::connect(nats_url.clone(), bucket.clone(), placement_key)
|
|
.await
|
|
.expect("connect placement kv");
|
|
|
|
let config = ConfigRegistry::new(
|
|
Some(std::sync::Arc::new(routing_src)),
|
|
Some(std::sync::Arc::new(placement_src)),
|
|
);
|
|
|
|
let secret = b"test_secret".to_vec();
|
|
let token = make_token(&secret, &["control:write", "control:read"]);
|
|
|
|
let handle = HANDLE
|
|
.get_or_init(|| {
|
|
PrometheusBuilder::new()
|
|
.install_recorder()
|
|
.expect("failed to install prometheus recorder")
|
|
})
|
|
.clone();
|
|
|
|
let app = api::build_app(AppState {
|
|
prometheus: handle,
|
|
auth: AuthConfig {
|
|
hs256_secret: Some(secret),
|
|
},
|
|
jobs: JobStore::default(),
|
|
audit: AuditStore::default(),
|
|
tenant_locks: TenantLocks::default(),
|
|
config_locks: ConfigLocks::default(),
|
|
http: reqwest::Client::new(),
|
|
placement: PlacementStore::new(repo_root().join("config/placement/dev.json")),
|
|
billing: api::billing::BillingStore::new(std::env::temp_dir().join("billing-test.json")),
|
|
billing_provider: std::sync::Arc::new(api::billing::MockProvider),
|
|
billing_enforcement_enabled: false,
|
|
config,
|
|
fleet_services: vec![],
|
|
swarm: SwarmStore::new(repo_root().join("swarm/dev.json")),
|
|
docs: None,
|
|
});
|
|
|
|
let routing_value = serde_json::json!({
|
|
"revision": 1,
|
|
"aggregate_placement": { "t1": "local" },
|
|
"projection_placement": { "t1": "local" },
|
|
"runner_placement": { "t1": "local" },
|
|
"aggregate_shards": { "local": ["http://aggregate:50051"] },
|
|
"projection_shards": { "local": ["http://projection:8080"] },
|
|
"runner_shards": { "local": ["http://runner:8080"] }
|
|
});
|
|
|
|
let apply = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.uri("/admin/v1/jobs/config/apply")
|
|
.method("POST")
|
|
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
|
.header("idempotency-key", format!("k-{}", Uuid::new_v4()))
|
|
.header(header::CONTENT_TYPE, "application/json")
|
|
.body(Body::from(
|
|
serde_json::json!({
|
|
"domain": "routing",
|
|
"expected_revision": null,
|
|
"reason": "test apply",
|
|
"value": routing_value
|
|
})
|
|
.to_string(),
|
|
))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(apply.status(), StatusCode::OK);
|
|
let body = axum::body::to_bytes(apply.into_body(), 1024 * 1024)
|
|
.await
|
|
.unwrap();
|
|
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
|
let job_id = Uuid::parse_str(v.get("job_id").unwrap().as_str().unwrap()).unwrap();
|
|
|
|
let job = wait_done(app.clone(), job_id, &token).await;
|
|
assert_eq!(
|
|
job.get("status").and_then(|v| v.as_str()),
|
|
Some("succeeded")
|
|
);
|
|
|
|
let get = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.uri("/admin/v1/config/routing")
|
|
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(get.status(), StatusCode::OK);
|
|
let body = axum::body::to_bytes(get.into_body(), 1024 * 1024)
|
|
.await
|
|
.unwrap();
|
|
let got: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
|
assert_eq!(got.get("domain").unwrap().as_str().unwrap(), "routing");
|
|
assert!(got.get("revision").unwrap().as_u64().unwrap_or(0) > 0);
|
|
|
|
let rollback = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.uri("/admin/v1/jobs/config/rollback")
|
|
.method("POST")
|
|
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
|
.header("idempotency-key", format!("k-{}", Uuid::new_v4()))
|
|
.header(header::CONTENT_TYPE, "application/json")
|
|
.body(Body::from(
|
|
serde_json::json!({
|
|
"domain": "routing",
|
|
"reason": "test rollback"
|
|
})
|
|
.to_string(),
|
|
))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(rollback.status(), StatusCode::OK);
|
|
let body = axum::body::to_bytes(rollback.into_body(), 1024 * 1024)
|
|
.await
|
|
.unwrap();
|
|
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
|
let rb_id = Uuid::parse_str(v.get("job_id").unwrap().as_str().unwrap()).unwrap();
|
|
|
|
let rb_job = wait_done(app.clone(), rb_id, &token).await;
|
|
assert_eq!(
|
|
rb_job.get("status").and_then(|v| v.as_str()),
|
|
Some("succeeded")
|
|
);
|
|
}
|