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