Skip to content

Commit cf304e2

Browse files
committed
fix(e2e): run admin workflows against postgres [axon-7aca0004]
Enable TLS in installed service templates, repair legacy tenant db_name navigation, and route Postgres tenant slugs through a stable physical database key.\n\nRefs axon-5dadc6f6\nRefs axon-5391e739
1 parent 2c05d43 commit cf304e2

13 files changed

Lines changed: 306 additions & 197 deletions

File tree

.ddx/beads.jsonl

Lines changed: 4 additions & 0 deletions
Large diffs are not rendered by default.

.github/workflows/ci.yml

Lines changed: 1 addition & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -111,45 +111,10 @@ jobs:
111111
e2e-postgres:
112112
name: E2E (PostgreSQL)
113113
runs-on: ubuntu-latest
114-
services:
115-
postgres:
116-
image: postgres:16-alpine
117-
env:
118-
POSTGRES_USER: axon
119-
POSTGRES_PASSWORD: axon
120-
POSTGRES_DB: postgres
121-
ports:
122-
- 5432:5432
123-
options: >-
124-
--health-cmd pg_isready
125-
--health-interval 5s
126-
--health-timeout 5s
127-
--health-retries 5
128-
env:
129-
AXON_POSTGRES_DSN: postgresql://axon:axon@localhost:5432/postgres
130114
steps:
131115
- uses: actions/checkout@v6
132116
- uses: dtolnay/rust-toolchain@stable
133117
- uses: Swatinem/rust-cache@v2
134118
- uses: oven-sh/setup-bun@v2
135119
- run: cd ui && bun install
136-
- run: cd ui && bun run build
137-
- run: cargo build -p axon-cli
138-
- name: Start Axon PostgreSQL E2E server
139-
run: |
140-
target/debug/axon serve \
141-
--no-auth \
142-
--storage postgres \
143-
--postgres-dsn "$AXON_POSTGRES_DSN" \
144-
--http-port 4171 \
145-
--ui-dir ui/build > /tmp/axon-e2e.log 2>&1 &
146-
echo $! > /tmp/axon-e2e.pid
147-
for i in $(seq 1 60); do
148-
if curl -fsS http://localhost:4171/health >/dev/null; then
149-
exit 0
150-
fi
151-
sleep 1
152-
done
153-
cat /tmp/axon-e2e.log
154-
exit 1
155-
- run: cd ui && AXON_E2E_BASE_URL=http://host.docker.internal:4171 bun run test:e2e:postgres
120+
- run: cd ui && bun run test:e2e:postgres

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ members = [
1515
resolver = "2"
1616

1717
[workspace.package]
18-
version = "0.2.4"
18+
version = "0.2.5"
1919
edition = "2021"
2020
license = "MIT OR Apache-2.0"
2121
repository = "https://github.com/DocumentDrivenDX/axon"

crates/axon-cli/src/service.rs

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ After=network.target
6565
6666
[Service]
6767
Type=simple
68-
ExecStart={binary_path} serve --no-auth --sqlite-path @SQLITE_PATH@ --control-plane-path @CONTROL_PLANE_PATH@
68+
ExecStart={binary_path} serve --no-auth --tls-self-signed --sqlite-path @SQLITE_PATH@ --control-plane-path @CONTROL_PLANE_PATH@
6969
Restart=on-failure
7070
RestartSec=5
7171
StandardOutput=journal
@@ -88,7 +88,7 @@ After=network.target
8888
Type=simple
8989
User=axon
9090
Group=axon
91-
ExecStart={binary_path} serve --no-auth --sqlite-path @SQLITE_PATH@ --control-plane-path @CONTROL_PLANE_PATH@
91+
ExecStart={binary_path} serve --no-auth --tls-self-signed --sqlite-path @SQLITE_PATH@ --control-plane-path @CONTROL_PLANE_PATH@
9292
Restart=on-failure
9393
RestartSec=5
9494
StandardOutput=journal
@@ -232,6 +232,7 @@ const LAUNCHD_PLIST_TEMPLATE: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
232232
<string>{binary_path}</string>
233233
<string>serve</string>
234234
<string>--no-auth</string>
235+
<string>--tls-self-signed</string>
235236
<string>--sqlite-path</string>
236237
<string>@SQLITE_PATH@</string>
237238
<string>--control-plane-path</string>
@@ -369,3 +370,32 @@ fn run_cmd(program: &str, args: &[&str]) -> Result<()> {
369370
anyhow::bail!("{} {} exited with {}", program, args.join(" "), status);
370371
}
371372
}
373+
374+
#[cfg(test)]
375+
mod tests {
376+
use super::*;
377+
378+
#[test]
379+
fn systemd_user_unit_enables_self_signed_tls() {
380+
assert!(
381+
SYSTEMD_USER_UNIT.contains("serve --no-auth --tls-self-signed --sqlite-path"),
382+
"user systemd service must install with TLS enabled by default",
383+
);
384+
}
385+
386+
#[test]
387+
fn systemd_global_unit_enables_self_signed_tls() {
388+
assert!(
389+
SYSTEMD_GLOBAL_UNIT.contains("serve --no-auth --tls-self-signed --sqlite-path"),
390+
"global systemd service must install with TLS enabled by default",
391+
);
392+
}
393+
394+
#[test]
395+
fn launchd_plist_enables_self_signed_tls() {
396+
assert!(
397+
LAUNCHD_PLIST_TEMPLATE.contains("<string>--tls-self-signed</string>"),
398+
"launchd service must install with TLS enabled by default",
399+
);
400+
}
401+
}

