Skip to content
Open
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
22 changes: 20 additions & 2 deletions crates/database/src/servers.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use commons_errors::{AppError, Result};
use commons_types::{
device::DeviceRole,
geo::GeoPoint,
server::{kind::ServerKind, rank::ServerRank, ticket::CanopyTicket},
};
Expand Down Expand Up @@ -223,12 +224,18 @@ impl Server {
_ => {}
}

// Find or create the device that owns this key.
// Find or create the device that owns this key. The ticket is
// the operator's trust signal — promote a freshly-created (or
// previously-Untrusted) device to `Server` so they don't have
// to flip it manually after import.
let device = if let Some(device) = crate::devices::Device::from_key(db, &key_der).await? {
device
} else {
crate::devices::Device::create(db, key_der).await?
};
if device.role == DeviceRole::Untrusted {
crate::devices::Device::trust(db, device.id, DeviceRole::Server).await?;
}

let cloud = ticket.hosting.as_deref().map(|h| {
matches!(
Expand All @@ -252,23 +259,34 @@ impl Server {
};

let kind_str = server_value.kind.to_string();
let rank_str = server_value.rank.map(|r| r.to_string());

// Upsert: insert or update on conflict.
// Upsert: insert or update on conflict. Ticket-derived fields
// (kind, rank, cloud, parent_server_id) re-apply on update;
// operator-edited state (listed, geolocation, alert_when_down)
// is preserved.
diesel::insert_into(servers::table)
.values((
servers::id.eq(server_value.id),
servers::name.eq(&server_value.name),
servers::host.eq(&host_str),
servers::kind.eq(&kind_str),
servers::rank.eq(&rank_str),
servers::device_id.eq(server_value.device_id),
servers::parent_server_id.eq(server_value.parent_server_id),
servers::listed.eq(server_value.listed),
servers::cloud.eq(server_value.cloud),
))
.on_conflict(servers::id)
.do_update()
.set((
servers::name.eq(&server_value.name),
servers::host.eq(&host_str),
servers::kind.eq(&kind_str),
servers::rank.eq(&rank_str),
servers::device_id.eq(server_value.device_id),
servers::parent_server_id.eq(server_value.parent_server_id),
servers::cloud.eq(server_value.cloud),
))
.returning(Self::as_select())
.get_result(db)
Expand Down
99 changes: 97 additions & 2 deletions crates/database/tests/upsert_from_ticket.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
use commons_types::server::{kind::ServerKind, rank::ServerRank, ticket::CanopyTicket};
use database::servers::Server;
use commons_types::{
device::DeviceRole,
server::{kind::ServerKind, rank::ServerRank, ticket::CanopyTicket},
};
use database::{devices::Device, servers::Server};
use uuid::Uuid;

fn synthesize_ticket(server_id: Uuid, hostname: &str, url: &str) -> CanopyTicket {
Expand Down Expand Up @@ -41,6 +44,98 @@ async fn upsert_from_ticket_smoke() {
.await
}

#[tokio::test(flavor = "multi_thread")]
async fn upsert_from_ticket_persists_rank_and_trusts_device() {
commons_tests::db::TestDb::run(async |mut conn, _| {
let id = Uuid::new_v4();
let ticket = synthesize_ticket(id, "alpha", "https://alpha.example.com");
let server = Server::upsert_from_ticket(
&mut conn,
&ticket,
ServerKind::Facility,
Some(ServerRank::Production),
)
.await
.expect("upsert");

assert_eq!(server.rank, Some(ServerRank::Production));
assert_eq!(server.kind, ServerKind::Facility);

let device_id = server.device_id.expect("server has a device");
let device = Device::get_with_info(&mut conn, device_id).await.expect("device").device;
assert_eq!(device.role, DeviceRole::Server);
})
.await
}

#[tokio::test(flavor = "multi_thread")]
async fn upsert_from_ticket_re_import_refreshes_rank() {
commons_tests::db::TestDb::run(async |mut conn, _| {
let id = Uuid::new_v4();
let ticket = synthesize_ticket(id, "alpha", "https://alpha.example.com");

let first = Server::upsert_from_ticket(
&mut conn,
&ticket,
ServerKind::Facility,
Some(ServerRank::Demo),
)
.await
.expect("first upsert");
assert_eq!(first.rank, Some(ServerRank::Demo));

let second = Server::upsert_from_ticket(
&mut conn,
&ticket,
ServerKind::Facility,
Some(ServerRank::Production),
)
.await
.expect("second upsert");
assert_eq!(second.rank, Some(ServerRank::Production));
assert_eq!(second.id, first.id);
})
.await
}

#[tokio::test(flavor = "multi_thread")]
async fn upsert_from_ticket_preserves_higher_role_on_existing_device() {
commons_tests::db::TestDb::run(async |mut conn, _| {
let id = Uuid::new_v4();
let ticket = synthesize_ticket(id, "alpha", "https://alpha.example.com");

// First import → device gets Server role.
let first = Server::upsert_from_ticket(
&mut conn,
&ticket,
ServerKind::Facility,
Some(ServerRank::Production),
)
.await
.expect("first upsert");
let device_id = first.device_id.expect("device_id");

// Manually promote to Admin (simulates an operator who chose
// to give this device extra privileges).
Device::trust(&mut conn, device_id, DeviceRole::Admin)
.await
.expect("trust admin");

// Re-import the same ticket. The device should *not* be demoted.
let _ = Server::upsert_from_ticket(
&mut conn,
&ticket,
ServerKind::Facility,
Some(ServerRank::Production),
)
.await
.expect("second upsert");
let device = Device::get_with_info(&mut conn, device_id).await.expect("device").device;
assert_eq!(device.role, DeviceRole::Admin);
})
.await
}

#[tokio::test(flavor = "multi_thread")]
async fn upsert_from_ticket_idempotent() {
commons_tests::db::TestDb::run(async |mut conn, _| {
Expand Down