Skip to content

Commit c8e186b

Browse files
authored
Merge branch 'main' into test/catalog-statement-count
2 parents c9fa2ad + 20c406a commit c8e186b

21 files changed

Lines changed: 527 additions & 24 deletions

File tree

‎clients/typescript/src/schema.d.ts‎

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -482,6 +482,22 @@ export interface paths {
482482
patch?: never;
483483
trace?: never;
484484
};
485+
"/api/control/locations/{location_id}/targets/{target}/snapshots": {
486+
parameters: {
487+
query?: never;
488+
header?: never;
489+
path?: never;
490+
cookie?: never;
491+
};
492+
get: operations["listPersonSnapshots"];
493+
put?: never;
494+
post?: never;
495+
delete?: never;
496+
options?: never;
497+
head?: never;
498+
patch?: never;
499+
trace?: never;
500+
};
485501
"/api/control/locations/{location_id}/targets/{target}/statistics": {
486502
parameters: {
487503
query?: never;
@@ -1488,6 +1504,28 @@ export interface components {
14881504
};
14891505
/** @enum {string} */
14901506
PlatformEventKind: "heartbeat" | "scheduler_no_leader" | "tick_overdue" | "calendar_horizon_low" | "location_broken" | "launch_failed";
1507+
/** @description A changed subject, such as `model:orders`, or `role:analyst` (`role:` for those who can't assign roles). */
1508+
PublishedChange: {
1509+
change_kind: string;
1510+
subject_key: string;
1511+
};
1512+
/** @description One publication as people browse it: its commit, environment and changed definitions. */
1513+
PublishedSnapshot: {
1514+
/** @description Added, changed and removed nodes and roles, in publication order. */
1515+
changes: components["schemas"]["PublishedChange"][];
1516+
/** @description Digest of the lock file, Python and `sqb` versions; a change means dependencies changed. */
1517+
environment_hash: string;
1518+
/** Format: int64 */
1519+
generation: number;
1520+
git_author?: string | null;
1521+
git_dirty: boolean;
1522+
git_revision?: string | null;
1523+
/** Format: date-time */
1524+
published_at: string;
1525+
/** Format: uuid */
1526+
snapshot_id: string;
1527+
sqb_version: string;
1528+
};
14911529
/** @description One invocation's records; the server owns its claim namespace, start time and version. */
14921530
RecallBatch: {
14931531
invocation_id: string;
@@ -3286,6 +3324,47 @@ export interface operations {
32863324
};
32873325
};
32883326
};
3327+
listPersonSnapshots: {
3328+
parameters: {
3329+
query?: {
3330+
/** @description Maximum recent publications; defaults to 20, capped at 100 */
3331+
limit?: number;
3332+
};
3333+
header?: never;
3334+
path: {
3335+
location_id: string;
3336+
target: string;
3337+
};
3338+
cookie?: never;
3339+
};
3340+
requestBody?: never;
3341+
responses: {
3342+
/** @description Publications newest first, each with its changed nodes and roles */
3343+
200: {
3344+
headers: {
3345+
[name: string]: unknown;
3346+
};
3347+
content: {
3348+
"application/json": components["schemas"]["PublishedSnapshot"][];
3349+
};
3350+
};
3351+
/** @description Session rejected */
3352+
401: {
3353+
headers: {
3354+
[name: string]: unknown;
3355+
};
3356+
content?: never;
3357+
};
3358+
403: {
3359+
headers: {
3360+
[name: string]: unknown;
3361+
};
3362+
content: {
3363+
"application/json": components["schemas"]["ApiErrorBody"];
3364+
};
3365+
};
3366+
};
3367+
};
32893368
readPersonStatistics: {
32903369
parameters: {
32913370
query: {

‎crates/sqlbuild-orchestration/src/api/_helpers/authentication/password_sessions.rs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,10 @@ pub(crate) fn person_routes() -> Router<ControlStore> {
117117
"/api/control/locations/{location_id}/targets/{target}/triggers/{trigger_id}/ticks",
118118
get(crate::api::_helpers::resources::person_reads::ticks),
119119
)
120+
.route(
121+
"/api/control/locations/{location_id}/targets/{target}/snapshots",
122+
get(crate::api::_helpers::resources::person_reads::snapshots),
123+
)
120124
.route(
121125
"/api/control/locations/{location_id}/targets/{target}/catalog",
122126
get(crate::api::_helpers::resources::person_operations::catalog),

‎crates/sqlbuild-orchestration/src/api/_helpers/resources/person_reads.rs‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
//! Person reads of run nodes, catalog nodes, jobs, triggers and ticks; refusal precedes every lookup, so unknown objects look forbidden.
1+
//! Person reads of run nodes, catalog nodes, jobs, triggers, ticks and publications; refusal precedes every lookup, so unknown objects look forbidden.
22
33
use axum::extract::{Path, Query, State};
44
use axum::http::StatusCode;
@@ -15,6 +15,8 @@ use crate::runs::main::list_person_run_nodes::list_person_run_nodes;
1515
use crate::runs::models::RunNodeRecord;
1616
use crate::scheduler::main::list_person_ticks::list_person_ticks;
1717
use crate::scheduler::models::TickRecord;
18+
use crate::snapshots::main::list_person_snapshots::list_person_snapshots;
19+
use crate::snapshots::models::PublishedSnapshot;
1820
use crate::store::models::ControlStore;
1921
use crate::warehouse_metadata::main::list_person_catalog_nodes::list_person_catalog_nodes;
2022
use crate::warehouse_metadata::models::CatalogNodeRecord;
@@ -136,6 +138,29 @@ pub(crate) async fn ticks(
136138
.ok_or_else(|| not_found("trigger not found"))
137139
}
138140

141+
#[utoipa::path(get, path = "/api/control/locations/{location_id}/targets/{target}/snapshots", operation_id = "listPersonSnapshots",
142+
params(("location_id" = Uuid, Path), ("target" = String, Path),
143+
("limit" = Option<u32>, Query, description = "Maximum recent publications; defaults to 20, capped at 100")),
144+
responses((status = 200, body = Vec<PublishedSnapshot>, description = "Publications newest first, each with its changed nodes and roles"),
145+
(status = 401, description = "Session rejected"),
146+
(status = 403, body = crate::api::models::ApiErrorBody)), security(("session" = []), ("cliSession" = [])))]
147+
pub(crate) async fn snapshots(
148+
(State(store), Extension(context)): (State<ControlStore>, Extension<AuditContext>),
149+
Extension(default_role): Extension<DefaultRole>,
150+
(Path((location_id, target)), Query(options)): (Path<(Uuid, String)>, Query<ReadQuery>),
151+
Extension(AuthenticatedPerson(principal, _)): Extension<AuthenticatedPerson>,
152+
) -> Result<Json<Vec<PublishedSnapshot>>, ApiError> {
153+
Ok(Json(
154+
list_person_snapshots(
155+
&store,
156+
(&context, principal),
157+
((location_id, &target), options.limit.unwrap_or(20)),
158+
default_role,
159+
)
160+
.await?,
161+
))
162+
}
163+
139164
fn not_found(message: &str) -> ApiError {
140165
ApiError {
141166
status: StatusCode::NOT_FOUND,

‎crates/sqlbuild-orchestration/src/api/models.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ use crate::api::_helpers::resources::{
2525
person_reads::jobs,
2626
person_reads::triggers,
2727
person_reads::ticks,
28+
person_reads::snapshots,
2829
person_operations::catalog,
2930
person_operations::statistics,
3031
person_operations::fingerprint,

‎crates/sqlbuild-orchestration/src/authorization/main/mod.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ pub mod change_administrator;
77
pub mod compile_roles;
88
pub(crate) mod deny_authorization;
99
pub mod grant_admin;
10+
pub(crate) mod person_assigns_roles;
1011
pub(crate) mod person_lists_target;
1112
pub(crate) mod publish_custom_roles;
1213
pub(crate) mod publish_group_roles;
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
//! Whether a person may assign roles server-wide; unaudited, for deciding what a read may name.
2+
3+
use std::collections::BTreeSet;
4+
5+
use uuid::Uuid;
6+
7+
use crate::authorization::_helpers::person_grants::{allowed, person_role_rows};
8+
use crate::credentials::models::{Action, DefaultRole};
9+
use crate::store::errors::ControlError;
10+
use crate::store::models::ControlTransaction;
11+
12+
/// Inside the caller's transaction, so the answer matches the rest of its read.
13+
pub(crate) async fn person_assigns_roles(
14+
transaction: ControlTransaction,
15+
principal_id: Uuid,
16+
) -> Result<(ControlTransaction, bool), ControlError> {
17+
let (transaction, rows) = person_role_rows(transaction, principal_id, None).await?;
18+
let assigns = allowed(
19+
&rows.unwrap_or_default(),
20+
(None, None),
21+
&BTreeSet::new(),
22+
DefaultRole::None,
23+
)
24+
.contains(&Action::RoleAssign);
25+
Ok((transaction, assigns))
26+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
//! A person browses a target's publications only with view on every current node, like its jobs.
2+
3+
use std::collections::BTreeMap;
4+
5+
use chrono::{DateTime, Utc};
6+
use uuid::Uuid;
7+
8+
use crate::authorization::main::authorize_listing::authorize_listing;
9+
use crate::authorization::main::person_assigns_roles::person_assigns_roles;
10+
use crate::credentials::models::{AuditContext, DefaultRole};
11+
use crate::snapshots::models::{PublishedChange, PublishedSnapshot};
12+
use crate::store::errors::ControlError;
13+
use crate::store::main::{begin::begin, commit::commit, decode_row::decode_row, query::query};
14+
use crate::store::models::{ControlStore, Parameter, TransactionMode};
15+
use crate::store::types::Row;
16+
17+
/// Newest first, at most 100 publications; changed roles are named only for people who assign roles.
18+
pub async fn list_person_snapshots(
19+
store: &ControlStore,
20+
(context, credentials): (&AuditContext, Uuid),
21+
((location_id, target), limit): ((Uuid, &str), u32),
22+
default_role: DefaultRole,
23+
) -> Result<Vec<PublishedSnapshot>, ControlError> {
24+
let (transaction, _) = authorize_listing(
25+
begin(store, TransactionMode::Write).await?,
26+
(context, credentials),
27+
(location_id, target),
28+
default_role,
29+
)
30+
.await?;
31+
let scope = vec![
32+
Parameter::Id(location_id),
33+
Parameter::Text(target.into()),
34+
Parameter::Integer(i64::from(limit.min(100))),
35+
];
36+
let (transaction, published) = query(transaction,
37+
"SELECT snapshot_id, generation, git_revision, CASE WHEN git_dirty THEN 1 ELSE 0 END AS git_dirty, git_author, environment_hash, sqb_version, published_at \
38+
FROM sqb_snapshots WHERE location_id=$1 AND target=$2 ORDER BY generation DESC LIMIT $3",
39+
scope.clone()).await?;
40+
let (transaction, changed) = query(transaction,
41+
"SELECT c.snapshot_id, c.change_kind, c.subject_key FROM sqb_snapshot_changes c \
42+
WHERE c.snapshot_id IN (SELECT s.snapshot_id FROM sqb_snapshots s WHERE s.location_id=$1 AND s.target=$2 \
43+
ORDER BY s.generation DESC LIMIT $3) ORDER BY c.snapshot_id, c.change_seq",
44+
scope).await?;
45+
let (transaction, names_roles) = person_assigns_roles(transaction, credentials).await?;
46+
commit(transaction).await?;
47+
let mut changes: BTreeMap<Uuid, Vec<PublishedChange>> = BTreeMap::new();
48+
for row in changed {
49+
let snapshot_id = id_of(&row)?;
50+
let mut change: PublishedChange = decode_row(row)?;
51+
if !names_roles && change.subject_key.starts_with("role:") {
52+
change.subject_key = "role:".into();
53+
}
54+
changes.entry(snapshot_id).or_default().push(change);
55+
}
56+
published
57+
.into_iter()
58+
.map(|row| {
59+
let mut snapshot = snapshot_of(row)?;
60+
snapshot.changes = changes.remove(&snapshot.snapshot_id).unwrap_or_default();
61+
Ok(snapshot)
62+
})
63+
.collect()
64+
}
65+
66+
fn snapshot_of(row: Row) -> Result<PublishedSnapshot, ControlError> {
67+
let snapshot_id = id_of(&row)?;
68+
let text = |key: &str| row[key].as_str().map(String::from);
69+
let published_at: DateTime<Utc> = serde_json::from_value(row["published_at"].clone())
70+
.map_err(|error| ControlError::Invalid(format!("stored publication time: {error}")))?;
71+
Ok(PublishedSnapshot {
72+
snapshot_id,
73+
generation: row["generation"].as_i64().unwrap_or_default(),
74+
git_revision: text("git_revision"),
75+
git_dirty: row["git_dirty"].as_i64() == Some(1),
76+
git_author: text("git_author"),
77+
environment_hash: text("environment_hash").unwrap_or_default(),
78+
sqb_version: text("sqb_version").unwrap_or_default(),
79+
published_at,
80+
changes: Vec::new(),
81+
})
82+
}
83+
84+
fn id_of(row: &Row) -> Result<Uuid, ControlError> {
85+
serde_json::from_value(row["snapshot_id"].clone())
86+
.map_err(|error| ControlError::Invalid(format!("stored snapshot id: {error}")))
87+
}

‎crates/sqlbuild-orchestration/src/snapshots/main/mod.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ pub(super) mod _publish_snapshot;
22
pub mod current_snapshot;
33
pub mod current_snapshot_record;
44
pub(crate) mod job_write_set;
5+
pub mod list_person_snapshots;
56
pub mod next_remote_generation;
67
pub(crate) mod prune_snapshots;
78
pub(crate) mod publish_development_snapshot;

‎crates/sqlbuild-orchestration/src/snapshots/models.rs‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,3 +122,26 @@ impl Default for SnapshotRetention {
122122
}
123123
}
124124
}
125+
126+
/// One publication as people browse it: its commit, environment and changed definitions.
127+
#[derive(Clone, Debug, PartialEq, Eq, Serialize, utoipa::ToSchema)]
128+
pub struct PublishedSnapshot {
129+
pub snapshot_id: Uuid,
130+
pub generation: i64,
131+
pub git_revision: Option<String>,
132+
pub git_dirty: bool,
133+
pub git_author: Option<String>,
134+
/// Digest of the lock file, Python and `sqb` versions; a change means dependencies changed.
135+
pub environment_hash: String,
136+
pub sqb_version: String,
137+
pub published_at: DateTime<Utc>,
138+
/// Added, changed and removed nodes and roles, in publication order.
139+
pub changes: Vec<PublishedChange>,
140+
}
141+
142+
/// A changed subject, such as `model:orders`, or `role:analyst` (`role:` for those who can't assign roles).
143+
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
144+
pub struct PublishedChange {
145+
pub change_kind: String,
146+
pub subject_key: String,
147+
}

‎crates/sqlbuild-orchestration/tests/api/test_person_sessions.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,7 @@ fn given_browser_session_roles_when_work_is_requested_then_scope_revocation_and_
288288
format!("/api/control/locations/{location}/targets/development/nodes"),
289289
format!("/api/control/locations/{location}/targets/development/jobs"),
290290
format!("/api/control/locations/{location}/targets/development/triggers"),
291+
format!("/api/control/locations/{location}/targets/development/snapshots"),
291292
format!(
292293
"/api/control/locations/{location}/targets/development/triggers/{}/ticks",
293294
trigger.trigger_id

0 commit comments

Comments
 (0)