crates/axon-server/src/control_plane.rs

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,10 @@ impl ControlPlaneDb {
160160
.execute(&self.pool),
161161
);
162162

163-
// Step 3: drop obsolete junction tables introduced in earlier schema revisions.
163+
// Step 3: backfill db_name for tenants created before slugs existed.
164+
self.backfill_missing_tenant_db_names()?;
165+
166+
// Step 4: drop obsolete junction tables introduced in earlier schema revisions.
164167
self.block_on(sqlx::query("DROP TABLE IF EXISTS tenant_databases").execute(&self.pool))
165168
.map_err(AxonError::Storage)?;
166169

@@ -178,6 +181,34 @@ impl ControlPlaneDb {
178181
Ok(())
179182
}
180183

184+
fn backfill_missing_tenant_db_names(&self) -> Result<(), AxonError> {
185+
let rows = self
186+
.block_on(
187+
sqlx::query(
188+
"SELECT id, name FROM tenants
189+
WHERE db_name = '' OR db_name IS NULL
190+
ORDER BY created_at",
191+
)
192+
.fetch_all(&self.pool),
193+
)
194+
.map_err(AxonError::Storage)?;
195+
196+
for row in rows {
197+
let id: String = row.get("id");
198+
let name: String = row.get("name");
199+
let db_name = tenant_db_slug(&name, &id);
200+
self.block_on(
201+
sqlx::query("UPDATE tenants SET db_name = ? WHERE id = ?")
202+
.bind(db_name)
203+
.bind(id)
204+
.execute(&self.pool),
205+
)
206+
.map_err(AxonError::Storage)?;
207+
}
208+
209+
Ok(())
210+
}
211+
181212
// -- cors_origins ----------------------------------------------------------
182213

183214
/// List all configured CORS allowed origins.
@@ -363,6 +394,32 @@ impl ControlPlaneDb {
363394
}
364395
}
365396

397+
fn tenant_db_slug(name: &str, id: &str) -> String {
398+
let slug: String = name
399+
.chars()
400+
.map(|c| {
401+
if c.is_alphanumeric() {
402+
c.to_ascii_lowercase()
403+
} else {
404+
'-'
405+
}
406+
})
407+
.collect();
408+
let slug: String = slug
409+
.split('-')
410+
.filter(|s| !s.is_empty())
411+
.collect::<Vec<_>>()
412+
.join("-");
413+
414+
let id_prefix: String = id.chars().filter(|c| c != &'-').take(8).collect();
415+
416+
if slug.is_empty() {
417+
format!("tenant-{id_prefix}")
418+
} else {
419+
format!("{slug}-{id_prefix}")
420+
}
421+
}
422+
366423
// ---------------------------------------------------------------------------
367424
// Tests
368425
// ---------------------------------------------------------------------------
@@ -404,6 +461,25 @@ mod tests {
404461
assert!(!tables.contains(&"tenant_databases".to_string()));
405462
}
406463

464+
#[test]
465+
fn migrate_backfills_empty_tenant_db_names() {
466+
let db = setup();
467+
db.create_tenant(
468+
"019d87ba-2e17-7060-8d23-bc8b0aac9c53",
469+
"Nexiq",
470+
"",
471+
"2026-04-13T16:43:38Z",
472+
)
473+
.expect("create legacy tenant");
474+
475+
db.migrate().expect("migration should backfill db_name");
476+
477+
let tenant = db
478+
.get_tenant("019d87ba-2e17-7060-8d23-bc8b0aac9c53")
479+
.expect("tenant");
480+
assert_eq!(tenant.db_name, "nexiq-019d87ba");
481+
}
482+
407483
// -- tenants ------------------------------------------------------------
408484

409485
#[test]

crates/axon-server/src/tenant_router.rs

Lines changed: 87 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,62 @@ fn logical_database_from_slug(slug: &str) -> &str {
3030
.unwrap_or(DEFAULT_DATABASE)
3131
}
3232

