Skip to main content

cufflink_types/
features.rs

1//! Platform capability names: what `/api/version` advertises in `supports`
2//! and a manifest may demand in `required_platform_features`.
3
4use crate::{
5    CustomRouteDefinition, ScheduleDefinition, ServiceManifest, SubscriptionDefinition,
6    TableDefinition,
7};
8use std::collections::BTreeSet;
9
10pub const ROUTE_POSTURE: &str = "route_posture";
11pub const DB_V2: &str = "db_v2";
12pub const CACHE: &str = "cache";
13pub const SERVICE_CALL: &str = "service_call";
14pub const RESPONSE_V2: &str = "response_v2";
15pub const RESPONSE_RAW_JSON: &str = "response_raw_json";
16pub const SSE_BRIDGE: &str = "sse_bridge";
17pub const ROUTE_MATCHING: &str = "route_matching";
18pub const ROUTE_LIMITS: &str = "route_limits";
19pub const ROUTE_CONCURRENCY: &str = "route_concurrency";
20pub const ERROR_PROFILE: &str = "error_profile";
21pub const INTERNAL_POSTURE: &str = "internal_posture";
22pub const API_KEY_POSTURE: &str = "api_key_posture";
23pub const SCOPED_PERMISSIONS: &str = "scoped_permissions";
24pub const SCOPED_POSTURE: &str = "scoped_posture";
25pub const TYPED_ROUTES: &str = "typed_routes";
26pub const NATS_CONSUMER_CONFIG: &str = "nats_consumer_config";
27pub const NATS_MESSAGE_META: &str = "nats_message_meta";
28pub const NATS_PUBLISH_V2: &str = "nats_publish_v2";
29pub const STREAMS: &str = "streams";
30pub const SCHEDULES: &str = "schedules";
31pub const JOBS_V2: &str = "jobs_v2";
32pub const DELIVERY_CONTROL: &str = "delivery_control";
33pub const HTTP_V2: &str = "http_v2";
34pub const STORAGE_V2: &str = "storage_v2";
35pub const STORAGE_ACL: &str = "storage_acl";
36pub const TENANT_BUCKETS: &str = "tenant_buckets";
37pub const UTIL_V2: &str = "util_v2";
38pub const SCHEMA_ADOPTED: &str = "schema_adopted";
39pub const SQL_MIGRATIONS: &str = "sql_migrations";
40pub const COLUMN_TYPES_V2: &str = "column_types_v2";
41pub const AUDIT_REDACTION: &str = "audit_redaction";
42pub const KEYSET_LISTS: &str = "keyset_lists";
43pub const OUTBOX: &str = "outbox";
44pub const DB_ROLES: &str = "db_roles";
45pub const SCHEMA_GUARD: &str = "schema_guard";
46pub const NAMESPACE_CLAIMS: &str = "namespace_claims";
47pub const SECRET_ENCRYPTION: &str = "secret_encryption";
48pub const CONSUMER_RECONCILER: &str = "consumer_reconciler";
49pub const ASYNC_DEPLOY: &str = "async_deploy";
50pub const EXT_CALL: &str = "ext_call";
51pub const NATIVE_SERVICES: &str = "native_services";
52pub const PRIVATE_TABLES: &str = "private_tables";
53pub const INACTIVE_DEPLOY: &str = "inactive_deploy";
54pub const CONFIG_APPLY: &str = "config_apply";
55pub const COLUMN_PERMISSIONS: &str = "column_permissions";
56pub const GUEST_MEMORY_BUDGET: &str = "guest_memory_budget";
57pub const GUEST_STATEMENT_TIMEOUT: &str = "guest_statement_timeout";
58pub const SQL_PARSE: &str = "sql_parse";
59pub const WEB_RELEASES: &str = "web_releases";
60
61impl ServiceManifest {
62    /// Features this manifest's declared shape requires, sorted and
63    /// deduplicated. A manifest that needs a capability always demands it, so
64    /// a platform too old to provide it rejects the deploy instead of silently
65    /// running the service without it.
66    pub fn derived_features(&self) -> BTreeSet<&'static str> {
67        self.custom_routes
68            .iter()
69            .flat_map(CustomRouteDefinition::derived_features)
70            .chain(
71                self.declared_tables()
72                    .flat_map(TableDefinition::derived_features),
73            )
74            .chain(
75                self.subscriptions
76                    .iter()
77                    .flat_map(SubscriptionDefinition::derived_features),
78            )
79            .chain(
80                self.schedules
81                    .iter()
82                    .flat_map(ScheduleDefinition::derived_features),
83            )
84            .chain(self.service_features())
85            .collect()
86    }
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92    use crate::manifest::fixtures::sample_manifest;
93    use crate::RoutePosture;
94    use serde_json::Value;
95
96    const SAMPLE: &str = include_str!("../fixtures/sample_manifest.json");
97    const TODO: &str = include_str!("../fixtures/todo_manifest.json");
98    const BLOG: &str = include_str!("../fixtures/blog_manifest.json");
99    /// Every shape v0.27.0 accepted, canonicalised and hashed by v0.27.0.
100    /// It wrote a subscription's `config` in an order that changed from
101    /// one process to the next; the hashes are those of the runs that
102    /// wrote it in the order of its keys, as every run does now.
103    const LEGACY_SHAPES: &str = include_str!("../fixtures/legacy_shapes_manifest.json");
104
105    fn manifest(json: &str) -> ServiceManifest {
106        serde_json::from_str(json).expect("fixture manifest")
107    }
108
109    fn route(handler: &str, posture: Option<RoutePosture>) -> CustomRouteDefinition {
110        CustomRouteDefinition {
111            method: "GET".into(),
112            path: format!("/{handler}"),
113            handler: handler.into(),
114            job: None,
115            posture,
116            forward_auth: false,
117            ..Default::default()
118        }
119    }
120
121    #[test]
122    fn sample_manifest_derives_only_route_posture() {
123        let sample = manifest(SAMPLE);
124        assert_eq!(sample.derived_features(), BTreeSet::from([ROUTE_POSTURE]));
125        assert_eq!(sample.required_platform_features, vec![ROUTE_POSTURE]);
126        for crud_only in [TODO, BLOG] {
127            assert!(manifest(crud_only).derived_features().is_empty());
128        }
129    }
130
131    #[test]
132    fn route_posture_is_derived_once_from_any_declared_posture() {
133        let mut m = sample_manifest();
134        assert!(m.derived_features().is_empty());
135        m.custom_routes.push(route("legacy", None));
136        assert!(m.derived_features().is_empty());
137        m.custom_routes
138            .push(route("me", Some(RoutePosture::Authenticated)));
139        m.custom_routes.push(route(
140            "hook",
141            Some(RoutePosture::Public {
142                reason: "signed webhook".into(),
143            }),
144        ));
145        assert_eq!(m.derived_features(), BTreeSet::from([ROUTE_POSTURE]));
146    }
147
148    #[test]
149    fn strict_routing_demands_route_matching() {
150        let mut m = sample_manifest();
151        m.routing = crate::RoutingMode::Strict;
152        assert_eq!(m.derived_features(), BTreeSet::from([ROUTE_MATCHING]));
153    }
154
155    #[test]
156    fn an_error_profile_demands_error_profile() {
157        let mut m = sample_manifest();
158        m.errors = Some(crate::ErrorProfile {
159            key: "error_message".into(),
160        });
161        assert_eq!(m.derived_features(), BTreeSet::from([ERROR_PROFILE]));
162    }
163
164    fn sql_migration() -> crate::SqlMigration {
165        crate::SqlMigration {
166            version: 1,
167            name: "initial_tables".into(),
168            sql: "CREATE TABLE loans ();".into(),
169            no_transaction: false,
170        }
171    }
172
173    fn stream() -> crate::StreamDefinition {
174        serde_json::from_value(serde_json::json!({
175            "name": "LOANS", "subjects": ["loans.created"], "retention": "limits"
176        }))
177        .expect("stream")
178    }
179
180    fn schedule() -> crate::ScheduleDefinition {
181        serde_json::from_value(serde_json::json!({
182            "name": "order_retry", "handler": "retry_orders", "spec": {"kind": "every", "secs": 30}
183        }))
184        .expect("schedule")
185    }
186
187    fn private_table() -> TableDefinition {
188        serde_json::from_value(serde_json::json!({
189            "name": "loans", "managed": false,
190            "columns": [{"name": "body", "column_type": "text"}]
191        }))
192        .expect("table")
193    }
194
195    /// One case per R3 field: where the field lands in the manifest JSON,
196    /// the manifest before it is set, the same manifest with only that
197    /// field set, and the features it demands.
198    fn r3_fields() -> Vec<(
199        &'static str,
200        ServiceManifest,
201        ServiceManifest,
202        Vec<&'static str>,
203    )> {
204        use crate::{
205            AckMode, ConsumerNaming, HandlerType, JobConfig, MigrationHistory, Pagination, RetryOn,
206            SchemaOwnership,
207        };
208        let base = || manifest(SAMPLE);
209        let subscribed = || {
210            let mut m = base();
211            m.subscriptions.insert(
212                0,
213                SubscriptionDefinition::new("dw.{tenant}.items.created", "h", HandlerType::Wasm),
214            );
215            m
216        };
217        let service =
218            |pointer, after: ServiceManifest, features| (pointer, base(), after, features);
219        let table = |pointer, edit: fn(&mut TableDefinition), features| {
220            let mut after = base();
221            edit(&mut after.tables[0]);
222            (pointer, base(), after, features)
223        };
224        let route = |pointer, edit: fn(&mut crate::CustomRouteDefinition), features| {
225            let mut after = base();
226            edit(&mut after.custom_routes[0]);
227            (pointer, base(), after, features)
228        };
229        let subscription = |pointer, edit: fn(&mut SubscriptionDefinition), features| {
230            let mut after = subscribed();
231            edit(&mut after.subscriptions[0]);
232            (pointer, subscribed(), after, features)
233        };
234        vec![
235            service(
236                "/consumers",
237                ServiceManifest {
238                    consumers: ConsumerNaming::Scoped,
239                    ..base()
240                },
241                vec![NATS_CONSUMER_CONFIG],
242            ),
243            service(
244                "/streams",
245                ServiceManifest {
246                    streams: vec![stream()],
247                    ..base()
248                },
249                vec![STREAMS],
250            ),
251            service(
252                "/schedules",
253                ServiceManifest {
254                    schedules: vec![schedule()],
255                    ..base()
256                },
257                vec![SCHEDULES],
258            ),
259            service(
260                "/schedules/0/job/retry_on",
261                ServiceManifest {
262                    schedules: vec![crate::ScheduleDefinition {
263                        job: Some(JobConfig {
264                            retry_on: vec![RetryOn::Timeout],
265                            ..Default::default()
266                        }),
267                        ..schedule()
268                    }],
269                    ..base()
270                },
271                vec![SCHEDULES, JOBS_V2],
272            ),
273            service(
274                "/schema",
275                ServiceManifest {
276                    schema: SchemaOwnership::Adopted,
277                    ..base()
278                },
279                vec![SCHEMA_ADOPTED],
280            ),
281            service(
282                "/schema_name",
283                ServiceManifest {
284                    schema_name: Some("loan_service".into()),
285                    ..base()
286                },
287                vec![SCHEMA_ADOPTED],
288            ),
289            service(
290                "/migrations",
291                ServiceManifest {
292                    migrations: vec![sql_migration()],
293                    ..base()
294                },
295                vec![SQL_MIGRATIONS],
296            ),
297            service(
298                "/extensions",
299                ServiceManifest {
300                    extensions: vec!["google.token".into()],
301                    ..base()
302                },
303                vec![EXT_CALL],
304            ),
305            service(
306                "/provides",
307                ServiceManifest {
308                    mode: crate::ServiceMode::Native,
309                    provides: vec![crate::ProvidedExtension {
310                        name: "google.token".into(),
311                        callers: vec!["reports-service".into()],
312                    }],
313                    ..base()
314                },
315                vec![NATIVE_SERVICES],
316            ),
317            service(
318                "/migration_history",
319                ServiceManifest {
320                    migration_history: Some(MigrationHistory::default()),
321                    ..base()
322                },
323                vec![],
324            ),
325            subscription(
326                "/subscriptions/0/ack",
327                |s| s.ack = AckMode::OnSuccess,
328                vec![NATS_CONSUMER_CONFIG],
329            ),
330            subscription(
331                "/subscriptions/0/consumer",
332                |s| s.consumer = Some(Default::default()),
333                vec![NATS_CONSUMER_CONFIG],
334            ),
335            subscription(
336                "/subscriptions/0/timeout_secs",
337                |s| s.timeout_secs = Some(30),
338                vec![NATS_CONSUMER_CONFIG],
339            ),
340            table(
341                "/tables/0/managed",
342                |t| t.managed = false,
343                vec![SCHEMA_ADOPTED],
344            ),
345            table(
346                "/tables/0/checks",
347                |t| {
348                    t.checks.push(crate::CheckConstraint {
349                        name: "c".into(),
350                        expr: "a > 0".into(),
351                    })
352                },
353                vec![COLUMN_TYPES_V2],
354            ),
355            table(
356                "/tables/0/search",
357                |t| t.search = Some(Default::default()),
358                vec![COLUMN_TYPES_V2],
359            ),
360            table(
361                "/tables/0/indexes/0/predicate",
362                |t| {
363                    t.indexes.insert(
364                        0,
365                        crate::IndexDefinition {
366                            name: "i".into(),
367                            columns: vec!["id".into()],
368                            predicate: Some("id IS NOT NULL".into()),
369                            ..Default::default()
370                        },
371                    )
372                },
373                vec![COLUMN_TYPES_V2],
374            ),
375            table(
376                "/tables/0/indexes/0/method",
377                |t| {
378                    t.indexes.insert(
379                        0,
380                        crate::IndexDefinition {
381                            name: "i".into(),
382                            columns: vec!["id".into()],
383                            method: crate::IndexMethod::Hash,
384                            ..Default::default()
385                        },
386                    )
387                },
388                vec![COLUMN_TYPES_V2],
389            ),
390            table(
391                "/tables/0/audit_redact",
392                |t| t.audit_redact.push("dob".into()),
393                vec![AUDIT_REDACTION],
394            ),
395            table(
396                "/tables/0/sensitive",
397                |t| t.sensitive = true,
398                vec![AUDIT_REDACTION],
399            ),
400            route(
401                "/custom_routes/0/audit_redact",
402                |r| r.audit_redact.push("card.number".into()),
403                vec![AUDIT_REDACTION],
404            ),
405            route(
406                "/custom_routes/0/max_concurrent",
407                |r| r.max_concurrent = Some(4),
408                vec![ROUTE_CONCURRENCY],
409            ),
410            table(
411                "/tables/0/pagination",
412                |t| t.pagination = Pagination::Keyset,
413                vec![KEYSET_LISTS],
414            ),
415            table(
416                "/tables/0/columns/1/access",
417                |t| {
418                    t.columns[1].access = Some(crate::ColumnAccess {
419                        read: Some("internal".into()),
420                        write: None,
421                    })
422                },
423                vec![COLUMN_PERMISSIONS],
424            ),
425            service(
426                "/private_tables",
427                ServiceManifest {
428                    private_tables: vec![private_table()],
429                    ..base()
430                },
431                vec![PRIVATE_TABLES, SCHEMA_ADOPTED],
432            ),
433        ]
434    }
435
436    fn json(manifest: &ServiceManifest) -> Value {
437        serde_json::to_value(manifest).expect("serialize")
438    }
439
440    #[test]
441    fn every_r3_field_is_absent_until_it_is_set() {
442        for (pointer, before, after, _) in r3_fields() {
443            assert_eq!(json(&before).pointer(pointer), None, "{pointer}");
444            assert!(json(&after).pointer(pointer).is_some(), "{pointer}");
445        }
446    }
447
448    #[test]
449    fn every_r3_field_changes_the_hash_and_round_trips() {
450        for (pointer, before, after, _) in r3_fields() {
451            assert_ne!(
452                after.deploy_hash().expect("hash"),
453                before.deploy_hash().expect("hash"),
454                "{pointer}"
455            );
456            let wire = json(&after);
457            let reparsed: ServiceManifest = serde_json::from_value(wire.clone()).expect("parse");
458            assert_eq!(json(&reparsed), wire, "{pointer}");
459        }
460    }
461
462    #[test]
463    fn every_r3_field_demands_its_features() {
464        for (pointer, before, after, demanded) in r3_fields() {
465            assert_eq!(
466                before.derived_features(),
467                BTreeSet::from([ROUTE_POSTURE]),
468                "{pointer}"
469            );
470            let expected: BTreeSet<&str> = demanded.into_iter().chain([ROUTE_POSTURE]).collect();
471            assert_eq!(after.derived_features(), expected, "{pointer}");
472        }
473    }
474
475    #[test]
476    fn deploy_hash_unchanged_for_legacy_manifest() {
477        for (json, deploy, schema) in [
478            (
479                SAMPLE,
480                "eee68954141745273b4157ffa4da676f33db4a908f63949bc9a4d7a2a437a17e",
481                "c1bfff7742704c2d5c1849a6eb964cece595080d25bcddf9e899dbd137ce5978",
482            ),
483            (
484                TODO,
485                "927dfafc66a426e311dc92eee91f2964e6ad450f7d57266a8cab4300a1359bab",
486                "aa0463d8a7e1fd9e3c6bca62fa0480e4ee6ce4083d17441f02061dca699e9907",
487            ),
488            (
489                BLOG,
490                "9d931c651c3bff3d9be5105b7867ffae01913757bdd8db0a13b1337dd17b7ae5",
491                "98cd636af82779af1d42ccc7f7daff5b1fd0e70cb553a15233ce4a4e3655e43f",
492            ),
493            (
494                LEGACY_SHAPES,
495                "6bcd77c75c7980173facd21b6032b82636e6ec4119c0c6636acd1a67a1f2343a",
496                "11dc0ab100791653dc63e43a52d5c870ccf2a9d6997f64775664887400107439",
497            ),
498        ] {
499            let m = manifest(json);
500            assert_eq!(m.deploy_hash().expect("hash"), deploy, "{}", m.name);
501            assert_eq!(m.schema_hash().expect("schema hash"), schema, "{}", m.name);
502        }
503    }
504
505    #[test]
506    fn a_manifest_without_private_tables_serialises_as_before() {
507        for json in [SAMPLE, TODO, BLOG, LEGACY_SHAPES] {
508            let stored: Value = serde_json::from_str(json).expect("fixture json");
509            let m = manifest(json);
510            assert!(m.private_tables.is_empty(), "{}", m.name);
511            let wire = serde_json::to_value(&m).expect("serialize");
512            assert_eq!(wire.get("private_tables"), None, "{}", m.name);
513            assert_eq!(wire, stored, "{}", m.name);
514            assert_eq!(m.declared_tables().count(), m.tables.len(), "{}", m.name);
515        }
516    }
517
518    #[test]
519    fn private_tables_demand_their_feature() {
520        let mut m = sample_manifest();
521        assert!(m.derived_features().is_empty());
522        m.private_tables = vec![private_table()];
523        assert_eq!(
524            m.derived_features(),
525            BTreeSet::from([PRIVATE_TABLES, SCHEMA_ADOPTED])
526        );
527        m.private_tables[0].columns[0].column_type = crate::ColumnType::JsonbArray;
528        assert_eq!(
529            m.derived_features(),
530            BTreeSet::from([COLUMN_TYPES_V2, PRIVATE_TABLES, SCHEMA_ADOPTED])
531        );
532    }
533
534    #[test]
535    fn legacy_manifest_json_round_trips_unchanged() {
536        for json in [SAMPLE, TODO, BLOG, LEGACY_SHAPES] {
537            let stored: Value = serde_json::from_str(json).expect("fixture json");
538            let reserialized = serde_json::to_value(manifest(json)).expect("serialize");
539            assert_eq!(reserialized, stored);
540        }
541    }
542
543    #[test]
544    fn a_legacy_manifest_demands_what_it_demanded_of_v0_27_0() {
545        assert_eq!(
546            manifest(LEGACY_SHAPES).derived_features(),
547            BTreeSet::from([
548                "api_key_posture",
549                "error_profile",
550                "internal_posture",
551                "route_limits",
552                "route_matching",
553                "route_posture",
554                "scoped_permissions",
555                "scoped_posture",
556                "sse_bridge",
557            ])
558        );
559    }
560}