33+
fn is_postgres_database_identifier(name: &str) -> bool {
34+
let Some(first) = name.chars().next() else {
35+
return false;
36+
};
37+
(first.is_ascii_alphabetic() || first == '_')
38+
&& name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
39+
}
40+
41+
fn stable_database_hash(name: &str) -> u64 {
42+
let mut hash = 0xcbf2_9ce4_8422_2325_u64;
43+
for byte in name.bytes() {
44+
hash ^= u64::from(byte);
45+
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
46+
}
47+
hash
48+
}
49+
50+
fn postgres_database_key(public_name: &str) -> String {
51+
if is_postgres_database_identifier(public_name) {
52+
return public_name.to_owned();
53+
}
54+
55+
let mut stem = String::new();
56+
let mut last_was_separator = false;
57+
for byte in public_name.bytes() {
58+
let next = if byte.is_ascii_alphanumeric() {
59+
char::from(byte).to_ascii_lowercase()
60+
} else {
61+
'_'
62+
};
63+
64+
if next == '_' {
65+
if !stem.is_empty() && !last_was_separator {
66+
stem.push(next);
67+
}
68+
last_was_separator = true;
69+
} else {
70+
stem.push(next);
71+
last_was_separator = false;
72+
}
73+
74+
if stem.len() >= 39 {
75+
break;
76+
}
77+
}
78+
79+
while stem.ends_with('_') {
80+
stem.pop();
81+
}
82+
if stem.is_empty() {
83+
stem.push_str("db");
84+
}
85+
86+
format!("t_{stem}_{:016x}", stable_database_hash(public_name))
87+
}
88+
3389
fn ensure_logical_database<S: StorageAdapter>(storage: &mut S, slug: &str) -> Result<(), String> {
3490
let database = logical_database_from_slug(slug);
3591
if database == DEFAULT_DATABASE {
@@ -252,27 +308,31 @@ impl TenantRouter {
252308

253309
let superadmin_dsn = superadmin_dsn.clone();
254310
let db_name_owned = db_name.to_owned();
311+
let physical_db_name = postgres_database_key(&db_name_owned);
255312

256313
// Provision the physical database (may already exist — that's
257314
// fine, we just open a connection to it).
258-
let tenant_conn_str = axon_storage::tenant_dsn(&superadmin_dsn, &db_name_owned);
315+
let tenant_conn_str = axon_storage::tenant_dsn(&superadmin_dsn, &physical_db_name);
259316

260317
// Spawn the blocking connect on a dedicated thread so we don't
261318
// block the async runtime.
262319
let handler = tokio::task::spawn_blocking(move || {
263320
// Attempt to provision; ignore AlreadyExists.
264-
match axon_storage::provision_postgres_database(&superadmin_dsn, &db_name_owned) {
321+
match axon_storage::provision_postgres_database(
322+
&superadmin_dsn,
323+
&physical_db_name,
324+
) {
265325
Ok(()) | Err(axon_core::error::AxonError::AlreadyExists(_)) => {}
266326
Err(e) => {
267327
return Err(format!(
268-
"failed to provision PostgreSQL database 'axon_{db_name_owned}': {e}"
328+
"failed to provision PostgreSQL database 'axon_{physical_db_name}' for '{db_name_owned}': {e}"
269329
))
270330
}
271331
}
272332

273333
let mut storage = PostgresStorageAdapter::connect(&tenant_conn_str).map_err(|e| {
274334
format!(
275-
"failed to connect to tenant PostgreSQL database 'axon_{db_name_owned}': {e}"
335+
"failed to connect to tenant PostgreSQL database 'axon_{physical_db_name}' for '{db_name_owned}': {e}"
276336
)
277337
})?;
278338
ensure_logical_database(&mut storage, &db_name_owned)?;
@@ -360,17 +420,18 @@ impl TenantRouter {
360420

361421
let superadmin_dsn = superadmin_dsn.clone();
362422
let db_name_owned = db_name.to_owned();
423+
let physical_db_name = postgres_database_key(&db_name_owned);
363424

364425
tokio::task::spawn_blocking(move || {
365426
match axon_storage::deprovision_postgres_database(
366427
&superadmin_dsn,
367-
&db_name_owned,
428+
&physical_db_name,
368429
) {
369430
Ok(()) => Ok(()),
370431
// Already gone — treat as success.
371432
Err(axon_core::error::AxonError::NotFound(_)) => Ok(()),
372433
Err(e) => Err(format!(
373-
"failed to drop PostgreSQL database 'axon_{db_name_owned}': {e}"
434+
"failed to drop PostgreSQL database 'axon_{physical_db_name}' for '{db_name_owned}': {e}"
374435
)),
375436
}
376437
})
@@ -402,6 +463,26 @@ mod tests {
402463
TenantRouter::new(tmp.to_path_buf(), default_handler)
403464
}
404465

466+
#[test]
467+
fn postgres_database_key_preserves_valid_identifiers() {
468+
assert_eq!(postgres_database_key("tenant_123"), "tenant_123");
469+
}
470+
471+
#[test]
472+
fn postgres_database_key_maps_public_slugs_to_safe_identifiers() {
473+
let public = "e2e-smoke-mo7zvzze-019dadd5:first";
474+
let key = postgres_database_key(public);
475+
476+
assert_ne!(key, public);
477+
assert!(is_postgres_database_identifier(&key));
478+
assert!(key.len() <= 58);
479+
assert_eq!(key, postgres_database_key(public));
480+
assert_ne!(
481+
key,
482+
postgres_database_key("e2e-smoke-mo7zvzze-019dadd5:second")
483+
);
484+
}
485+
405486
#[tokio::test(flavor = "multi_thread")]
406487
async fn get_or_create_default_returns_default_handler() {
407488
let tmp = tempfile::tempdir().expect("tempdir");

0 commit comments

Comments
 (0)