From 0933ececa40a58e4614f9293c004ca357dcaa7d1 Mon Sep 17 00:00:00 2001 From: Benjamin Naecker Date: Wed, 5 Jun 2024 21:11:33 +0000 Subject: [PATCH] Define timeseries schema in TOML This is the first commit in a thread of work to support updates to timeseries schema. In our first step, we're moving the definition of timeseries from the Rust structs we use today, to TOML text files. Each file describes one target and all the metrics associated with it, from which we generate exactly the same Rust code that it replaces. This opens the door to a lot of downstream work, including well-defined updates to schema; detection of conflicting schema; improved metadata for timeseries; and generation of documentation. As an example, this moves exactly one (1) timeseries into the new format, the physical datalink statistics tracked through illumos kstats. - Move the `oximeter` crate into a private implementation crate, and re-export it at its original path - Add intermediate representation of timeseries schema, and code for deserializing TOML timeseries definitions, checking them for conflicts, and handling updates. Note that **no actual updates** are currently supported. The ingesting code includes a check that version numbers are exactly 1, except in tests. We include tests that check updates in a number of ways, but until the remainder of the work is done, we limit timeseries in the wild to their current version of 1. - Add a macro for consuming timeseries definitions in TOML, and generating the equivalent Rust code. Developers should use this new `oximeter::use_timeseries!()` proc macro to create new timeseries. - Add an integration test in Nexus that pulls in timeseries schema ingested with with `oximeter::use_timeseries!()`, and checks them all for conflicts. As developers add new timeseries in TOML format, they must be added here as well to ensure we detect problems at CI time. - Updates `kstat-rs` dep to simplify platform-specific code. This is part of this commit because we need the definitions of the physical-data-link timeseries for our integration test, but those were previously only compiled on illumos platforms. They're now included everywhere, while the tests remain illumos-only. --- Cargo.lock | 265 ++-- Cargo.toml | 6 + nexus/Cargo.toml | 2 +- nexus/tests/integration_tests/mod.rs | 1 + .../integration_tests/timeseries_schema.rs | 38 + openapi/bootstrap-agent.json | 10 + openapi/nexus-internal.json | 10 + openapi/nexus.json | 95 ++ openapi/sled-agent.json | 10 + openapi/wicketd.json | 12 + oximeter/db/src/lib.rs | 15 + oximeter/db/src/model.rs | 18 +- oximeter/db/src/oxql/ast/grammar.rs | 2 +- oximeter/db/src/query.rs | 32 + oximeter/impl/Cargo.toml | 35 + oximeter/{oximeter => impl}/src/histogram.rs | 8 +- oximeter/impl/src/lib.rs | 45 + oximeter/impl/src/schema/codegen.rs | 391 +++++ oximeter/impl/src/schema/ir.rs | 1288 +++++++++++++++++ .../src/schema.rs => impl/src/schema/mod.rs} | 128 +- oximeter/{oximeter => impl}/src/test_util.rs | 2 +- oximeter/{oximeter => impl}/src/traits.rs | 40 +- oximeter/{oximeter => impl}/src/types.rs | 42 +- .../{oximeter => impl}/tests/fail/failures.rs | 0 .../tests/fail/failures.stderr | 0 .../tests/test_compilation.rs | 0 oximeter/instruments/Cargo.toml | 7 +- .../schema/physical-data-link.toml | 90 ++ oximeter/instruments/src/kstat/link.rs | 179 +-- oximeter/instruments/src/kstat/mod.rs | 57 +- oximeter/instruments/src/kstat/sampler.rs | 89 +- oximeter/instruments/src/lib.rs | 2 +- oximeter/oximeter-macro-impl/src/lib.rs | 4 + oximeter/oximeter/Cargo.toml | 24 +- oximeter/oximeter/src/bin/oximeter-schema.rs | 97 ++ oximeter/oximeter/src/lib.rs | 151 +- oximeter/timeseries-macro/Cargo.toml | 17 + oximeter/timeseries-macro/src/lib.rs | 60 + schema/all-zone-requests.json | 1 + schema/rss-sled-plan.json | 10 + sled-agent/src/metrics.rs | 124 +- workspace-hack/Cargo.toml | 12 +- 42 files changed, 2888 insertions(+), 531 deletions(-) create mode 100644 nexus/tests/integration_tests/timeseries_schema.rs create mode 100644 oximeter/impl/Cargo.toml rename oximeter/{oximeter => impl}/src/histogram.rs (99%) create mode 100644 oximeter/impl/src/lib.rs create mode 100644 oximeter/impl/src/schema/codegen.rs create mode 100644 oximeter/impl/src/schema/ir.rs rename oximeter/{oximeter/src/schema.rs => impl/src/schema/mod.rs} (84%) rename oximeter/{oximeter => impl}/src/test_util.rs (98%) rename oximeter/{oximeter => impl}/src/traits.rs (90%) rename oximeter/{oximeter => impl}/src/types.rs (96%) rename oximeter/{oximeter => impl}/tests/fail/failures.rs (100%) rename oximeter/{oximeter => impl}/tests/fail/failures.stderr (100%) rename oximeter/{oximeter => impl}/tests/test_compilation.rs (100%) create mode 100644 oximeter/instruments/schema/physical-data-link.toml create mode 100644 oximeter/oximeter/src/bin/oximeter-schema.rs create mode 100644 oximeter/timeseries-macro/Cargo.toml create mode 100644 oximeter/timeseries-macro/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 01e6f04557b..2f41183517f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -166,7 +166,7 @@ dependencies = [ "omicron-workspace-hack", "proc-macro2", "quote", - "syn 2.0.64", + "syn 2.0.66", ] [[package]] @@ -273,7 +273,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.64", + "syn 2.0.66", ] [[package]] @@ -295,7 +295,7 @@ checksum = "16e62a023e7c117e27523144c5d2459f4397fcc3cab0085af8e2224f643a0193" dependencies = [ "proc-macro2", "quote", - "syn 2.0.64", + "syn 2.0.66", ] [[package]] @@ -306,7 +306,7 @@ checksum = "c6fa2087f2753a7da8cc1c0dbfcf89579dd57458e36769de5ac750b4671737ca" dependencies = [ "proc-macro2", "quote", - "syn 2.0.64", + "syn 2.0.66", ] [[package]] @@ -359,7 +359,7 @@ dependencies = [ "quote", "serde", "serde_tokenstream", - "syn 2.0.64", + "syn 2.0.66", ] [[package]] @@ -518,7 +518,7 @@ dependencies = [ "regex", "rustc-hash", "shlex", - "syn 2.0.64", + "syn 2.0.66", "which", ] @@ -696,7 +696,7 @@ dependencies = [ "omicron-workspace-hack", "oxnet", "progenitor", - "regress", + "regress 0.9.1", "reqwest", "schemars", "serde", @@ -1038,7 +1038,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.64", + "syn 2.0.66", ] [[package]] @@ -1516,7 +1516,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.64", + "syn 2.0.66", ] [[package]] @@ -1540,7 +1540,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.64", + "syn 2.0.66", ] [[package]] @@ -1551,7 +1551,7 @@ checksum = "733cabb43482b1a1b53eee8583c2b9e8684d592215ea83efd305dd31bc2f0178" dependencies = [ "darling_core", "quote", - "syn 2.0.64", + "syn 2.0.66", ] [[package]] @@ -1585,7 +1585,7 @@ dependencies = [ "quote", "serde", "serde_tokenstream", - "syn 2.0.64", + "syn 2.0.66", ] [[package]] @@ -1628,7 +1628,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.64", + "syn 2.0.66", ] [[package]] @@ -1661,7 +1661,7 @@ checksum = "5fe87ce4529967e0ba1dcf8450bab64d97dfd5010a6256187ffe2e43e6f0e049" dependencies = [ "proc-macro2", "quote", - "syn 2.0.64", + "syn 2.0.66", ] [[package]] @@ -1682,7 +1682,7 @@ checksum = "62d671cc41a825ebabc75757b62d3d168c577f9149b2d49ece1dad1f72119d25" dependencies = [ "proc-macro2", "quote", - "syn 2.0.64", + "syn 2.0.66", ] [[package]] @@ -1703,7 +1703,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.64", + "syn 2.0.66", ] [[package]] @@ -1713,7 +1713,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "206868b8242f27cecce124c19fd88157fbd0dd334df2587f36417bafbc85097b" dependencies = [ "derive_builder_core", - "syn 2.0.64", + "syn 2.0.66", ] [[package]] @@ -1790,7 +1790,7 @@ dependencies = [ "diesel_table_macro_syntax", "proc-macro2", "quote", - "syn 2.0.64", + "syn 2.0.66", ] [[package]] @@ -1799,7 +1799,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc5557efc453706fed5e4fa85006fe9817c224c3f480a34c7e5959fd700921c5" dependencies = [ - "syn 2.0.64", + "syn 2.0.66", ] [[package]] @@ -1991,7 +1991,7 @@ dependencies = [ "progenitor-client", "quote", "rand 0.8.5", - "regress", + "regress 0.9.1", "reqwest", "rustfmt-wrapper", "schemars", @@ -2057,7 +2057,7 @@ dependencies = [ "quote", "serde", "serde_tokenstream", - "syn 2.0.64", + "syn 2.0.66", ] [[package]] @@ -2466,7 +2466,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" dependencies = [ "proc-macro2", "quote", - "syn 2.0.64", + "syn 2.0.66", ] [[package]] @@ -2578,7 +2578,7 @@ checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" dependencies = [ "proc-macro2", "quote", - "syn 2.0.64", + "syn 2.0.66", ] [[package]] @@ -3475,7 +3475,7 @@ dependencies = [ "oxide-vpc", "oxlog", "oxnet", - "regress", + "regress 0.9.1", "schemars", "serde", "serde_json", @@ -3613,7 +3613,7 @@ dependencies = [ "installinator-common", "omicron-workspace-hack", "progenitor", - "regress", + "regress 0.9.1", "reqwest", "schemars", "serde", @@ -3866,14 +3866,14 @@ version = "0.1.0" source = "git+https://github.com/oxidecomputer/opte?rev=417f74e94978c23f3892ac328c3387f3ecd9bb29#417f74e94978c23f3892ac328c3387f3ecd9bb29" dependencies = [ "quote", - "syn 2.0.64", + "syn 2.0.66", ] [[package]] name = "kstat-rs" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcc713c7902f757cf0c04012dbad3864ea505f2660467b704847ea7ea2ff6d67" +checksum = "27964e4632377753acb0898ce6f28770d50cbca1339200ae63d700cff97b5c2b" dependencies = [ "libc", "thiserror", @@ -3992,7 +3992,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c2a198fb6b0eada2a8df47933734e6d35d350665a33a3593d7164fa52c75c19" dependencies = [ "cfg-if", - "windows-targets 0.52.5", + "windows-targets 0.48.5", ] [[package]] @@ -4355,7 +4355,7 @@ dependencies = [ "cfg-if", "proc-macro2", "quote", - "syn 2.0.64", + "syn 2.0.66", ] [[package]] @@ -4503,7 +4503,7 @@ dependencies = [ "omicron-workspace-hack", "oxnet", "progenitor", - "regress", + "regress 0.9.1", "reqwest", "schemars", "serde", @@ -4718,7 +4718,7 @@ dependencies = [ "omicron-workspace-hack", "proc-macro2", "quote", - "syn 2.0.64", + "syn 2.0.66", ] [[package]] @@ -4914,7 +4914,7 @@ version = "0.1.0" dependencies = [ "omicron-workspace-hack", "quote", - "syn 2.0.64", + "syn 2.0.66", ] [[package]] @@ -5085,7 +5085,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.64", + "syn 2.0.66", ] [[package]] @@ -5295,7 +5295,7 @@ dependencies = [ "progenitor-client", "proptest", "rand 0.8.5", - "regress", + "regress 0.9.1", "reqwest", "schemars", "semver 1.0.23", @@ -5932,7 +5932,7 @@ dependencies = [ "string_cache", "subtle", "syn 1.0.109", - "syn 2.0.64", + "syn 2.0.66", "time", "time-macros", "tokio", @@ -6052,7 +6052,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.64", + "syn 2.0.66", ] [[package]] @@ -6166,7 +6166,7 @@ dependencies = [ "omicron-workspace-hack", "progenitor", "rand 0.8.5", - "regress", + "regress 0.9.1", "reqwest", "serde", "serde_json", @@ -6195,22 +6195,15 @@ dependencies = [ name = "oximeter" version = "0.1.0" dependencies = [ - "approx", - "bytes", - "chrono", - "num", - "omicron-common", + "anyhow", + "clap", "omicron-workspace-hack", + "oximeter-impl", "oximeter-macro-impl", - "regex", - "rstest", - "schemars", - "serde", - "serde_json", - "strum", - "thiserror", - "trybuild", - "uuid", + "oximeter-timeseries-macro", + "prettyplease", + "syn 2.0.66", + "toml 0.8.13", ] [[package]] @@ -6315,6 +6308,34 @@ dependencies = [ "uuid", ] +[[package]] +name = "oximeter-impl" +version = "0.1.0" +dependencies = [ + "approx", + "bytes", + "chrono", + "heck 0.5.0", + "num", + "omicron-common", + "omicron-workspace-hack", + "oximeter-macro-impl", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rstest", + "schemars", + "serde", + "serde_json", + "strum", + "syn 2.0.66", + "thiserror", + "toml 0.8.13", + "trybuild", + "uuid", +] + [[package]] name = "oximeter-instruments" version = "0.1.0" @@ -6325,6 +6346,7 @@ dependencies = [ "futures", "http 0.2.12", "kstat-rs", + "libc", "omicron-workspace-hack", "oximeter", "rand 0.8.5", @@ -6343,7 +6365,7 @@ dependencies = [ "omicron-workspace-hack", "proc-macro2", "quote", - "syn 2.0.64", + "syn 2.0.66", ] [[package]] @@ -6371,6 +6393,17 @@ dependencies = [ "uuid", ] +[[package]] +name = "oximeter-timeseries-macro" +version = "0.1.0" +dependencies = [ + "omicron-workspace-hack", + "oximeter-impl", + "proc-macro2", + "quote", + "syn 2.0.66", +] + [[package]] name = "oxlog" version = "0.1.0" @@ -6524,7 +6557,7 @@ dependencies = [ "regex", "regex-syntax 0.8.3", "structmeta 0.3.0", - "syn 2.0.64", + "syn 2.0.66", ] [[package]] @@ -6692,7 +6725,7 @@ dependencies = [ "pest_meta", "proc-macro2", "quote", - "syn 2.0.64", + "syn 2.0.66", ] [[package]] @@ -6762,7 +6795,7 @@ checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965" dependencies = [ "proc-macro2", "quote", - "syn 2.0.64", + "syn 2.0.66", ] [[package]] @@ -7032,7 +7065,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f12335488a2f3b0a83b14edad48dca9879ce89b2edd10e80237e4e852dd645e" dependencies = [ "proc-macro2", - "syn 2.0.64", + "syn 2.0.66", ] [[package]] @@ -7080,9 +7113,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.82" +version = "1.0.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ad3d49ab951a01fbaafe34f2ec74122942fe18a3f9814c3268f1bb72042131b" +checksum = "22244ce15aa966053a896d1accb3a6e68469b97c7f33f284b99f0d576879fc23" dependencies = [ "unicode-ident", ] @@ -7128,7 +7161,7 @@ dependencies = [ "schemars", "serde", "serde_json", - "syn 2.0.64", + "syn 2.0.66", "thiserror", "typify", "unicode-ident", @@ -7148,7 +7181,7 @@ dependencies = [ "serde_json", "serde_tokenstream", "serde_yaml", - "syn 2.0.64", + "syn 2.0.66", ] [[package]] @@ -7628,7 +7661,7 @@ checksum = "bcc303e793d3734489387d205e9b186fac9c6cfacedd98cbb2e8a5943595f3e6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.64", + "syn 2.0.66", ] [[package]] @@ -7682,6 +7715,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "regress" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16fe0a24af5daaae947294213d2fd2646fbf5e1fbacc1d4ba3e84b2393854842" +dependencies = [ + "hashbrown 0.14.5", + "memchr", +] + [[package]] name = "relative-path" version = "1.9.3" @@ -7882,7 +7925,7 @@ dependencies = [ "regex", "relative-path", "rustc_version 0.4.0", - "syn 2.0.64", + "syn 2.0.66", "unicode-ident", ] @@ -8263,9 +8306,9 @@ dependencies = [ [[package]] name = "schemars" -version = "0.8.20" +version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0218ceea14babe24a4a5836f86ade86c1effbc198164e619194cb5069187e29" +checksum = "09c024468a378b7e36765cd36702b7a90cc3cba11654f6685c8f233408e89e92" dependencies = [ "bytes", "chrono", @@ -8278,14 +8321,14 @@ dependencies = [ [[package]] name = "schemars_derive" -version = "0.8.20" +version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed5a1ccce8ff962e31a165d41f6e2a2dd1245099dc4d594f5574a86cd90f4d3" +checksum = "b1eee588578aff73f856ab961cd2f79e36bc45d7ded33a7562adba4667aecc0e" dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.64", + "syn 2.0.66", ] [[package]] @@ -8311,7 +8354,7 @@ checksum = "7f81c2fde025af7e69b1d1420531c8a8811ca898919db177141a85313b1cb932" dependencies = [ "proc-macro2", "quote", - "syn 2.0.64", + "syn 2.0.66", ] [[package]] @@ -8440,7 +8483,7 @@ checksum = "500cbc0ebeb6f46627f50f3f5811ccf6bf00643be300b4c3eabc0ef55dc5b5ba" dependencies = [ "proc-macro2", "quote", - "syn 2.0.64", + "syn 2.0.66", ] [[package]] @@ -8451,7 +8494,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.64", + "syn 2.0.66", ] [[package]] @@ -8501,7 +8544,7 @@ checksum = "6c64451ba24fc7a6a2d60fc75dd9c83c90903b19028d4eff35e88fc1e86564e9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.64", + "syn 2.0.66", ] [[package]] @@ -8522,7 +8565,7 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.64", + "syn 2.0.66", ] [[package]] @@ -8564,7 +8607,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.64", + "syn 2.0.66", ] [[package]] @@ -8754,7 +8797,7 @@ dependencies = [ "omicron-workspace-hack", "oxnet", "progenitor", - "regress", + "regress 0.9.1", "reqwest", "schemars", "serde", @@ -8912,7 +8955,7 @@ source = "git+https://github.com/oxidecomputer/slog-error-chain?branch=main#15f6 dependencies = [ "proc-macro2", "quote", - "syn 2.0.64", + "syn 2.0.66", ] [[package]] @@ -9039,7 +9082,7 @@ dependencies = [ "heck 0.4.1", "proc-macro2", "quote", - "syn 2.0.64", + "syn 2.0.66", ] [[package]] @@ -9166,7 +9209,7 @@ checksum = "01b2e185515564f15375f593fb966b5718bc624ba77fe49fa4616ad619690554" dependencies = [ "proc-macro2", "quote", - "syn 2.0.64", + "syn 2.0.66", ] [[package]] @@ -9176,7 +9219,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ff9eaf853dec4c8802325d8b6d3dffa86cc707fd7a1a4cdbf416e13b061787a" dependencies = [ "quote", - "syn 2.0.64", + "syn 2.0.66", ] [[package]] @@ -9262,7 +9305,7 @@ dependencies = [ "proc-macro2", "quote", "structmeta-derive 0.2.0", - "syn 2.0.64", + "syn 2.0.66", ] [[package]] @@ -9274,7 +9317,7 @@ dependencies = [ "proc-macro2", "quote", "structmeta-derive 0.3.0", - "syn 2.0.64", + "syn 2.0.66", ] [[package]] @@ -9285,7 +9328,7 @@ checksum = "a60bcaff7397072dca0017d1db428e30d5002e00b6847703e2e42005c95fbe00" dependencies = [ "proc-macro2", "quote", - "syn 2.0.64", + "syn 2.0.66", ] [[package]] @@ -9296,7 +9339,7 @@ checksum = "152a0b65a590ff6c3da95cabe2353ee04e6167c896b28e3b14478c2636c922fc" dependencies = [ "proc-macro2", "quote", - "syn 2.0.64", + "syn 2.0.66", ] [[package]] @@ -9331,7 +9374,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.64", + "syn 2.0.66", ] [[package]] @@ -9344,7 +9387,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.64", + "syn 2.0.66", ] [[package]] @@ -9391,9 +9434,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.64" +version = "2.0.66" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ad3dee41f36859875573074334c200d1add8e4a87bb37113ebd31d926b7b11f" +checksum = "c42f3f41a2de00b01c0aaad383c5a45241efc8b2d1eda5661812fda5f3cdcff5" dependencies = [ "proc-macro2", "quote", @@ -9567,7 +9610,7 @@ dependencies = [ "proc-macro2", "quote", "structmeta 0.2.0", - "syn 2.0.64", + "syn 2.0.66", ] [[package]] @@ -9583,22 +9626,22 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.60" +version = "1.0.61" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "579e9083ca58dd9dcf91a9923bb9054071b9ebbd800b342194c9feb0ee89fc18" +checksum = "c546c80d6be4bc6a00c0f01730c08df82eaa7a7a61f11d656526506112cc1709" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.60" +version = "1.0.61" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2470041c06ec3ac1ab38d0356a6119054dedaea53e12fbefc0de730a1c08524" +checksum = "46c3384250002a6d5af4d114f2845d37b57521033f30d5c3f46c4d70e1197533" dependencies = [ "proc-macro2", "quote", - "syn 2.0.64", + "syn 2.0.66", ] [[package]] @@ -9735,7 +9778,7 @@ checksum = "8d9ef545650e79f30233c0003bcc2504d7efac6dad25fca40744de773fe2049c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.64", + "syn 2.0.66", ] [[package]] @@ -9806,7 +9849,7 @@ checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.64", + "syn 2.0.66", ] [[package]] @@ -10083,7 +10126,7 @@ checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.64", + "syn 2.0.66", ] [[package]] @@ -10340,7 +10383,7 @@ checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" [[package]] name = "typify" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/typify#ad1296f6ceb998ae8c247d999b7828703a232bdd" +source = "git+https://github.com/oxidecomputer/typify#d36f65eea4356374a1d7e68009ecc210756ec755" dependencies = [ "typify-impl", "typify-macro", @@ -10349,18 +10392,18 @@ dependencies = [ [[package]] name = "typify-impl" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/typify#ad1296f6ceb998ae8c247d999b7828703a232bdd" +source = "git+https://github.com/oxidecomputer/typify#d36f65eea4356374a1d7e68009ecc210756ec755" dependencies = [ "heck 0.5.0", "log", "proc-macro2", "quote", - "regress", + "regress 0.10.0", "schemars", "semver 1.0.23", "serde", "serde_json", - "syn 2.0.64", + "syn 2.0.66", "thiserror", "unicode-ident", ] @@ -10368,7 +10411,7 @@ dependencies = [ [[package]] name = "typify-macro" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/typify#ad1296f6ceb998ae8c247d999b7828703a232bdd" +source = "git+https://github.com/oxidecomputer/typify#d36f65eea4356374a1d7e68009ecc210756ec755" dependencies = [ "proc-macro2", "quote", @@ -10377,7 +10420,7 @@ dependencies = [ "serde", "serde_json", "serde_tokenstream", - "syn 2.0.64", + "syn 2.0.66", "typify-impl", ] @@ -10584,7 +10627,7 @@ dependencies = [ "proc-macro2", "quote", "serde_tokenstream", - "syn 2.0.64", + "syn 2.0.66", "usdt-impl", ] @@ -10602,7 +10645,7 @@ dependencies = [ "quote", "serde", "serde_json", - "syn 2.0.64", + "syn 2.0.66", "thiserror", "thread-id", "version_check", @@ -10618,7 +10661,7 @@ dependencies = [ "proc-macro2", "quote", "serde_tokenstream", - "syn 2.0.64", + "syn 2.0.66", "usdt-impl", ] @@ -10797,7 +10840,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.64", + "syn 2.0.66", "wasm-bindgen-shared", ] @@ -10831,7 +10874,7 @@ checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.64", + "syn 2.0.66", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -11071,7 +11114,7 @@ dependencies = [ "omicron-common", "omicron-workspace-hack", "progenitor", - "regress", + "regress 0.9.1", "reqwest", "schemars", "serde", @@ -11411,7 +11454,7 @@ checksum = "125139de3f6b9d625c39e2efdd73d41bdac468ccd556556440e322be0e1bbd91" dependencies = [ "proc-macro2", "quote", - "syn 2.0.64", + "syn 2.0.66", ] [[package]] @@ -11422,7 +11465,7 @@ checksum = "15e934569e47891f7d9411f1a451d947a60e000ab3bd24fbb970f000387d1b3b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.64", + "syn 2.0.66", ] [[package]] @@ -11442,7 +11485,7 @@ checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" dependencies = [ "proc-macro2", "quote", - "syn 2.0.64", + "syn 2.0.66", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 489c7a15522..10ed3aa7677 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,10 +58,12 @@ members = [ "nexus/types", "oximeter/collector", "oximeter/db", + "oximeter/impl", "oximeter/instruments", "oximeter/oximeter-macro-impl", "oximeter/oximeter", "oximeter/producer", + "oximeter/timeseries-macro", "package", "passwords", "rpaths", @@ -141,10 +143,12 @@ default-members = [ "nexus/types", "oximeter/collector", "oximeter/db", + "oximeter/impl", "oximeter/instruments", "oximeter/oximeter-macro-impl", "oximeter/oximeter", "oximeter/producer", + "oximeter/timeseries-macro", "package", "passwords", "rpaths", @@ -371,9 +375,11 @@ oximeter = { path = "oximeter/oximeter" } oximeter-client = { path = "clients/oximeter-client" } oximeter-db = { path = "oximeter/db/" } oximeter-collector = { path = "oximeter/collector" } +oximeter-impl = { path = "oximeter/impl" } oximeter-instruments = { path = "oximeter/instruments" } oximeter-macro-impl = { path = "oximeter/oximeter-macro-impl" } oximeter-producer = { path = "oximeter/producer" } +oximeter-timeseries-macro = { path = "oximeter/timeseries-macro" } p256 = "0.13" parse-display = "0.9.0" partial-io = { version = "0.5.4", features = ["proptest1", "tokio1"] } diff --git a/nexus/Cargo.toml b/nexus/Cargo.toml index 58a1e824cb3..a9faea83fdd 100644 --- a/nexus/Cargo.toml +++ b/nexus/Cargo.toml @@ -133,7 +133,7 @@ rcgen.workspace = true regex.workspace = true similar-asserts.workspace = true sp-sim.workspace = true -rustls = { workspace = true } +rustls.workspace = true subprocess.workspace = true term.workspace = true trust-dns-resolver.workspace = true diff --git a/nexus/tests/integration_tests/mod.rs b/nexus/tests/integration_tests/mod.rs index 5054527c636..207e2bf2e3a 100644 --- a/nexus/tests/integration_tests/mod.rs +++ b/nexus/tests/integration_tests/mod.rs @@ -41,6 +41,7 @@ mod sp_updater; mod ssh_keys; mod subnet_allocation; mod switch_port; +mod timeseries_schema; mod unauthorized; mod unauthorized_coverage; mod updates; diff --git a/nexus/tests/integration_tests/timeseries_schema.rs b/nexus/tests/integration_tests/timeseries_schema.rs new file mode 100644 index 00000000000..30e317d3eeb --- /dev/null +++ b/nexus/tests/integration_tests/timeseries_schema.rs @@ -0,0 +1,38 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +use std::collections::BTreeMap; + +/// This test checks that changes to timeseries schema are all consistent. +/// +/// Timeseries schema are described in a TOML format that makes it relatively +/// easy to add new versions of the timeseries. Those definitions are ingested +/// at compile-time and checked for self-consistency, but it's still possible +/// for two unrelated definitions to conflict. This test catches those. +/// +/// As developers define new timeseries, those should be added to this test for +/// checking. To define metrics in their own crates, developers use the +/// proc-macro `oximeter::use_timeseries!()` +#[test] +fn timeseries_schema_consistency() { + let mut all_schema = BTreeMap::new(); + for list in + [oximeter_instruments::kstat::link::physical_data_link::timeseries_schema()] + { + for schema in list { + let key = (schema.timeseries_name.clone(), schema.version); + if let Some(dup) = all_schema.insert(key, schema.clone()) { + assert_eq!( + dup, schema, + "Timeseries '{}' version {} is duplicated, but the \ + schema themselves differ. This will lead to a conflict \ + at registration time, so at least one of the schema \ + should be renamed, re-versioned, or include different \ + fields to differentiate them.", + dup.timeseries_name, dup.version, + ); + }; + } + } +} diff --git a/openapi/bootstrap-agent.json b/openapi/bootstrap-agent.json index 5d175e7b09f..6050939b940 100644 --- a/openapi/bootstrap-agent.json +++ b/openapi/bootstrap-agent.json @@ -328,6 +328,7 @@ "checker": { "nullable": true, "description": "Checker to apply to incoming messages.", + "default": null, "type": "string" }, "originate": { @@ -340,6 +341,7 @@ "shaper": { "nullable": true, "description": "Shaper to apply to outgoing messages.", + "default": null, "type": "string" } }, @@ -437,6 +439,7 @@ "local_pref": { "nullable": true, "description": "Apply a local preference to routes received from this peer.", + "default": null, "type": "integer", "format": "uint32", "minimum": 0 @@ -444,11 +447,13 @@ "md5_auth_key": { "nullable": true, "description": "Use the given key for TCP-MD5 authentication with the peer.", + "default": null, "type": "string" }, "min_ttl": { "nullable": true, "description": "Require messages from a peer have a minimum IP time to live field.", + "default": null, "type": "integer", "format": "uint8", "minimum": 0 @@ -456,6 +461,7 @@ "multi_exit_discriminator": { "nullable": true, "description": "Apply the provided multi-exit discriminator (MED) updates sent to the peer.", + "default": null, "type": "integer", "format": "uint32", "minimum": 0 @@ -467,6 +473,7 @@ "remote_asn": { "nullable": true, "description": "Require that a peer has a specified ASN.", + "default": null, "type": "integer", "format": "uint32", "minimum": 0 @@ -474,6 +481,7 @@ "vlan_id": { "nullable": true, "description": "Associate a VLAN ID with a BGP peer session.", + "default": null, "type": "integer", "format": "uint16", "minimum": 0 @@ -1192,6 +1200,7 @@ "vlan_id": { "nullable": true, "description": "The VLAN id associated with this route.", + "default": null, "type": "integer", "format": "uint16", "minimum": 0 @@ -1234,6 +1243,7 @@ "vlan_id": { "nullable": true, "description": "The VLAN id (if any) associated with this address.", + "default": null, "type": "integer", "format": "uint16", "minimum": 0 diff --git a/openapi/nexus-internal.json b/openapi/nexus-internal.json index 17330b09744..03458032d0a 100644 --- a/openapi/nexus-internal.json +++ b/openapi/nexus-internal.json @@ -1567,6 +1567,7 @@ "checker": { "nullable": true, "description": "Checker to apply to incoming messages.", + "default": null, "type": "string" }, "originate": { @@ -1579,6 +1580,7 @@ "shaper": { "nullable": true, "description": "Shaper to apply to outgoing messages.", + "default": null, "type": "string" } }, @@ -1676,6 +1678,7 @@ "local_pref": { "nullable": true, "description": "Apply a local preference to routes received from this peer.", + "default": null, "type": "integer", "format": "uint32", "minimum": 0 @@ -1683,11 +1686,13 @@ "md5_auth_key": { "nullable": true, "description": "Use the given key for TCP-MD5 authentication with the peer.", + "default": null, "type": "string" }, "min_ttl": { "nullable": true, "description": "Require messages from a peer have a minimum IP time to live field.", + "default": null, "type": "integer", "format": "uint8", "minimum": 0 @@ -1695,6 +1700,7 @@ "multi_exit_discriminator": { "nullable": true, "description": "Apply the provided multi-exit discriminator (MED) updates sent to the peer.", + "default": null, "type": "integer", "format": "uint32", "minimum": 0 @@ -1706,6 +1712,7 @@ "remote_asn": { "nullable": true, "description": "Require that a peer has a specified ASN.", + "default": null, "type": "integer", "format": "uint32", "minimum": 0 @@ -1713,6 +1720,7 @@ "vlan_id": { "nullable": true, "description": "Associate a VLAN ID with a BGP peer session.", + "default": null, "type": "integer", "format": "uint16", "minimum": 0 @@ -4257,6 +4265,7 @@ "vlan_id": { "nullable": true, "description": "The VLAN id associated with this route.", + "default": null, "type": "integer", "format": "uint16", "minimum": 0 @@ -4899,6 +4908,7 @@ "vlan_id": { "nullable": true, "description": "The VLAN id (if any) associated with this address.", + "default": null, "type": "integer", "format": "uint16", "minimum": 0 diff --git a/openapi/nexus.json b/openapi/nexus.json index 01ec9aeb569..4e47710d223 100644 --- a/openapi/nexus.json +++ b/openapi/nexus.json @@ -9344,6 +9344,39 @@ } ] }, + "AuthzScope": { + "description": "Authorization scope for a timeseries.\n\nThis describes the level at which a user must be authorized to read data from a timeseries. For example, fleet-scoping means the data is only visible to an operator or fleet reader. Project-scoped, on the other hand, indicates that a user will see data limited to the projects on which they have read permissions.", + "oneOf": [ + { + "description": "Timeseries data is limited to fleet readers.", + "type": "string", + "enum": [ + "fleet" + ] + }, + { + "description": "Timeseries data is limited to the authorized silo for a user.", + "type": "string", + "enum": [ + "silo" + ] + }, + { + "description": "Timeseries data is limited to the authorized projects for a user.", + "type": "string", + "enum": [ + "project" + ] + }, + { + "description": "The timeseries is viewable to all without limitation.", + "type": "string", + "enum": [ + "none" + ] + } + ] + }, "Baseboard": { "description": "Properties that uniquely identify an Oxide hardware component", "type": "object", @@ -12548,6 +12581,10 @@ "description": "The name and type information for a field of a timeseries schema.", "type": "object", "properties": { + "description": { + "default": "", + "type": "string" + }, "field_type": { "$ref": "#/components/schemas/FieldType" }, @@ -15992,6 +16029,7 @@ "signing_keypair": { "nullable": true, "description": "request signing key pair", + "default": null, "allOf": [ { "$ref": "#/components/schemas/DerEncodedKeyPair" @@ -17852,6 +17890,22 @@ "points" ] }, + "TimeseriesDescription": { + "description": "Text descriptions for the target and metric of a timeseries.", + "type": "object", + "properties": { + "metric": { + "type": "string" + }, + "target": { + "type": "string" + } + }, + "required": [ + "metric", + "target" + ] + }, "TimeseriesName": { "title": "The name of a timeseries", "description": "Names are constructed by concatenating the target and metric names with ':'. Target and metric names must be lowercase alphanumeric characters with '_' separating words.", @@ -17875,6 +17929,14 @@ "description": "The schema for a timeseries.\n\nThis includes the name of the timeseries, as well as the datum type of its metric and the schema for each field.", "type": "object", "properties": { + "authz_scope": { + "default": "fleet", + "allOf": [ + { + "$ref": "#/components/schemas/AuthzScope" + } + ] + }, "created": { "type": "string", "format": "date-time" @@ -17882,6 +17944,17 @@ "datum_type": { "$ref": "#/components/schemas/DatumType" }, + "description": { + "default": { + "metric": "", + "target": "" + }, + "allOf": [ + { + "$ref": "#/components/schemas/TimeseriesDescription" + } + ] + }, "field_schema": { "type": "array", "items": { @@ -17891,6 +17964,20 @@ }, "timeseries_name": { "$ref": "#/components/schemas/TimeseriesName" + }, + "units": { + "default": "count", + "allOf": [ + { + "$ref": "#/components/schemas/Units" + } + ] + }, + "version": { + "default": 1, + "type": "integer", + "format": "uint8", + "minimum": 1 } }, "required": [ @@ -17981,6 +18068,14 @@ "items" ] }, + "Units": { + "description": "Measurement units for timeseries samples.", + "type": "string", + "enum": [ + "count", + "bytes" + ] + }, "User": { "description": "View of a User", "type": "object", diff --git a/openapi/sled-agent.json b/openapi/sled-agent.json index 79e3bac727e..c433cb135d3 100644 --- a/openapi/sled-agent.json +++ b/openapi/sled-agent.json @@ -1469,6 +1469,7 @@ "checker": { "nullable": true, "description": "Checker to apply to incoming messages.", + "default": null, "type": "string" }, "originate": { @@ -1481,6 +1482,7 @@ "shaper": { "nullable": true, "description": "Shaper to apply to outgoing messages.", + "default": null, "type": "string" } }, @@ -1578,6 +1580,7 @@ "local_pref": { "nullable": true, "description": "Apply a local preference to routes received from this peer.", + "default": null, "type": "integer", "format": "uint32", "minimum": 0 @@ -1585,11 +1588,13 @@ "md5_auth_key": { "nullable": true, "description": "Use the given key for TCP-MD5 authentication with the peer.", + "default": null, "type": "string" }, "min_ttl": { "nullable": true, "description": "Require messages from a peer have a minimum IP time to live field.", + "default": null, "type": "integer", "format": "uint8", "minimum": 0 @@ -1597,6 +1602,7 @@ "multi_exit_discriminator": { "nullable": true, "description": "Apply the provided multi-exit discriminator (MED) updates sent to the peer.", + "default": null, "type": "integer", "format": "uint32", "minimum": 0 @@ -1608,6 +1614,7 @@ "remote_asn": { "nullable": true, "description": "Require that a peer has a specified ASN.", + "default": null, "type": "integer", "format": "uint32", "minimum": 0 @@ -1615,6 +1622,7 @@ "vlan_id": { "nullable": true, "description": "Associate a VLAN ID with a BGP peer session.", + "default": null, "type": "integer", "format": "uint16", "minimum": 0 @@ -4161,6 +4169,7 @@ "vlan_id": { "nullable": true, "description": "The VLAN id associated with this route.", + "default": null, "type": "integer", "format": "uint16", "minimum": 0 @@ -4452,6 +4461,7 @@ "vlan_id": { "nullable": true, "description": "The VLAN id (if any) associated with this address.", + "default": null, "type": "integer", "format": "uint16", "minimum": 0 diff --git a/openapi/wicketd.json b/openapi/wicketd.json index df133403347..c69331893ae 100644 --- a/openapi/wicketd.json +++ b/openapi/wicketd.json @@ -1049,6 +1049,7 @@ "checker": { "nullable": true, "description": "Checker to apply to incoming messages.", + "default": null, "type": "string" }, "originate": { @@ -1061,6 +1062,7 @@ "shaper": { "nullable": true, "description": "Shaper to apply to outgoing messages.", + "default": null, "type": "string" } }, @@ -2854,6 +2856,7 @@ "vlan_id": { "nullable": true, "description": "The VLAN id associated with this route.", + "default": null, "type": "integer", "format": "uint16", "minimum": 0 @@ -5008,6 +5011,7 @@ "vlan_id": { "nullable": true, "description": "The VLAN id (if any) associated with this address.", + "default": null, "type": "integer", "format": "uint16", "minimum": 0 @@ -5028,6 +5032,7 @@ }, "allowed_export": { "description": "Apply export policy to this peer with an allow list.", + "default": null, "allOf": [ { "$ref": "#/components/schemas/UserSpecifiedImportExportPolicy" @@ -5036,6 +5041,7 @@ }, "allowed_import": { "description": "Apply import policy to this peer with an allow list.", + "default": null, "allOf": [ { "$ref": "#/components/schemas/UserSpecifiedImportExportPolicy" @@ -5051,6 +5057,7 @@ "auth_key_id": { "nullable": true, "description": "The key identifier for authentication to use with the peer.", + "default": null, "allOf": [ { "$ref": "#/components/schemas/BgpAuthKeyId" @@ -5110,6 +5117,7 @@ "local_pref": { "nullable": true, "description": "Apply a local preference to routes received from this peer.", + "default": null, "type": "integer", "format": "uint32", "minimum": 0 @@ -5117,6 +5125,7 @@ "min_ttl": { "nullable": true, "description": "Require messages from a peer have a minimum IP time to live field.", + "default": null, "type": "integer", "format": "uint8", "minimum": 0 @@ -5124,6 +5133,7 @@ "multi_exit_discriminator": { "nullable": true, "description": "Apply the provided multi-exit discriminator (MED) updates sent to the peer.", + "default": null, "type": "integer", "format": "uint32", "minimum": 0 @@ -5135,6 +5145,7 @@ "remote_asn": { "nullable": true, "description": "Require that a peer has a specified ASN.", + "default": null, "type": "integer", "format": "uint32", "minimum": 0 @@ -5142,6 +5153,7 @@ "vlan_id": { "nullable": true, "description": "Associate a VLAN ID with a BGP peer session.", + "default": null, "type": "integer", "format": "uint16", "minimum": 0 diff --git a/oximeter/db/src/lib.rs b/oximeter/db/src/lib.rs index e1570ee0c33..e78e546d4ab 100644 --- a/oximeter/db/src/lib.rs +++ b/oximeter/db/src/lib.rs @@ -160,6 +160,11 @@ impl From for TimeseriesSchema { schema.timeseries_name.as_str(), ) .expect("Invalid timeseries name in database"), + // TODO(ben): Convert from real values once in the DB. + description: Default::default(), + version: oximeter::schema::default_schema_version(), + authz_scope: oximeter::schema::AuthzScope::fleet(), + units: oximeter::schema::Units::count(), field_schema: schema.field_schema.into(), datum_type: schema.datum_type.into(), created: schema.created, @@ -236,9 +241,13 @@ pub struct TimeseriesPageSelector { pub(crate) type TimeseriesKey = u64; +// TODO(ben) The timeseries key needs to take the schema version into account. +// This will require a schema change to ClickHouse and a destructive update, +// since all previous keys will be invalid. pub(crate) fn timeseries_key(sample: &Sample) -> TimeseriesKey { timeseries_key_for( &sample.timeseries_name, + // sample.timeseries_version sample.sorted_target_fields(), sample.sorted_metric_fields(), sample.measurement.datum_type(), @@ -389,11 +398,13 @@ mod tests { name: String::from("later"), field_type: FieldType::U64, source: FieldSource::Target, + description: String::new(), }; let metric_field = FieldSchema { name: String::from("earlier"), field_type: FieldType::U64, source: FieldSource::Metric, + description: String::new(), }; let timeseries_name: TimeseriesName = "foo:bar".parse().unwrap(); let datum_type = DatumType::U64; @@ -401,6 +412,10 @@ mod tests { [target_field.clone(), metric_field.clone()].into_iter().collect(); let expected_schema = TimeseriesSchema { timeseries_name: timeseries_name.clone(), + description: Default::default(), + version: oximeter::schema::default_schema_version(), + authz_scope: oximeter::schema::AuthzScope::fleet(), + units: oximeter::schema::Units::count(), field_schema, datum_type, created: Utc::now(), diff --git a/oximeter/db/src/model.rs b/oximeter/db/src/model.rs index 106c347ef6f..35fcaace60d 100644 --- a/oximeter/db/src/model.rs +++ b/oximeter/db/src/model.rs @@ -107,6 +107,9 @@ pub(crate) struct DbFieldList { pub types: Vec, #[serde(rename = "fields.source")] pub sources: Vec, + // TODO(ben): Add descriptions here + //#[serde(rename = "fields.description")] + //pub descriptions: Vec, } impl From for BTreeSet { @@ -119,6 +122,7 @@ impl From for BTreeSet { name, field_type: ty.into(), source: source.into(), + description: String::new(), }) .collect() } @@ -147,6 +151,8 @@ pub(crate) struct DbTimeseriesSchema { pub datum_type: DbDatumType, #[serde(with = "serde_timestamp")] pub created: DateTime, + // TODO(ben): Add authz scope, version, and units here, once they are + // tracked in CRDB and ClickHouse. } impl From for DbTimeseriesSchema { @@ -516,7 +522,7 @@ declare_histogram_measurement_row! { HistogramF64MeasurementRow, DbHistogram BTreeMap> { let mut out = BTreeMap::new(); for field in sample.fields() { - let timeseries_name = sample.timeseries_name.clone(); + let timeseries_name = sample.timeseries_name.to_string(); let timeseries_key = crate::timeseries_key(sample); let field_name = field.name.clone(); let (table_name, row_string) = match &field.value { @@ -664,7 +670,11 @@ pub(crate) fn unroll_measurement_row(sample: &Sample) -> (String, String) { let timeseries_name = sample.timeseries_name.clone(); let timeseries_key = crate::timeseries_key(sample); let measurement = &sample.measurement; - unroll_measurement_row_impl(timeseries_name, timeseries_key, measurement) + unroll_measurement_row_impl( + timeseries_name.to_string(), + timeseries_key, + measurement, + ) } /// Given a sample's measurement, return a table name and row to insert. @@ -1803,11 +1813,13 @@ mod tests { name: String::from("field0"), field_type: FieldType::I64, source: FieldSource::Target, + description: String::new(), }, FieldSchema { name: String::from("field1"), field_type: FieldType::IpAddr, source: FieldSource::Metric, + description: String::new(), }, ] .into_iter() @@ -1839,7 +1851,7 @@ mod tests { assert_eq!(out["oximeter.fields_i64"].len(), 1); let unpacked: StringFieldRow = serde_json::from_str(&out["oximeter.fields_string"][0]).unwrap(); - assert_eq!(unpacked.timeseries_name, sample.timeseries_name); + assert_eq!(sample.timeseries_name, unpacked.timeseries_name); let field = sample.target_fields().next().unwrap(); assert_eq!(unpacked.field_name, field.name); if let FieldValue::String(v) = &field.value { diff --git a/oximeter/db/src/oxql/ast/grammar.rs b/oximeter/db/src/oxql/ast/grammar.rs index a644dff41de..b6b22222986 100644 --- a/oximeter/db/src/oxql/ast/grammar.rs +++ b/oximeter/db/src/oxql/ast/grammar.rs @@ -257,7 +257,7 @@ peg::parser! { /// /// We support the following common escape sequences: /// - /// ```ignore + /// ```text /// \n /// \r /// \t diff --git a/oximeter/db/src/query.rs b/oximeter/db/src/query.rs index e14dfbbc557..2eac8f2a12b 100644 --- a/oximeter/db/src/query.rs +++ b/oximeter/db/src/query.rs @@ -777,16 +777,22 @@ mod tests { fn test_select_query_builder_filter_raw() { let schema = TimeseriesSchema { timeseries_name: TimeseriesName::try_from("foo:bar").unwrap(), + description: Default::default(), + version: oximeter::schema::default_schema_version(), + authz_scope: oximeter::schema::AuthzScope::fleet(), + units: oximeter::schema::Units::count(), field_schema: [ FieldSchema { name: "f0".to_string(), field_type: FieldType::I64, source: FieldSource::Target, + description: String::new(), }, FieldSchema { name: "f1".to_string(), field_type: FieldType::Bool, source: FieldSource::Target, + description: String::new(), }, ] .into_iter() @@ -910,6 +916,10 @@ mod tests { fn test_select_query_builder_no_fields() { let schema = TimeseriesSchema { timeseries_name: TimeseriesName::try_from("foo:bar").unwrap(), + description: Default::default(), + version: oximeter::schema::default_schema_version(), + authz_scope: oximeter::schema::AuthzScope::fleet(), + units: oximeter::schema::Units::count(), field_schema: BTreeSet::new(), datum_type: DatumType::I64, created: Utc::now(), @@ -932,6 +942,10 @@ mod tests { fn test_select_query_builder_limit_offset() { let schema = TimeseriesSchema { timeseries_name: TimeseriesName::try_from("foo:bar").unwrap(), + description: Default::default(), + version: oximeter::schema::default_schema_version(), + authz_scope: oximeter::schema::AuthzScope::fleet(), + units: oximeter::schema::Units::count(), field_schema: BTreeSet::new(), datum_type: DatumType::I64, created: Utc::now(), @@ -1002,16 +1016,22 @@ mod tests { fn test_select_query_builder_no_selectors() { let schema = TimeseriesSchema { timeseries_name: TimeseriesName::try_from("foo:bar").unwrap(), + description: Default::default(), + version: oximeter::schema::default_schema_version(), + authz_scope: oximeter::schema::AuthzScope::fleet(), + units: oximeter::schema::Units::count(), field_schema: [ FieldSchema { name: "f0".to_string(), field_type: FieldType::I64, source: FieldSource::Target, + description: String::new(), }, FieldSchema { name: "f1".to_string(), field_type: FieldType::Bool, source: FieldSource::Target, + description: String::new(), }, ] .into_iter() @@ -1065,16 +1085,22 @@ mod tests { fn test_select_query_builder_field_selectors() { let schema = TimeseriesSchema { timeseries_name: TimeseriesName::try_from("foo:bar").unwrap(), + description: Default::default(), + version: oximeter::schema::default_schema_version(), + authz_scope: oximeter::schema::AuthzScope::fleet(), + units: oximeter::schema::Units::count(), field_schema: [ FieldSchema { name: "f0".to_string(), field_type: FieldType::I64, source: FieldSource::Target, + description: String::new(), }, FieldSchema { name: "f1".to_string(), field_type: FieldType::Bool, source: FieldSource::Target, + description: String::new(), }, ] .into_iter() @@ -1116,16 +1142,22 @@ mod tests { fn test_select_query_builder_full() { let schema = TimeseriesSchema { timeseries_name: TimeseriesName::try_from("foo:bar").unwrap(), + description: Default::default(), + version: oximeter::schema::default_schema_version(), + authz_scope: oximeter::schema::AuthzScope::fleet(), + units: oximeter::schema::Units::count(), field_schema: [ FieldSchema { name: "f0".to_string(), field_type: FieldType::I64, source: FieldSource::Target, + description: String::new(), }, FieldSchema { name: "f1".to_string(), field_type: FieldType::Bool, source: FieldSource::Target, + description: String::new(), }, ] .into_iter() diff --git a/oximeter/impl/Cargo.toml b/oximeter/impl/Cargo.toml new file mode 100644 index 00000000000..9cbc8d3a4fb --- /dev/null +++ b/oximeter/impl/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "oximeter-impl" +version = "0.1.0" +edition = "2021" +license = "MPL-2.0" + +[lints] +workspace = true + +[dependencies] +bytes = { workspace = true, features = [ "serde" ] } +chrono.workspace = true +heck.workspace = true +num.workspace = true +omicron-common.workspace = true +omicron-workspace-hack.workspace = true +oximeter-macro-impl.workspace = true +prettyplease.workspace = true +proc-macro2.workspace = true +quote.workspace = true +regex.workspace = true +schemars = { workspace = true, features = [ "uuid1", "bytes", "chrono" ] } +serde.workspace = true +serde_json.workspace = true +strum.workspace = true +syn.workspace = true +toml.workspace = true +thiserror.workspace = true +uuid.workspace = true + +[dev-dependencies] +approx.workspace = true +rstest.workspace = true +serde_json.workspace = true +trybuild.workspace = true diff --git a/oximeter/oximeter/src/histogram.rs b/oximeter/impl/src/histogram.rs similarity index 99% rename from oximeter/oximeter/src/histogram.rs rename to oximeter/impl/src/histogram.rs index 82b99161532..e1bbd1f672a 100644 --- a/oximeter/oximeter/src/histogram.rs +++ b/oximeter/impl/src/histogram.rs @@ -4,7 +4,7 @@ //! Types for managing metrics that are histograms. -// Copyright 2023 Oxide Computer Company +// Copyright 2024 Oxide Computer Company use chrono::DateTime; use chrono::Utc; @@ -355,6 +355,9 @@ where /// Example /// ------- /// ```rust + /// # // Rename the impl crate so the doctests can refer to the public + /// # // `oximeter` crate, not the private impl. + /// # use oximeter_impl as oximeter; /// use oximeter::histogram::Histogram; /// /// let hist = Histogram::with_bins(&[(0..10).into(), (10..100).into()]).unwrap(); @@ -600,6 +603,9 @@ where /// ------- /// /// ```rust + /// # // Rename the impl crate so the doctests can refer to the public + /// # // `oximeter` crate, not the private impl. + /// # use oximeter_impl as oximeter; /// use oximeter::histogram::{Histogram, BinRange}; /// use std::ops::{RangeBounds, Bound}; /// diff --git a/oximeter/impl/src/lib.rs b/oximeter/impl/src/lib.rs new file mode 100644 index 00000000000..c601d8379c7 --- /dev/null +++ b/oximeter/impl/src/lib.rs @@ -0,0 +1,45 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +// Copyright 2024 Oxide Computer Company + +pub use oximeter_macro_impl::*; + +// Export the current crate as `oximeter`. The macros defined in `oximeter-macro-impl` generate +// code referring to symbols like `oximeter::traits::Target`. In consumers of this crate, that's +// fine, but internally there _is_ no crate named `oximeter`, it's just `self` or `crate`. +// +// See https://github.com/rust-lang/rust/pull/55275 for the PR introducing this fix, which links to +// lots of related issues and discussion. +extern crate self as oximeter; + +pub mod histogram; +pub mod schema; +pub mod test_util; +pub mod traits; +pub mod types; + +pub use schema::FieldSchema; +pub use schema::TimeseriesName; +pub use schema::TimeseriesSchema; +pub use traits::Metric; +pub use traits::Producer; +pub use traits::Target; +pub use types::Datum; +pub use types::DatumType; +pub use types::Field; +pub use types::FieldType; +pub use types::FieldValue; +pub use types::Measurement; +pub use types::MetricsError; +pub use types::Sample; + +/// Construct the timeseries name for a Target and Metric. +pub fn timeseries_name(target: &T, metric: &M) -> TimeseriesName +where + T: Target, + M: Metric, +{ + TimeseriesName(format!("{}:{}", target.name(), metric.name())) +} diff --git a/oximeter/impl/src/schema/codegen.rs b/oximeter/impl/src/schema/codegen.rs new file mode 100644 index 00000000000..f33d1d113c6 --- /dev/null +++ b/oximeter/impl/src/schema/codegen.rs @@ -0,0 +1,391 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +// Copyright 2024 Oxide Computer Company + +//! Generate Rust types and code from oximeter schema definitions. + +use crate::schema::ir::find_schema_version; +use crate::schema::ir::load_schema; +use crate::schema::AuthzScope; +use crate::schema::FieldSource; +use crate::schema::Units; +use crate::DatumType; +use crate::FieldSchema; +use crate::FieldType; +use crate::MetricsError; +use crate::TimeseriesSchema; +use chrono::prelude::DateTime; +use chrono::prelude::Utc; +use proc_macro2::TokenStream; +use quote::quote; + +/// Emit types for using one timeseries definition. +/// +/// Provided with a TOML-formatted schema definition, this emits Rust types for +/// the target and metric from the latest version; and a function that returns +/// the `TimeseriesSchema` for _all_ versions of the timeseries. +/// +/// Both of these items are emitted in a module with the same name as the +/// target. +pub fn use_timeseries(contents: &str) -> Result { + let schema = load_schema(contents)?; + let latest = find_schema_version(schema.iter().cloned(), None); + let mod_name = quote::format_ident!("{}", latest[0].component_names().0); + let types = emit_schema_types(latest); + let func = emit_schema_function(schema.into_iter()); + Ok(quote! { + pub mod #mod_name { + #types + #func + } + }) +} + +fn emit_schema_function( + list: impl Iterator, +) -> TokenStream { + quote! { + pub fn timeseries_schema() -> Vec<::oximeter::schema::TimeseriesSchema> { + vec![ + #(#list),* + ] + } + } +} + +fn emit_schema_types(list: Vec) -> TokenStream { + let first_schema = list.first().expect("load_schema ensures non-empty"); + let target_def = emit_target(first_schema); + let metric_defs = emit_metrics(&list); + quote! { + #target_def + #metric_defs + } +} + +fn emit_metrics(schema: &[TimeseriesSchema]) -> TokenStream { + let items = schema.iter().map(|s| emit_one(FieldSource::Metric, s)); + quote! { #(#items)* } +} + +fn emit_target(schema: &TimeseriesSchema) -> TokenStream { + emit_one(FieldSource::Target, schema) +} + +fn emit_one(source: FieldSource, schema: &TimeseriesSchema) -> TokenStream { + let names = schema.component_names(); + let name = match source { + FieldSource::Target => names.0, + FieldSource::Metric => names.1, + }; + let struct_name = + quote::format_ident!("{}", format!("{}", heck::AsPascalCase(name))); + let field_defs: Vec<_> = schema + .field_schema + .iter() + .filter_map(|s| { + if s.source == source { + let name = quote::format_ident!("{}", s.name); + let type_ = emit_rust_type_for_field(s.field_type); + let docstring = s.description.as_str(); + Some(quote! { + #[doc = #docstring] + pub #name: #type_ + }) + } else { + None + } + }) + .collect(); + let (oximeter_trait, maybe_datum, type_docstring) = match source { + FieldSource::Target => ( + quote! {::oximeter::Target }, + quote! {}, + schema.description.target.as_str(), + ), + FieldSource::Metric => { + let datum_type = emit_rust_type_for_datum_type(schema.datum_type); + ( + quote! { ::oximeter::Metric }, + quote! { pub datum: #datum_type }, + schema.description.metric.as_str(), + ) + } + }; + quote! { + #[doc = #type_docstring] + #[derive(Debug, Clone, #oximeter_trait, PartialEq)] + pub struct #struct_name { + #(#field_defs),* + #maybe_datum + } + } +} + +// Implement ToTokens for the components of a `TimeseriesSchema`. +// +// This is used so that we can emit a function that will return the same data as +// we parse from the TOML file with the timeseries definition, as a way to +// export the definitions without needing that actual file at runtime. +impl quote::ToTokens for DatumType { + fn to_tokens(&self, tokens: &mut TokenStream) { + let toks = match self { + DatumType::Bool => quote! { ::oximeter::DatumType::Bool }, + DatumType::I8 => quote! { ::oximeter::DatumType::I8 }, + DatumType::U8 => quote! { ::oximeter::DatumType::U8 }, + DatumType::I16 => quote! { ::oximeter::DatumType::I16 }, + DatumType::U16 => quote! { ::oximeter::DatumType::U16 }, + DatumType::I32 => quote! { ::oximeter::DatumType::I32 }, + DatumType::U32 => quote! { ::oximeter::DatumType::U32 }, + DatumType::I64 => quote! { ::oximeter::DatumType::I64 }, + DatumType::U64 => quote! { ::oximeter::DatumType::U64 }, + DatumType::F32 => quote! { ::oximeter::DatumType::F32 }, + DatumType::F64 => quote! { ::oximeter::DatumType::F64 }, + DatumType::String => quote! { ::oximeter::DatumType::String }, + DatumType::Bytes => quote! { ::oximeter::DatumType::Bytes }, + DatumType::CumulativeI64 => { + quote! { ::oximeter::DatumType::CumulativeI64 } + } + DatumType::CumulativeU64 => { + quote! { ::oximeter::DatumType::CumulativeU64 } + } + DatumType::CumulativeF32 => { + quote! { ::oximeter::DatumType::CumulativeF32 } + } + DatumType::CumulativeF64 => { + quote! { ::oximeter::DatumType::CumulativeF64 } + } + DatumType::HistogramI8 => { + quote! { ::oximeter::DatumType::HistogramI8 } + } + DatumType::HistogramU8 => { + quote! { ::oximeter::DatumType::HistogramU8 } + } + DatumType::HistogramI16 => { + quote! { ::oximeter::DatumType::HistogramI16 } + } + DatumType::HistogramU16 => { + quote! { ::oximeter::DatumType::HistogramU16 } + } + DatumType::HistogramI32 => { + quote! { ::oximeter::DatumType::HistogramI32 } + } + DatumType::HistogramU32 => { + quote! { ::oximeter::DatumType::HistogramU32 } + } + DatumType::HistogramI64 => { + quote! { ::oximeter::DatumType::HistogramI64 } + } + DatumType::HistogramU64 => { + quote! { ::oximeter::DatumType::HistogramU64 } + } + DatumType::HistogramF32 => { + quote! { ::oximeter::DatumType::HistogramF32 } + } + DatumType::HistogramF64 => { + quote! { ::oximeter::DatumType::HistogramF64 } + } + }; + toks.to_tokens(tokens); + } +} + +// Emit tokens representing the Rust path matching the provided datum type. +fn emit_rust_type_for_datum_type(datum_type: DatumType) -> TokenStream { + match datum_type { + DatumType::Bool => quote! { bool }, + DatumType::I8 => quote! { i8 }, + DatumType::U8 => quote! { u8 }, + DatumType::I16 => quote! { i16 }, + DatumType::U16 => quote! { u16 }, + DatumType::I32 => quote! { i32 }, + DatumType::U32 => quote! { u32 }, + DatumType::I64 => quote! { i64 }, + DatumType::U64 => quote! { u64 }, + DatumType::F32 => quote! { f32 }, + DatumType::F64 => quote! { f64 }, + DatumType::String => quote! { String }, + DatumType::Bytes => quote! { ::bytes::Bytes }, + DatumType::CumulativeI64 => { + quote! { ::oximeter::types::Cumulative } + } + DatumType::CumulativeU64 => { + quote! { ::oximeter::types::Cumulative } + } + DatumType::CumulativeF32 => { + quote! { ::oximeter::types::Cumulative } + } + DatumType::CumulativeF64 => { + quote! { ::oximeter::types::Cumulative } + } + DatumType::HistogramI8 => { + quote! { ::oximeter::histogram::Histogram } + } + DatumType::HistogramU8 => { + quote! { ::oximeter::histogram::Histogram } + } + DatumType::HistogramI16 => { + quote! { ::oximeter::histogram::Histogram } + } + DatumType::HistogramU16 => { + quote! { ::oximeter::histogram::Histogram } + } + DatumType::HistogramI32 => { + quote! { ::oximeter::histogram::Histogram } + } + DatumType::HistogramU32 => { + quote! { ::oximeter::histogram::Histogram } + } + DatumType::HistogramI64 => { + quote! { ::oximeter::histogram::Histogram } + } + DatumType::HistogramU64 => { + quote! { ::oximeter::histogram::Histogram } + } + DatumType::HistogramF32 => { + quote! { ::oximeter::histogram::Histogram } + } + DatumType::HistogramF64 => { + quote! { ::oximeter::histogram::Histogram } + } + } +} + +// Generate the quoted path to the Rust type matching the given field type. +fn emit_rust_type_for_field(field_type: FieldType) -> TokenStream { + match field_type { + FieldType::String => quote! { ::std::borrow::Cow<'static, str> }, + FieldType::I8 => quote! { i8 }, + FieldType::U8 => quote! { u8 }, + FieldType::I16 => quote! { i16 }, + FieldType::U16 => quote! { u16 }, + FieldType::I32 => quote! { i32 }, + FieldType::U32 => quote! { u32 }, + FieldType::I64 => quote! { i64 }, + FieldType::U64 => quote! { u64 }, + FieldType::IpAddr => quote! { ::core::net::IpAddr }, + FieldType::Uuid => quote! { ::uuid::Uuid }, + FieldType::Bool => quote! { bool }, + } +} + +impl quote::ToTokens for FieldSource { + fn to_tokens(&self, tokens: &mut TokenStream) { + let toks = match self { + FieldSource::Target => { + quote! { ::oximeter::schema::FieldSource::Target } + } + FieldSource::Metric => { + quote! { ::oximeter::schema::FieldSource::Metric } + } + }; + toks.to_tokens(tokens); + } +} + +impl quote::ToTokens for FieldType { + fn to_tokens(&self, tokens: &mut TokenStream) { + let toks = match self { + FieldType::String => quote! { ::oximeter::FieldType::String }, + FieldType::I8 => quote! { ::oximeter::FieldType::I8 }, + FieldType::U8 => quote! { ::oximeter::FieldType::U8 }, + FieldType::I16 => quote! { ::oximeter::FieldType::I16 }, + FieldType::U16 => quote! { ::oximeter::FieldType::U16 }, + FieldType::I32 => quote! { ::oximeter::FieldType::I32 }, + FieldType::U32 => quote! { ::oximeter::FieldType::U32 }, + FieldType::I64 => quote! { ::oximeter::FieldType::I64 }, + FieldType::U64 => quote! { ::oximeter::FieldType::U64 }, + FieldType::IpAddr => quote! { ::oximeter::FieldType::IpAddr }, + FieldType::Uuid => quote! { ::oximeter::FieldType::Uuid }, + FieldType::Bool => quote! { ::oximeter::FieldType::Bool }, + }; + toks.to_tokens(tokens); + } +} + +impl quote::ToTokens for AuthzScope { + fn to_tokens(&self, tokens: &mut TokenStream) { + let toks = match self { + AuthzScope::Fleet => { + quote! { ::oximeter::schema::AuthzScope::Fleet } + } + AuthzScope::Silo => quote! { ::oximeter::schema::AuthzScope::Silo }, + AuthzScope::Project => { + quote! { ::oximeter::schema::AuthzScope::Project } + } + AuthzScope::None => quote! { ::oximeter::schema::AuthzScope::None }, + }; + toks.to_tokens(tokens); + } +} + +fn quote_creation_time(created: DateTime) -> TokenStream { + let secs = created.timestamp(); + let nsecs = created.timestamp_subsec_nanos(); + quote! { + ::chrono::DateTime::from_timestamp(#secs, #nsecs).unwrap() + } +} + +impl quote::ToTokens for Units { + fn to_tokens(&self, tokens: &mut TokenStream) { + let toks = match self { + Units::Count => quote! { ::oximeter::schema::Units::Count }, + Units::Bytes => quote! { ::oximeter::schema::Units::Bytes }, + }; + toks.to_tokens(tokens); + } +} + +impl quote::ToTokens for FieldSchema { + fn to_tokens(&self, tokens: &mut TokenStream) { + let name = self.name.as_str(); + let field_type = self.field_type; + let source = self.source; + let description = self.description.as_str(); + let toks = quote! { + ::oximeter::FieldSchema { + name: String::from(#name), + field_type: #field_type, + source: #source, + description: String::from(#description), + } + }; + toks.to_tokens(tokens); + } +} + +impl quote::ToTokens for TimeseriesSchema { + fn to_tokens(&self, tokens: &mut TokenStream) { + let field_schema = &self.field_schema; + let timeseries_name = self.timeseries_name.to_string(); + let target_description = self.description.target.as_str(); + let metric_description = self.description.metric.as_str(); + let authz_scope = self.authz_scope; + let units = self.units; + let datum_type = self.datum_type; + let ver = self.version.get(); + let version = quote! { ::core::num::NonZeroU8::new(#ver).unwrap() }; + let created = quote_creation_time(self.created); + let toks = quote! { + ::oximeter::schema::TimeseriesSchema { + timeseries_name: ::oximeter::TimeseriesName::try_from(#timeseries_name).unwrap(), + description: ::oximeter::schema::TimeseriesDescription { + target: String::from(#target_description), + metric: String::from(#metric_description), + }, + authz_scope: #authz_scope, + units: #units, + field_schema: ::std::collections::BTreeSet::from([ + #(#field_schema),* + ]), + datum_type: #datum_type, + version: #version, + created: #created, + } + }; + toks.to_tokens(tokens); + } +} diff --git a/oximeter/impl/src/schema/ir.rs b/oximeter/impl/src/schema/ir.rs new file mode 100644 index 00000000000..819fc410d7b --- /dev/null +++ b/oximeter/impl/src/schema/ir.rs @@ -0,0 +1,1288 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +// Copyright 2024 Oxide Computer Company + +//! Serialization of timeseries schema definitions. +//! +//! These types are used as an intermediate representation of schema. The schema +//! are written in TOML; deserialized into these types; and then either +//! inspected or used to generate code that contains the equivalent Rust types +//! and trait implementations. + +use crate::schema::AuthzScope; +use crate::schema::DatumType; +use crate::schema::FieldSource; +use crate::schema::FieldType; +use crate::schema::TimeseriesDescription; +use crate::schema::Units; +use crate::FieldSchema; +use crate::MetricsError; +use crate::TimeseriesName; +use crate::TimeseriesSchema; +use chrono::Utc; +use serde::Deserialize; +use std::collections::btree_map::Entry; +use std::collections::BTreeMap; +use std::collections::BTreeSet; +use std::num::NonZeroU8; + +#[derive(Debug, Deserialize)] +pub struct FieldMetadata { + #[serde(rename = "type")] + pub type_: FieldType, + pub description: String, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +pub enum MetricFields { + Removed { removed_in: NonZeroU8 }, + Added { added_in: NonZeroU8, fields: Vec }, + Versioned(VersionedFields), +} + +#[derive(Debug, Deserialize)] +pub struct VersionedFields { + pub version: NonZeroU8, + pub fields: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct TargetDefinition { + pub name: String, + pub description: String, + pub authz_scope: AuthzScope, + pub versions: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct MetricDefinition { + pub name: String, + pub description: String, + pub units: Units, + pub datum_type: DatumType, + pub versions: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct TimeseriesDefinition { + pub target: TargetDefinition, + pub metrics: Vec, + pub fields: BTreeMap, +} + +impl TimeseriesDefinition { + pub fn into_schema_list( + self, + ) -> Result, MetricsError> { + if self.target.versions.is_empty() { + return defn_error(String::from( + "At least one target version must be defined", + )); + } + if self.metrics.is_empty() { + return defn_error(String::from( + "At least one metric must be defined", + )); + } + let mut timeseries = BTreeMap::new(); + let target_name = &self.target.name; + + // At this point, we do not support actually _modifying_ schema. + // Instead, we're putting in place infrastructure to support multiple + // versions, while still requiring all schema to define the first and + // only the first version. + // + // We omit this check in tests, to ensure that the code correctly + // handles updates. + #[cfg(not(test))] + if self.target.versions.len() > 1 + || self + .target + .versions + .iter() + .any(|fields| fields.version.get() > 1) + { + return defn_error(String::from( + "Exactly one timeseries version, with version number 1, \ + may currently be specified. Updates will be supported \ + in future releases.", + )); + } + + // First create a map from target version to the fields in it. + // + // This is used to do O(lg n) lookups into the set of target fields when we + // iterate through metric versions below, i.e., avoiding quadratic behavior. + let mut target_fields_by_version = BTreeMap::new(); + for (expected_version, target_fields) in + (1u8..).zip(self.target.versions.iter()) + { + if expected_version != target_fields.version.get() { + return defn_error(format!( + "Target '{}' versions should be sequential \ + and monotonically increasing (expected {}, found {})", + target_name, expected_version, target_fields.version, + )); + } + + let fields: BTreeSet<_> = + target_fields.fields.iter().cloned().collect(); + if fields.len() != target_fields.fields.len() { + return defn_error(format!( + "Target '{}' version {} lists duplicate field names", + target_name, expected_version, + )); + } + + if target_fields_by_version + .insert(expected_version, fields) + .is_some() + { + return defn_error(format!( + "Target '{}' version {} is duplicated", + target_name, expected_version, + )); + } + } + + // Start by looping over all the metrics in the definition. + // + // As we do so, we'll attach the target definition at the corresponding + // version, along with running some basic lints and checks. + for metric in self.metrics.iter() { + let metric_name = &metric.name; + + // Store the current version of the metric. This doesn't need to be + // sequential, but they do need to be monotonic and have a matching + // target version. We'll fill in any gaps with the last active version + // of the metric (if any). + let mut current_version: Option = None; + + // Also store the last used version of the target. This lets users omit + // an unchanged metric, and we use this value to fill in the implied + // version of the metric. + let mut last_target_version: u8 = 0; + + // Iterate through each version of this metric. + // + // In general, we expect metrics to be addded in the first version; + // modified by adding / removing fields; and possibly removed at the + // end. However, they can be added / removed multiple times, and not + // added until a later version of the target. + for metric_fields in metric.versions.iter() { + // Fill in any gaps from the last target version to this next + // metric version. This only works once we've filled in at least + // one version of the metric, and stored the current version / + // fields. + if let Some(current) = current_version.as_ref() { + let current_fields = current + .fields() + .expect("Should have some fields if we have any previous version"); + while last_target_version <= current.version().get() { + last_target_version += 1; + let Some(target_fields) = + target_fields_by_version.get(&last_target_version) + else { + return defn_error(format!( + "Metric '{}' version {} does not have \ + a matching version in the target '{}'", + metric_name, last_target_version, target_name, + )); + }; + let field_schema = construct_field_schema( + &self.fields, + target_name, + target_fields, + metric_name, + current_fields, + )?; + let authz_scope = extract_authz_scope( + metric_name, + self.target.authz_scope, + &field_schema, + )?; + let timeseries_name = TimeseriesName::try_from( + format!("{}:{}", target_name, metric_name), + )?; + let version = + NonZeroU8::new(last_target_version).unwrap(); + let description = TimeseriesDescription { + target: self.target.description.clone(), + metric: metric.description.clone(), + }; + let schema = TimeseriesSchema { + timeseries_name: timeseries_name.clone(), + description, + field_schema, + datum_type: metric.datum_type, + version, + authz_scope, + units: metric.units, + created: Utc::now(), + }; + if let Some(old) = timeseries + .insert((timeseries_name, version), schema) + { + return defn_error(format!( + "Timeseries '{}' version {} is duplicated", + old.timeseries_name, old.version, + )); + } + } + } + + // Extract the fields named in this version, checking that they're + // compatible with the last known version, if any. + let new_version = extract_metric_fields( + metric_name, + metric_fields, + ¤t_version, + )?; + let version = current_version.insert(new_version); + let Some(metric_fields) = version.fields() else { + continue; + }; + + // Now, insert the _next_ version of the metric with the + // validated fields we've collected for it. + last_target_version += 1; + let Some(target_fields) = + target_fields_by_version.get(&last_target_version) + else { + return defn_error(format!( + "Metric '{}' version {} does not have \ + a matching version in the target '{}'", + metric_name, last_target_version, target_name, + )); + }; + let field_schema = construct_field_schema( + &self.fields, + target_name, + target_fields, + metric_name, + metric_fields, + )?; + let authz_scope = extract_authz_scope( + metric_name, + self.target.authz_scope, + &field_schema, + )?; + let timeseries_name = TimeseriesName::try_from(format!( + "{}:{}", + target_name, metric_name + ))?; + let version = NonZeroU8::new(last_target_version).unwrap(); + let description = TimeseriesDescription { + target: self.target.description.clone(), + metric: metric.description.clone(), + }; + let schema = TimeseriesSchema { + timeseries_name: timeseries_name.clone(), + description, + field_schema, + datum_type: metric.datum_type, + version, + authz_scope, + units: metric.units, + created: Utc::now(), + }; + if let Some(old) = + timeseries.insert((timeseries_name, version), schema) + { + return defn_error(format!( + "Timeseries '{}' version {} is duplicated", + old.timeseries_name, old.version, + )); + } + } + + // We also allow omitting later versions of metrics if they are + // unchanged. A target has to specify every version, even if it's the + // same, but the metrics need only specify differences. + // + // Here, look for any target version strictly later than the last metric + // version, and create a corresponding target / metric pair for it. + if let Some(last_metric_fields) = metric.versions.last() { + match last_metric_fields { + MetricFields::Removed { .. } => {} + MetricFields::Added { + added_in: last_metric_version, + fields, + } + | MetricFields::Versioned(VersionedFields { + version: last_metric_version, + fields, + }) => { + let metric_field_names: BTreeSet<_> = + fields.iter().cloned().collect(); + let next_version = last_metric_version + .get() + .checked_add(1) + .expect("version < 256"); + for (version, target_fields) in + target_fields_by_version.range(next_version..) + { + let field_schema = construct_field_schema( + &self.fields, + target_name, + target_fields, + metric_name, + &metric_field_names, + )?; + let authz_scope = extract_authz_scope( + metric_name, + self.target.authz_scope, + &field_schema, + )?; + let timeseries_name = TimeseriesName::try_from( + format!("{}:{}", target_name, metric_name), + )?; + let version = NonZeroU8::new(*version).unwrap(); + let description = TimeseriesDescription { + target: self.target.description.clone(), + metric: metric.description.clone(), + }; + let schema = TimeseriesSchema { + timeseries_name: timeseries_name.clone(), + description, + field_schema, + datum_type: metric.datum_type, + version, + authz_scope, + units: metric.units, + created: Utc::now(), + }; + if let Some(old) = timeseries + .insert((timeseries_name, version), schema) + { + return defn_error(format!( + "Timeseries '{}' version {} is duplicated", + old.timeseries_name, old.version, + )); + } + } + } + } + } + } + Ok(timeseries.into_values().collect()) + } +} + +#[derive(Clone, Debug)] +enum CurrentVersion { + Active { version: NonZeroU8, fields: BTreeSet }, + Inactive { removed_in: NonZeroU8 }, +} + +impl CurrentVersion { + fn version(&self) -> NonZeroU8 { + match self { + CurrentVersion::Active { version, .. } => *version, + CurrentVersion::Inactive { removed_in } => *removed_in, + } + } + + fn fields(&self) -> Option<&BTreeSet> { + match self { + CurrentVersion::Active { fields, .. } => Some(fields), + CurrentVersion::Inactive { .. } => None, + } + } +} + +fn defn_error(s: String) -> Result { + Err(MetricsError::SchemaDefinition(s)) +} + +/// Load the list of timeseries schema from a schema definition in TOML format. +pub fn load_schema( + contents: &str, +) -> Result, MetricsError> { + toml::from_str::(contents) + .map_err(|e| MetricsError::Toml(e.to_string())) + .and_then(TimeseriesDefinition::into_schema_list) +} + +// Find schema of a specified version in an iterator, or the latest. +pub(super) fn find_schema_version( + list: impl Iterator, + version: Option, +) -> Vec { + match version { + Some(ver) => list.into_iter().filter(|s| s.version == ver).collect(), + None => { + let mut last_version = BTreeMap::new(); + for schema in list { + let metric_name = schema.component_names().1.to_string(); + match last_version.entry(metric_name) { + Entry::Vacant(entry) => { + entry.insert((schema.version, schema.clone())); + } + Entry::Occupied(mut entry) => { + let existing_version = entry.get().0; + if existing_version < schema.version { + entry.insert((schema.version, schema.clone())); + } + } + } + } + last_version.into_values().map(|(_ver, schema)| schema).collect() + } + } +} + +fn extract_authz_scope( + metric_name: &str, + authz_scope: AuthzScope, + field_schema: &BTreeSet, +) -> Result { + let check_for_key = |scope: &str| { + let key = format!("{scope}_id"); + if field_schema.iter().any(|field| { + field.name == key && field.field_type == FieldType::Uuid + }) { + Ok(()) + } else { + defn_error(format!( + "Metric '{}' has '{}' authorization scope, and so must \ + contain a field '{}' of UUID type", + metric_name, scope, key, + )) + } + }; + match authz_scope { + AuthzScope::Silo => check_for_key("silo")?, + AuthzScope::Project => check_for_key("project")?, + _ => {} + } + Ok(authz_scope) +} + +fn construct_field_schema( + all_fields: &BTreeMap, + target_name: &str, + target_fields: &BTreeSet, + metric_name: &str, + metric_field_names: &BTreeSet, +) -> Result, MetricsError> { + if let Some(dup) = target_fields.intersection(&metric_field_names).next() { + return defn_error(format!( + "Field '{}' is duplicated between target \ + '{}' and metric '{}'", + dup, target_name, metric_name, + )); + } + + let mut field_schema = BTreeSet::new(); + for (field_name, source) in + target_fields.iter().zip(std::iter::repeat(FieldSource::Target)).chain( + metric_field_names + .iter() + .zip(std::iter::repeat(FieldSource::Metric)), + ) + { + let Some(metadata) = all_fields.get(field_name.as_str()) else { + let (kind, name) = if source == FieldSource::Target { + ("target", target_name) + } else { + ("metric", metric_name) + }; + return defn_error(format!( + "Field '{}' is referenced in the {} '{}', but it \ + does not appear in the set of all fields.", + field_name, kind, name, + )); + }; + validate_field_name(field_name, metadata)?; + field_schema.insert(FieldSchema { + name: field_name.to_string(), + field_type: metadata.type_, + source, + description: metadata.description.clone(), + }); + } + Ok(field_schema) +} + +fn is_snake_case(s: &str) -> bool { + s == format!("{}", heck::AsSnakeCase(s)) +} + +fn is_valid_ident_name(s: &str) -> bool { + syn::parse_str::(s).is_ok() && is_snake_case(s) +} + +fn validate_field_name( + field_name: &str, + metadata: &FieldMetadata, +) -> Result<(), MetricsError> { + if !is_valid_ident_name(field_name) { + return defn_error(format!( + "Field name '{}' should be a valid identifier in snake_case", + field_name, + )); + } + if metadata.type_ == FieldType::Uuid + && !(field_name.ends_with("_id") || field_name == "id") + { + return defn_error(format!( + "Uuid field '{}' should end with '_id' or equal 'id'", + field_name, + )); + } + Ok(()) +} + +fn extract_metric_fields<'a>( + metric_name: &'a str, + metric_fields: &'a MetricFields, + current_version: &Option, +) -> Result { + let new_version = match metric_fields { + MetricFields::Removed { removed_in } => { + match current_version { + Some(CurrentVersion::Active { version, .. }) => { + // This metric was active, and is now being + // removed. Bump the version and mark it active, + // but there are no fields to return here. + if removed_in <= version { + return defn_error(format!( + "Metric '{}' is removed in version \ + {}, which is not strictly after the \ + current active version, {}", + metric_name, removed_in, version, + )); + } + CurrentVersion::Inactive { removed_in: *removed_in } + } + Some(CurrentVersion::Inactive { removed_in }) => { + return defn_error(format!( + "Metric '{}' was already removed in \ + version {}, it cannot be removed again", + metric_name, removed_in, + )); + } + None => { + return defn_error(format!( + "Metric {} has no previous version, \ + it cannot be removed.", + metric_name, + )); + } + } + } + MetricFields::Added { added_in, fields } => { + match current_version { + Some(CurrentVersion::Active { .. }) => { + return defn_error(format!( + "Metric '{}' is already active, it \ + cannot be added again until it is removed.", + metric_name, + )); + } + Some(CurrentVersion::Inactive { removed_in }) => { + // The metric is currently inactive, just check + // that the newly-active version is greater. + if added_in <= removed_in { + return defn_error(format!( + "Re-added metric '{}' must appear in a later \ + version than the one in which it was removed ({})", + metric_name, removed_in, + )); + } + CurrentVersion::Active { + version: *added_in, + fields: to_unique_field_names(metric_name, fields)?, + } + } + None => { + // There was no previous version for this + // metric, just add it. + CurrentVersion::Active { + version: *added_in, + fields: to_unique_field_names(metric_name, fields)?, + } + } + } + } + MetricFields::Versioned(new_fields) => { + match current_version { + Some(CurrentVersion::Active { version, .. }) => { + // The happy-path, we're stepping the version + // and possibly modifying the fields. + if new_fields.version <= *version { + return defn_error(format!( + "Metric '{}' version should increment, \ + expected at least {}, found {}", + metric_name, + version.checked_add(1).expect("version < 256"), + new_fields.version, + )); + } + CurrentVersion::Active { + version: new_fields.version, + fields: to_unique_field_names( + metric_name, + &new_fields.fields, + )?, + } + } + Some(CurrentVersion::Inactive { removed_in }) => { + // The metric has been removed in the past, it + // needs to be added again first. + return defn_error(format!( + "Metric '{}' was removed in version {}, \ + it must be added again first", + metric_name, removed_in, + )); + } + None => { + // The metric never existed, it must be added + // first. + return defn_error(format!( + "Metric '{}' must be added in at its first \ + version, and can then be modified", + metric_name, + )); + } + } + } + }; + Ok(new_version) +} + +fn to_unique_field_names( + name: &str, + fields: &[String], +) -> Result, MetricsError> { + let set: BTreeSet<_> = fields.iter().cloned().collect(); + if set.len() != fields.len() { + return defn_error(format!("Object '{name}' has duplicate fields")); + } + Ok(set) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extract_authz_scope_requires_relevant_field() { + assert!( + extract_authz_scope("foo", AuthzScope::Project, &BTreeSet::new()) + .is_err(), + "Project-scoped auth without a project_id field should be an error" + ); + + let schema = std::iter::once(FieldSchema { + name: "project_id".to_string(), + field_type: FieldType::Uuid, + source: FieldSource::Target, + description: String::new(), + }) + .collect(); + extract_authz_scope("foo", AuthzScope::Project, &schema).expect( + "Project-scoped auth with a project_id field should succeed", + ); + + let schema = std::iter::once(FieldSchema { + name: "project_id".to_string(), + field_type: FieldType::String, + source: FieldSource::Target, + description: String::new(), + }) + .collect(); + assert!( + extract_authz_scope("foo", AuthzScope::Project, &schema).is_err(), + "Project-scoped auth with a project_id field \ + that's not a UUID should be an error", + ); + } + + fn all_fields() -> BTreeMap { + let mut out = BTreeMap::new(); + for name in ["foo", "bar"] { + out.insert( + String::from(name), + FieldMetadata { + type_: FieldType::U8, + description: String::new(), + }, + ); + } + out + } + + #[test] + fn construct_field_schema_fails_with_reference_to_unknown_field() { + let all = all_fields(); + let target_fields = BTreeSet::new(); + let bad_name = String::from("baz"); + let metric_fields = std::iter::once(bad_name).collect(); + assert!( + construct_field_schema( + &all, + "target", + &target_fields, + "metric", + &metric_fields, + ) + .is_err(), + "Should return an error when the metric references a field \ + that doesn't exist in the global field list" + ); + } + + #[test] + fn construct_field_schema_fails_with_duplicate_field_names() { + let all = all_fields(); + let name = String::from("bar"); + let target_fields = std::iter::once(name.clone()).collect(); + let metric_fields = std::iter::once(name).collect(); + assert!(construct_field_schema( + &all, + "target", + &target_fields, + "metric", + &metric_fields, + ).is_err(), + "Should return an error when the target and metric share a field name" + ); + } + + #[test] + fn construct_field_schema_picks_up_correct_fields() { + let all = all_fields(); + let all_schema = all + .iter() + .zip([FieldSource::Metric, FieldSource::Target]) + .map(|((name, md), source)| FieldSchema { + name: name.clone(), + field_type: md.type_, + source, + description: String::new(), + }) + .collect(); + let foo = String::from("foo"); + let target_fields = std::iter::once(foo).collect(); + let bar = String::from("bar"); + let metric_fields = std::iter::once(bar).collect(); + assert_eq!( + construct_field_schema( + &all, + "target", + &target_fields, + "metric", + &metric_fields, + ) + .unwrap(), + all_schema, + "Each field is referenced exactly once, so we should return \ + the entire input set of fields" + ); + } + + #[test] + fn validate_field_name_disallows_bad_names() { + for name in ["PascalCase", "with spaces", "12345", "💖"] { + assert!( + validate_field_name( + name, + &FieldMetadata { + type_: FieldType::U8, + description: String::new() + } + ) + .is_err(), + "Field named {name} should be invalid" + ); + } + } + + #[test] + fn validate_field_name_verifies_uuid_field_names() { + assert!( + validate_field_name( + "projectid", + &FieldMetadata { + type_: FieldType::Uuid, + description: String::new() + } + ) + .is_err(), + "A Uuid field should be required to end in `_id`", + ); + for name in ["project_id", "id"] { + assert!( + validate_field_name(name, + &FieldMetadata { + type_: FieldType::Uuid, + description: String::new() + } + ).is_ok(), + "A Uuid field should be required to end in '_id' or exactly 'id'", + ); + } + } + + #[test] + fn extract_metric_fields_succeeds_with_gaps_in_versions() { + let metric_fields = MetricFields::Versioned(VersionedFields { + version: NonZeroU8::new(10).unwrap(), + fields: vec![], + }); + let current_version = Some(CurrentVersion::Active { + version: NonZeroU8::new(1).unwrap(), + fields: BTreeSet::new(), + }); + extract_metric_fields("foo", &metric_fields, ¤t_version).expect( + "Extracting metric fields should work with non-sequential \ + but increasing version numbers", + ); + } + + #[test] + fn extract_metric_fields_fails_with_non_increasing_versions() { + let metric_fields = MetricFields::Versioned(VersionedFields { + version: NonZeroU8::new(1).unwrap(), + fields: vec![], + }); + let current_version = Some(CurrentVersion::Active { + version: NonZeroU8::new(1).unwrap(), + fields: BTreeSet::new(), + }); + let res = + extract_metric_fields("foo", &metric_fields, ¤t_version); + let Err(MetricsError::SchemaDefinition(msg)) = &res else { + panic!("Expected schema definition error, found: {res:#?}"); + }; + assert!( + msg.contains("should increment"), + "Should fail when version numbers are non-increasing", + ); + } + + #[test] + fn extract_metric_fields_requires_adding_first() { + let metric_fields = MetricFields::Versioned(VersionedFields { + version: NonZeroU8::new(1).unwrap(), + fields: vec![], + }); + let current_version = None; + let res = + extract_metric_fields("foo", &metric_fields, ¤t_version); + let Err(MetricsError::SchemaDefinition(msg)) = &res else { + panic!("Expected schema definition error, found: {res:#?}"); + }; + assert!( + msg.contains("must be added in at its first version"), + "Should require that the first version of a metric explicitly \ + adds it in, before modification", + ); + + let metric_fields = MetricFields::Added { + added_in: NonZeroU8::new(1).unwrap(), + fields: vec![], + }; + let current_version = None; + extract_metric_fields("foo", &metric_fields, ¤t_version).expect( + "Should succeed when fields are added_in for their first version", + ); + } + + #[test] + fn extract_metric_fields_fails_to_add_existing_metric() { + let metric_fields = MetricFields::Added { + added_in: NonZeroU8::new(2).unwrap(), + fields: vec![], + }; + let current_version = Some(CurrentVersion::Active { + version: NonZeroU8::new(1).unwrap(), + fields: BTreeSet::new(), + }); + let res = + extract_metric_fields("foo", &metric_fields, ¤t_version); + let Err(MetricsError::SchemaDefinition(msg)) = &res else { + panic!("Expected schema definition error, found: {res:#?}"); + }; + assert!( + msg.contains("is already active"), + "Should fail when adding a metric that's already active", + ); + } + + #[test] + fn extract_metric_fields_fails_to_remove_non_existent_metric() { + let metric_fields = + MetricFields::Removed { removed_in: NonZeroU8::new(3).unwrap() }; + let current_version = Some(CurrentVersion::Inactive { + removed_in: NonZeroU8::new(1).unwrap(), + }); + let res = + extract_metric_fields("foo", &metric_fields, ¤t_version); + let Err(MetricsError::SchemaDefinition(msg)) = &res else { + panic!("Expected schema definition error, found: {res:#?}"); + }; + assert!( + msg.contains("was already removed"), + "Should fail when removing a metric that's already gone", + ); + } + + #[test] + fn load_schema_catches_metric_versions_not_added_in() { + let contents = r#" + [target] + name = "target" + description = "some target" + authz_scope = "fleet" + versions = [ + { version = 1, fields = [ "foo" ] } + ] + + [[metrics]] + name = "metric" + description = "some metric" + datum_type = "u8" + units = "count" + versions = [ + { version = 1, fields = [] } + ] + + [fields.foo] + type = "string" + description = "a field" + "#; + let res = load_schema(contents); + let Err(MetricsError::SchemaDefinition(msg)) = &res else { + panic!("Should fail when metrics are not added in, but found: {res:#?}"); + }; + assert!( + msg.contains("must be added in at its first"), + "Error message should indicate that metrics need to be \ + added_in first, then modified" + ); + } + + #[test] + fn into_schema_list_fails_with_zero_metrics() { + let contents = r#" + [target] + name = "target" + description = "some target" + authz_scope = "fleet" + versions = [ + { version = 1, fields = [ "foo" ] } + ] + + [[metrics]] + name = "metric" + description = "some metric" + datum_type = "u8" + units = "count" + versions = [ + { added_in = 1, fields = [] } + ] + + [fields.foo] + type = "string" + description = "a field" + "#; + let mut def: TimeseriesDefinition = toml::from_str(contents).unwrap(); + def.metrics.clear(); + let res = def.into_schema_list(); + let Err(MetricsError::SchemaDefinition(msg)) = &res else { + panic!("Should fail with zero metrics, but found: {res:#?}"); + }; + assert!( + msg.contains("At least one metric must"), + "Error message should indicate that metrics need to be \ + added_in first, then modified" + ); + } + + #[test] + fn load_schema_fails_with_nonexistent_target_version() { + let contents = r#" + [target] + name = "target" + description = "some target" + authz_scope = "fleet" + versions = [ + { version = 1, fields = [ "foo" ] }, + ] + + [[metrics]] + name = "metric" + description = "some metric" + datum_type = "u8" + units = "count" + versions = [ + { added_in = 1, fields = [] }, + { version = 2, fields = [] } + ] + + [fields.foo] + type = "string" + description = "a field" + "#; + let res = load_schema(contents); + let Err(MetricsError::SchemaDefinition(msg)) = &res else { + panic!( + "Should fail when a metric version refers \ + to a non-existent target version, but found: {res:#?}", + ); + }; + assert!( + msg.contains("does not have a matching version in the target"), + "Error message should indicate that the metric \ + refers to a nonexistent version in the target, found: {msg}", + ); + } + + fn assert_sequential_versions( + first: &TimeseriesSchema, + second: &TimeseriesSchema, + ) { + assert_eq!(first.timeseries_name, second.timeseries_name); + assert_eq!( + first.version.get(), + second.version.get().checked_sub(1).unwrap() + ); + assert_eq!(first.datum_type, second.datum_type); + assert_eq!(first.field_schema, second.field_schema); + } + + #[test] + fn load_schema_fills_in_late_implied_metric_versions() { + let contents = r#" + [target] + name = "target" + description = "some target" + authz_scope = "fleet" + versions = [ + { version = 1, fields = [ "foo" ] }, + { version = 2, fields = [ "foo" ] }, + ] + + [[metrics]] + name = "metric" + description = "some metric" + datum_type = "u8" + units = "count" + versions = [ + { added_in = 1, fields = [] } + ] + + [fields.foo] + type = "string" + description = "a field" + "#; + let schema = load_schema(contents).unwrap(); + assert_eq!( + schema.len(), + 2, + "Should have filled in version 2 of the metric using the \ + corresponding target version", + ); + assert_sequential_versions(&schema[0], &schema[1]); + } + + #[test] + fn load_schema_fills_in_implied_metric_versions_when_last_is_modified() { + let contents = r#" + [target] + name = "target" + description = "some target" + authz_scope = "fleet" + versions = [ + { version = 1, fields = [ "foo" ] }, + { version = 2, fields = [ "foo" ] }, + { version = 3, fields = [ "foo" ] }, + ] + + [[metrics]] + name = "metric" + description = "some metric" + datum_type = "u8" + units = "count" + versions = [ + { added_in = 1, fields = [] }, + { version = 3, fields = [] }, + ] + + [fields.foo] + type = "string" + description = "a field" + "#; + let schema = load_schema(contents).unwrap(); + assert_eq!( + schema.len(), + 3, + "Should have filled in version 2 of the metric using the \ + corresponding target version", + ); + assert_sequential_versions(&schema[0], &schema[1]); + assert_sequential_versions(&schema[1], &schema[2]); + } + + #[test] + fn load_schema_fills_in_implied_metric_versions() { + let contents = r#" + [target] + name = "target" + description = "some target" + authz_scope = "fleet" + versions = [ + { version = 1, fields = [ "foo" ] }, + { version = 2, fields = [ "foo" ] }, + { version = 3, fields = [ "foo" ] }, + ] + + [[metrics]] + name = "metric" + description = "some metric" + datum_type = "u8" + units = "count" + versions = [ + { added_in = 1, fields = [] }, + ] + + [fields.foo] + type = "string" + description = "a field" + "#; + let schema = load_schema(contents).unwrap(); + assert_eq!( + schema.len(), + 3, + "Should have filled in versions 2 and 3 of the metric using the \ + corresponding target version", + ); + assert_sequential_versions(&schema[0], &schema[1]); + assert_sequential_versions(&schema[1], &schema[2]); + } + + #[test] + fn load_schema_fills_in_implied_metric_versions_when_last_version_is_removed( + ) { + let contents = r#" + [target] + name = "target" + description = "some target" + authz_scope = "fleet" + versions = [ + { version = 1, fields = [ "foo" ] }, + { version = 2, fields = [ "foo" ] }, + { version = 3, fields = [ "foo" ] }, + ] + + [[metrics]] + name = "metric" + description = "some metric" + datum_type = "u8" + units = "count" + versions = [ + { added_in = 1, fields = [] }, + { removed_in = 3 }, + ] + + [fields.foo] + type = "string" + description = "a field" + "#; + let schema = load_schema(contents).unwrap(); + dbg!(&schema); + assert_eq!( + schema.len(), + 2, + "Should have filled in version 2 of the metric using the \ + corresponding target version", + ); + assert_sequential_versions(&schema[0], &schema[1]); + } + + #[test] + fn load_schema_skips_versions_until_metric_is_added() { + let contents = r#" + [target] + name = "target" + description = "some target" + authz_scope = "fleet" + versions = [ + { version = 1, fields = [ "foo" ] }, + { version = 2, fields = [ "foo" ] }, + { version = 3, fields = [ "foo" ] }, + ] + + [[metrics]] + name = "metric" + description = "some metric" + datum_type = "u8" + units = "count" + versions = [ + { added_in = 3, fields = [] }, + ] + + [fields.foo] + type = "string" + description = "a field" + "#; + let schema = load_schema(contents).unwrap(); + assert_eq!( + schema.len(), + 1, + "Should have only created the last version of the timeseries" + ); + } + + #[test] + fn load_schema_fails_with_duplicate_timeseries() { + let contents = r#" + [target] + name = "target" + description = "some target" + authz_scope = "fleet" + versions = [ + { version = 1, fields = [ "foo" ] }, + ] + + [[metrics]] + name = "metric" + description = "some metric" + datum_type = "u8" + units = "count" + versions = [ + { added_in = 1, fields = [] }, + ] + + [[metrics]] + name = "metric" + description = "some metric" + datum_type = "u8" + units = "count" + versions = [ + { added_in = 1, fields = [] }, + ] + + [fields.foo] + type = "string" + description = "a field" + "#; + let res = load_schema(contents); + let Err(MetricsError::SchemaDefinition(msg)) = &res else { + panic!( + "Expected to fail with duplicated timeseries, but found {res:#?}", + ); + }; + assert!( + msg.ends_with("is duplicated"), + "Message should indicate that a timeseries name / \ + version is duplicated" + ); + } +} diff --git a/oximeter/oximeter/src/schema.rs b/oximeter/impl/src/schema/mod.rs similarity index 84% rename from oximeter/oximeter/src/schema.rs rename to oximeter/impl/src/schema/mod.rs index 2a577fc8f1b..031427b8bda 100644 --- a/oximeter/oximeter/src/schema.rs +++ b/oximeter/impl/src/schema/mod.rs @@ -2,10 +2,13 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. -// Copyright 2023 Oxide Computer Company +// Copyright 2024 Oxide Computer Company //! Tools for working with schema for fields and timeseries. +pub mod codegen; +pub mod ir; + use crate::types::DatumType; use crate::types::FieldType; use crate::types::MetricsError; @@ -21,6 +24,7 @@ use std::collections::btree_map::Entry; use std::collections::BTreeMap; use std::collections::BTreeSet; use std::fmt::Write; +use std::num::NonZeroU8; use std::path::Path; /// The name and type information for a field of a timeseries schema. @@ -39,6 +43,8 @@ pub struct FieldSchema { pub name: String, pub field_type: FieldType, pub source: FieldSource, + #[serde(default)] + pub description: String, } /// The source from which a field is derived, the target or metric. @@ -68,7 +74,7 @@ pub enum FieldSource { Debug, Clone, PartialEq, PartialOrd, Ord, Eq, Hash, Serialize, Deserialize, )] #[serde(try_from = "&str")] -pub struct TimeseriesName(String); +pub struct TimeseriesName(pub(crate) String); impl JsonSchema for TimeseriesName { fn schema_name() -> String { @@ -153,6 +159,31 @@ fn validate_timeseries_name(s: &str) -> Result<&str, MetricsError> { } } +/// Text descriptions for the target and metric of a timeseries. +#[derive(Clone, Debug, Default, Deserialize, JsonSchema, Serialize)] +pub struct TimeseriesDescription { + pub target: String, + pub metric: String, +} + +/// Measurement units for timeseries samples. +#[derive(Clone, Copy, Debug, Deserialize, Serialize, JsonSchema, PartialEq)] +#[serde(rename_all = "snake_case")] +// TODO-completeness: Include more units, such as power / temperature. +// TODO-completeness: Decide whether and how to handle dimensional analysis +// during queries, if needed. +pub enum Units { + Count, + Bytes, +} + +impl Units { + /// Construct a `Units::Count`. + pub const fn count() -> Self { + Units::Count + } +} + /// The schema for a timeseries. /// /// This includes the name of the timeseries, as well as the datum type of its metric and the @@ -160,11 +191,26 @@ fn validate_timeseries_name(s: &str) -> Result<&str, MetricsError> { #[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] pub struct TimeseriesSchema { pub timeseries_name: TimeseriesName, + // TODO-cleanup: This default should be removed once schema are tracked in + // CockroachDB. Same for the version and scope fields. + #[serde(default)] + pub description: TimeseriesDescription, pub field_schema: BTreeSet, pub datum_type: DatumType, + #[serde(default = "default_schema_version")] + pub version: NonZeroU8, + #[serde(default = "AuthzScope::fleet")] + pub authz_scope: AuthzScope, + #[serde(default = "Units::count")] + pub units: Units, pub created: DateTime, } +/// Default version for timeseries schema, 1. +pub const fn default_schema_version() -> NonZeroU8 { + unsafe { NonZeroU8::new_unchecked(1) } +} + impl From<&Sample> for TimeseriesSchema { fn from(sample: &Sample) -> Self { let timeseries_name = sample.timeseries_name.parse().unwrap(); @@ -174,6 +220,7 @@ impl From<&Sample> for TimeseriesSchema { name: field.name.clone(), field_type: field.value.field_type(), source: FieldSource::Target, + description: String::new(), }; field_schema.insert(schema); } @@ -182,11 +229,21 @@ impl From<&Sample> for TimeseriesSchema { name: field.name.clone(), field_type: field.value.field_type(), source: FieldSource::Metric, + description: String::new(), }; field_schema.insert(schema); } let datum_type = sample.measurement.datum_type(); - Self { timeseries_name, field_schema, datum_type, created: Utc::now() } + Self { + timeseries_name, + description: Default::default(), + field_schema, + datum_type, + version: default_schema_version(), + authz_scope: AuthzScope::fleet(), + units: Units::Count, + created: Utc::now(), + } } } @@ -197,15 +254,14 @@ impl TimeseriesSchema { T: Target, M: Metric, { - let timeseries_name = - TimeseriesName::try_from(crate::timeseries_name(target, metric)) - .unwrap(); + let timeseries_name = crate::timeseries_name(target, metric); let mut field_schema = BTreeSet::new(); for field in target.fields() { let schema = FieldSchema { name: field.name.clone(), field_type: field.value.field_type(), source: FieldSource::Target, + description: String::new(), }; field_schema.insert(schema); } @@ -214,11 +270,21 @@ impl TimeseriesSchema { name: field.name.clone(), field_type: field.value.field_type(), source: FieldSource::Metric, + description: String::new(), }; field_schema.insert(schema); } let datum_type = metric.datum_type(); - Self { timeseries_name, field_schema, datum_type, created: Utc::now() } + Self { + timeseries_name, + description: Default::default(), + field_schema, + datum_type, + version: default_schema_version(), + authz_scope: AuthzScope::fleet(), + units: Units::Count, + created: Utc::now(), + } } /// Construct a timeseries schema from a sample @@ -245,6 +311,7 @@ impl TimeseriesSchema { impl PartialEq for TimeseriesSchema { fn eq(&self, other: &TimeseriesSchema) -> bool { self.timeseries_name == other.timeseries_name + && self.version == other.version && self.datum_type == other.datum_type && self.field_schema == other.field_schema } @@ -263,6 +330,45 @@ impl PartialEq for TimeseriesSchema { const TIMESERIES_NAME_REGEX: &str = "^(([a-z]+[a-z0-9]*)(_([a-z0-9]+))*):(([a-z]+[a-z0-9]*)(_([a-z0-9]+))*)$"; +/// Authorization scope for a timeseries. +/// +/// This describes the level at which a user must be authorized to read data +/// from a timeseries. For example, fleet-scoping means the data is only visible +/// to an operator or fleet reader. Project-scoped, on the other hand, indicates +/// that a user will see data limited to the projects on which they have read +/// permissions. +#[derive( + Clone, + Copy, + Debug, + Deserialize, + Eq, + Hash, + JsonSchema, + Ord, + PartialEq, + PartialOrd, + Serialize, +)] +#[serde(rename_all = "snake_case")] +pub enum AuthzScope { + /// Timeseries data is limited to fleet readers. + Fleet, + /// Timeseries data is limited to the authorized silo for a user. + Silo, + /// Timeseries data is limited to the authorized projects for a user. + Project, + /// The timeseries is viewable to all without limitation. + None, +} + +impl AuthzScope { + /// Construct a fleet authz scope. + pub const fn fleet() -> Self { + Self::Fleet + } +} + /// A set of timeseries schema, useful for testing changes to targets or /// metrics. #[derive(Debug, Default, Deserialize, PartialEq, Serialize)] @@ -586,11 +692,13 @@ mod tests { name: String::from("later"), field_type: FieldType::U64, source: FieldSource::Target, + description: String::new(), }; let metric_field = FieldSchema { name: String::from("earlier"), field_type: FieldType::U64, source: FieldSource::Metric, + description: String::new(), }; let timeseries_name: TimeseriesName = "foo:bar".parse().unwrap(); let datum_type = DatumType::U64; @@ -598,8 +706,12 @@ mod tests { [target_field.clone(), metric_field.clone()].into_iter().collect(); let expected_schema = TimeseriesSchema { timeseries_name, + description: Default::default(), field_schema, datum_type, + version: default_schema_version(), + authz_scope: AuthzScope::fleet(), + units: Units::Count, created: Utc::now(), }; @@ -627,11 +739,13 @@ mod tests { name: String::from("second"), field_type: FieldType::U64, source: FieldSource::Target, + description: String::new(), }); fields.insert(FieldSchema { name: String::from("first"), field_type: FieldType::U64, source: FieldSource::Target, + description: String::new(), }); let mut iter = fields.iter(); assert_eq!(iter.next().unwrap().name, "first"); diff --git a/oximeter/oximeter/src/test_util.rs b/oximeter/impl/src/test_util.rs similarity index 98% rename from oximeter/oximeter/src/test_util.rs rename to oximeter/impl/src/test_util.rs index a9778d03bc2..73dad0d122a 100644 --- a/oximeter/oximeter/src/test_util.rs +++ b/oximeter/impl/src/test_util.rs @@ -3,7 +3,7 @@ // file, You can obtain one at https://mozilla.org/MPL/2.0/. //! Utilities for testing the oximeter crate. -// Copyright 2021 Oxide Computer Company +// Copyright 2024 Oxide Computer Company use crate::histogram; use crate::histogram::Histogram; diff --git a/oximeter/oximeter/src/traits.rs b/oximeter/impl/src/traits.rs similarity index 90% rename from oximeter/oximeter/src/traits.rs rename to oximeter/impl/src/traits.rs index 0934d231e38..16baa4f6198 100644 --- a/oximeter/oximeter/src/traits.rs +++ b/oximeter/impl/src/traits.rs @@ -4,7 +4,7 @@ //! Traits used to describe metric data and its sources. -// Copyright 2021 Oxide Computer Company +// Copyright 2024 Oxide Computer Company use crate::histogram::Histogram; use crate::types; @@ -19,6 +19,7 @@ use chrono::DateTime; use chrono::Utc; use num::traits::One; use num::traits::Zero; +use std::num::NonZeroU8; use std::ops::Add; use std::ops::AddAssign; @@ -44,7 +45,11 @@ use std::ops::AddAssign; /// -------- /// /// ```rust -/// use oximeter::{Target, FieldType}; +/// # // Rename the impl crate so the doctests can refer to the public +/// # // `oximeter` crate, not the private impl. +/// # extern crate oximeter_impl as oximeter; +/// # use oximeter_macro_impl::*; +/// use oximeter::{traits::Target, types::FieldType}; /// use uuid::Uuid; /// /// #[derive(Target)] @@ -70,15 +75,29 @@ use std::ops::AddAssign; /// supported types. /// /// ```compile_fail +/// # // Rename the impl crate so the doctests can refer to the public +/// # // `oximeter` crate, not the private impl. +/// # extern crate oximeter_impl as oximeter; +/// # use oximeter_macro_impl::*; /// #[derive(oximeter::Target)] /// struct Bad { /// bad: f64, /// } /// ``` +/// +/// **Important:** Deriving this trait is deprecated, and will be removed in the +/// future. Instead, define your timeseries schema through the TOML format +/// described in [the crate documentation](crate), and use the code +/// generated by the `use_timeseries` macro. pub trait Target { /// Return the name of the target, which is the snake_case form of the struct's name. fn name(&self) -> &'static str; + /// Return the version of the target schema. + fn version(&self) -> NonZeroU8 { + unsafe { NonZeroU8::new_unchecked(1) } + } + /// Return the names of the target's fields, in the order in which they're defined. fn field_names(&self) -> &'static [&'static str]; @@ -141,6 +160,10 @@ pub trait Target { /// Example /// ------- /// ```rust +/// # // Rename the impl crate so the doctests can refer to the public +/// # // `oximeter` crate, not the private impl. +/// # extern crate oximeter_impl as oximeter; +/// # use oximeter_macro_impl::*; /// use chrono::Utc; /// use oximeter::Metric; /// @@ -162,6 +185,10 @@ pub trait Target { /// an unsupported type. /// /// ```compile_fail +/// # // Rename the impl crate so the doctests can refer to the public +/// # // `oximeter` crate, not the private impl. +/// # extern crate oximeter_impl as oximeter; +/// # use oximeter_macro_impl::*; /// #[derive(Metric)] /// pub struct BadType { /// field: f32, @@ -174,6 +201,11 @@ pub trait Metric { /// Return the name of the metric, which is the snake_case form of the struct's name. fn name(&self) -> &'static str; + /// Return the version of the metric schema. + fn version(&self) -> NonZeroU8 { + unsafe { NonZeroU8::new_unchecked(1) } + } + /// Return the names of the metric's fields, in the order in which they're defined. fn field_names(&self) -> &'static [&'static str]; @@ -332,6 +364,10 @@ pub use crate::histogram::HistogramSupport; /// Example /// ------- /// ```rust +/// # // Rename the impl crate so the doctests can refer to the public +/// # // `oximeter` crate, not the private impl. +/// # extern crate oximeter_impl as oximeter; +/// # use oximeter_macro_impl::*; /// use oximeter::{Datum, MetricsError, Metric, Producer, Target}; /// use oximeter::types::{Measurement, Sample, Cumulative}; /// diff --git a/oximeter/oximeter/src/types.rs b/oximeter/impl/src/types.rs similarity index 96% rename from oximeter/oximeter/src/types.rs rename to oximeter/impl/src/types.rs index 3e6ffc54423..7264f2d7c1f 100644 --- a/oximeter/oximeter/src/types.rs +++ b/oximeter/impl/src/types.rs @@ -4,11 +4,12 @@ //! Types used to describe targets, metrics, and measurements. -// Copyright 2023 Oxide Computer Company +// Copyright 2024 Oxide Computer Company use crate::histogram; use crate::traits; use crate::Producer; +use crate::TimeseriesName; use bytes::Bytes; use chrono::DateTime; use chrono::Utc; @@ -24,6 +25,7 @@ use std::fmt; use std::net::IpAddr; use std::net::Ipv4Addr; use std::net::Ipv6Addr; +use std::num::NonZeroU8; use std::ops::Add; use std::ops::AddAssign; use std::sync::Arc; @@ -667,8 +669,21 @@ pub enum MetricsError { #[error("Missing datum of type {datum_type} cannot have a start time")] MissingDatumCannotHaveStartTime { datum_type: DatumType }, + #[error("Invalid timeseries name")] InvalidTimeseriesName, + + #[error("TOML deserialization error: {0}")] + Toml(String), + + #[error("Schema definition error: {0}")] + SchemaDefinition(String), + + #[error("Target version {target} does not match metric version {metric}")] + TargetMetricVersionMismatch { + target: std::num::NonZeroU8, + metric: std::num::NonZeroU8, + }, } impl From for omicron_common::api::external::Error { @@ -795,7 +810,13 @@ pub struct Sample { pub measurement: Measurement, /// The name of the timeseries this sample belongs to - pub timeseries_name: String, + pub timeseries_name: TimeseriesName, + + /// The version of the timeseries this sample belongs to + // + // TODO-cleanup: This should be removed once schema are tracked in CRDB. + #[serde(default = "::oximeter::schema::default_schema_version")] + pub timeseries_version: NonZeroU8, // Target name and fields target: FieldSet, @@ -810,7 +831,8 @@ impl PartialEq for Sample { /// Two samples are considered equal if they have equal targets and metrics, and occur at the /// same time. Importantly, the _data_ is not used during comparison. fn eq(&self, other: &Sample) -> bool { - self.target.eq(&other.target) + self.timeseries_version.eq(&other.timeseries_version) + && self.target.eq(&other.target) && self.metric.eq(&other.metric) && self.measurement.start_time().eq(&other.measurement.start_time()) && self.measurement.timestamp().eq(&other.measurement.timestamp()) @@ -831,11 +853,18 @@ impl Sample { T: traits::Target, M: traits::Metric, { + if target.version() != metric.version() { + return Err(MetricsError::TargetMetricVersionMismatch { + target: target.version(), + metric: metric.version(), + }); + } let target_fields = FieldSet::from_target(target); let metric_fields = FieldSet::from_metric(metric); Self::verify_field_names(&target_fields, &metric_fields)?; Ok(Self { timeseries_name: crate::timeseries_name(target, metric), + timeseries_version: target.version(), target: target_fields, metric: metric_fields, measurement: metric.measure(timestamp), @@ -853,12 +882,19 @@ impl Sample { T: traits::Target, M: traits::Metric, { + if target.version() != metric.version() { + return Err(MetricsError::TargetMetricVersionMismatch { + target: target.version(), + metric: metric.version(), + }); + } let target_fields = FieldSet::from_target(target); let metric_fields = FieldSet::from_metric(metric); Self::verify_field_names(&target_fields, &metric_fields)?; let datum = Datum::Missing(MissingDatum::from(metric)); Ok(Self { timeseries_name: crate::timeseries_name(target, metric), + timeseries_version: target.version(), target: target_fields, metric: metric_fields, measurement: Measurement { timestamp, datum }, diff --git a/oximeter/oximeter/tests/fail/failures.rs b/oximeter/impl/tests/fail/failures.rs similarity index 100% rename from oximeter/oximeter/tests/fail/failures.rs rename to oximeter/impl/tests/fail/failures.rs diff --git a/oximeter/oximeter/tests/fail/failures.stderr b/oximeter/impl/tests/fail/failures.stderr similarity index 100% rename from oximeter/oximeter/tests/fail/failures.stderr rename to oximeter/impl/tests/fail/failures.stderr diff --git a/oximeter/oximeter/tests/test_compilation.rs b/oximeter/impl/tests/test_compilation.rs similarity index 100% rename from oximeter/oximeter/tests/test_compilation.rs rename to oximeter/impl/tests/test_compilation.rs diff --git a/oximeter/instruments/Cargo.toml b/oximeter/instruments/Cargo.toml index a04e26fdaaa..de5fb55d26a 100644 --- a/oximeter/instruments/Cargo.toml +++ b/oximeter/instruments/Cargo.toml @@ -13,6 +13,7 @@ chrono = { workspace = true, optional = true } dropshot = { workspace = true, optional = true } futures = { workspace = true, optional = true } http = { workspace = true, optional = true } +kstat-rs = { workspace = true, optional = true } oximeter = { workspace = true, optional = true } slog = { workspace = true, optional = true } tokio = { workspace = true, optional = true } @@ -20,6 +21,9 @@ thiserror = { workspace = true, optional = true } uuid = { workspace = true, optional = true } omicron-workspace-hack.workspace = true +[target.'cfg(not(target_os = "illumos"))'.dependencies] +libc.workspace = true + [features] default = ["http-instruments", "datalink"] http-instruments = [ @@ -48,6 +52,3 @@ rand.workspace = true slog-async.workspace = true slog-term.workspace = true oximeter.workspace = true - -[target.'cfg(target_os = "illumos")'.dependencies] -kstat-rs = { workspace = true, optional = true } diff --git a/oximeter/instruments/schema/physical-data-link.toml b/oximeter/instruments/schema/physical-data-link.toml new file mode 100644 index 00000000000..234c886c1b1 --- /dev/null +++ b/oximeter/instruments/schema/physical-data-link.toml @@ -0,0 +1,90 @@ +[target] +name = "physical_data_link" +description = "A physical network link on a compute sled" +authz_scope = "fleet" +versions = [ + { version = 1, fields = [ "rack_id", "sled_id", "hostname", "serial", "link_name" ] }, + #{ version = 2, fields = [ "rack_id", "sled_id", "serial", "model", "revision", "link_name" ] }, +] + +[fields.rack_id] +type = "uuid" +description = "UUID for the link's sled" + +[fields.sled_id] +type = "uuid" +description = "UUID for the link's sled" + +[fields.hostname] +type = "string" +description = "Hostname of the link's sled" + +[fields.model] +type = "string" +description = "Model number of the link's sled" + +[fields.revision] +type = "u32" +description = "Revision number of the sled" + +[fields.serial] +type = "string" +description = "Serial number of the sled" + +[fields.link_name] +type = "string" +description = "Name of the physical data link" + +[[metrics]] +name = "bytes_sent" +description = "Number of bytes sent on the link" +units = "bytes" +datum_type = "cumulative_u64" +versions = [ + { added_in = 1, fields = [] } +] + +[[metrics]] +name = "bytes_received" +description = "Number of bytes received on the link" +units = "bytes" +datum_type = "cumulative_u64" +versions = [ + { added_in = 1, fields = [] } +] + +[[metrics]] +name = "packets_sent" +description = "Number of packets sent on the link" +units = "count" +datum_type = "cumulative_u64" +versions = [ + { added_in = 1, fields = [] } +] + +[[metrics]] +name = "packets_received" +description = "Number of packets received on the link" +units = "count" +datum_type = "cumulative_u64" +versions = [ + { added_in = 1, fields = [] } +] + +[[metrics]] +name = "errors_sent" +description = "Number of errors encountered when sending on the link" +units = "count" +datum_type = "cumulative_u64" +versions = [ + { added_in = 1, fields = [] } +] + +[[metrics]] +name = "errors_received" +description = "Number of errors encountered when receiving on the link" +units = "count" +datum_type = "cumulative_u64" +versions = [ + { added_in = 1, fields = [] } +] diff --git a/oximeter/instruments/src/kstat/link.rs b/oximeter/instruments/src/kstat/link.rs index 03397c4108a..37384b19e78 100644 --- a/oximeter/instruments/src/kstat/link.rs +++ b/oximeter/instruments/src/kstat/link.rs @@ -15,98 +15,11 @@ use kstat_rs::Data; use kstat_rs::Kstat; use kstat_rs::Named; use oximeter::types::Cumulative; -use oximeter::Metric; use oximeter::Sample; -use oximeter::Target; -use uuid::Uuid; - -/// Information about a single physical Ethernet link on a host. -#[derive(Clone, Debug, Target)] -pub struct PhysicalDataLink { - /// The ID of the rack (cluster) containing this host. - pub rack_id: Uuid, - /// The ID of the sled itself. - pub sled_id: Uuid, - /// The serial number of the hosting sled. - pub serial: String, - /// The name of the host. - pub hostname: String, - /// The name of the link. - pub link_name: String, -} - -/// Information about a virtual Ethernet link on a host. -/// -/// Note that this is specifically for a VNIC in on the host system, not a guest -/// data link. -#[derive(Clone, Debug, Target)] -pub struct VirtualDataLink { - /// The ID of the rack (cluster) containing this host. - pub rack_id: Uuid, - /// The ID of the sled itself. - pub sled_id: Uuid, - /// The serial number of the hosting sled. - pub serial: String, - /// The name of the host, or the zone name for links in a zone. - pub hostname: String, - /// The name of the link. - pub link_name: String, -} - -/// Information about a guest virtual Ethernet link. -#[derive(Clone, Debug, Target)] -pub struct GuestDataLink { - /// The ID of the rack (cluster) containing this host. - pub rack_id: Uuid, - /// The ID of the sled itself. - pub sled_id: Uuid, - /// The serial number of the hosting sled. - pub serial: String, - /// The name of the host, or the zone name for links in a zone. - pub hostname: String, - /// The ID of the project containing the instance. - pub project_id: Uuid, - /// The ID of the instance. - pub instance_id: Uuid, - /// The name of the link. - pub link_name: String, -} - -/// The number of packets received on the link. -#[derive(Clone, Copy, Metric)] -pub struct PacketsReceived { - pub datum: Cumulative, -} - -/// The number of packets sent on the link. -#[derive(Clone, Copy, Metric)] -pub struct PacketsSent { - pub datum: Cumulative, -} - -/// The number of bytes sent on the link. -#[derive(Clone, Copy, Metric)] -pub struct BytesSent { - pub datum: Cumulative, -} - -/// The number of bytes received on the link. -#[derive(Clone, Copy, Metric)] -pub struct BytesReceived { - pub datum: Cumulative, -} -/// The number of errors received on the link. -#[derive(Clone, Copy, Metric)] -pub struct ErrorsReceived { - pub datum: Cumulative, -} - -/// The number of errors sent on the link. -#[derive(Clone, Copy, Metric)] -pub struct ErrorsSent { - pub datum: Cumulative, -} +oximeter::use_timeseries!( + "./oximeter/instruments/schema/physical-data-link.toml" +); // Helper function to extract the same kstat metrics from all link targets. fn extract_link_kstats( @@ -121,7 +34,7 @@ where let Named { name, value } = named_data; if *name == "rbytes64" { Some(value.as_u64().and_then(|x| { - let metric = BytesReceived { + let metric = physical_data_link::BytesReceived { datum: Cumulative::with_start_time(creation_time, x), }; Sample::new_with_timestamp(snapshot_time, target, &metric) @@ -129,7 +42,7 @@ where })) } else if *name == "obytes64" { Some(value.as_u64().and_then(|x| { - let metric = BytesSent { + let metric = physical_data_link::BytesSent { datum: Cumulative::with_start_time(creation_time, x), }; Sample::new_with_timestamp(snapshot_time, target, &metric) @@ -137,7 +50,7 @@ where })) } else if *name == "ipackets64" { Some(value.as_u64().and_then(|x| { - let metric = PacketsReceived { + let metric = physical_data_link::PacketsReceived { datum: Cumulative::with_start_time(creation_time, x), }; Sample::new_with_timestamp(snapshot_time, target, &metric) @@ -145,7 +58,7 @@ where })) } else if *name == "opackets64" { Some(value.as_u64().and_then(|x| { - let metric = PacketsSent { + let metric = physical_data_link::PacketsSent { datum: Cumulative::with_start_time(creation_time, x), }; Sample::new_with_timestamp(snapshot_time, target, &metric) @@ -153,7 +66,7 @@ where })) } else if *name == "ierrors" { Some(value.as_u32().and_then(|x| { - let metric = ErrorsReceived { + let metric = physical_data_link::ErrorsReceived { datum: Cumulative::with_start_time(creation_time, x.into()), }; Sample::new_with_timestamp(snapshot_time, target, &metric) @@ -161,7 +74,7 @@ where })) } else if *name == "oerrors" { Some(value.as_u32().and_then(|x| { - let metric = ErrorsSent { + let metric = physical_data_link::ErrorsSent { datum: Cumulative::with_start_time(creation_time, x.into()), }; Sample::new_with_timestamp(snapshot_time, target, &metric) @@ -177,19 +90,7 @@ trait LinkKstatTarget: KstatTarget { fn link_name(&self) -> &str; } -impl LinkKstatTarget for PhysicalDataLink { - fn link_name(&self) -> &str { - &self.link_name - } -} - -impl LinkKstatTarget for VirtualDataLink { - fn link_name(&self) -> &str { - &self.link_name - } -} - -impl LinkKstatTarget for GuestDataLink { +impl LinkKstatTarget for physical_data_link::PhysicalDataLink { fn link_name(&self) -> &str { &self.link_name } @@ -225,7 +126,7 @@ where } } -#[cfg(test)] +#[cfg(all(test, target_os = "illumos"))] mod tests { use super::*; use crate::kstat::sampler::KstatPath; @@ -325,17 +226,17 @@ mod tests { fn test_physical_datalink() { let link = TestEtherstub::new(); let sn = String::from("BRM000001"); - let dl = PhysicalDataLink { + let dl = physical_data_link::PhysicalDataLink { rack_id: RACK_ID, sled_id: SLED_ID, - serial: sn.clone(), - hostname: sn, - link_name: link.name.to_string(), + serial: sn.clone().into(), + hostname: sn.into(), + link_name: link.name.clone().into(), }; let ctl = Ctl::new().unwrap(); let ctl = ctl.update().unwrap(); let mut kstat = ctl - .filter(Some("link"), Some(0), Some(dl.link_name.as_str())) + .filter(Some("link"), Some(0), Some(link.name.as_str())) .next() .unwrap(); let creation_time = hrtime_to_utc(kstat.ks_crtime).unwrap(); @@ -349,12 +250,12 @@ mod tests { let mut sampler = KstatSampler::new(&test_logger()).unwrap(); let sn = String::from("BRM000001"); let link = TestEtherstub::new(); - let dl = PhysicalDataLink { + let dl = physical_data_link::PhysicalDataLink { rack_id: RACK_ID, sled_id: SLED_ID, - serial: sn.clone(), - hostname: sn, - link_name: link.name.to_string(), + serial: sn.clone().into(), + hostname: sn.into(), + link_name: link.name.clone().into(), }; let details = CollectionDetails::never(Duration::from_secs(1)); let id = sampler.add_target(dl, details).await.unwrap(); @@ -397,12 +298,12 @@ mod tests { KstatSampler::with_sample_limit(&test_logger(), limit).unwrap(); let sn = String::from("BRM000001"); let link = TestEtherstub::new(); - let dl = PhysicalDataLink { + let dl = physical_data_link::PhysicalDataLink { rack_id: RACK_ID, sled_id: SLED_ID, - serial: sn.clone(), - hostname: sn, - link_name: link.name.to_string(), + serial: sn.clone().into(), + hostname: sn.into(), + link_name: link.name.to_string().into(), }; let details = CollectionDetails::never(Duration::from_secs(1)); sampler.add_target(dl, details).await.unwrap(); @@ -464,12 +365,12 @@ mod tests { let sn = String::from("BRM000001"); let link = TestEtherstub::new(); info!(log, "created test etherstub"; "name" => &link.name); - let dl = PhysicalDataLink { + let dl = physical_data_link::PhysicalDataLink { rack_id: RACK_ID, sled_id: SLED_ID, - serial: sn.clone(), - hostname: sn, - link_name: link.name.to_string(), + serial: sn.clone().into(), + hostname: sn.into(), + link_name: link.name.to_string().into(), }; let collection_interval = Duration::from_secs(1); let expiry = Duration::from_secs(1); @@ -521,12 +422,12 @@ mod tests { let sn = String::from("BRM000001"); let link = TestEtherstub::new(); info!(log, "created test etherstub"; "name" => &link.name); - let dl = PhysicalDataLink { + let dl = physical_data_link::PhysicalDataLink { rack_id: RACK_ID, sled_id: SLED_ID, - serial: sn.clone(), - hostname: sn, - link_name: link.name.to_string(), + serial: sn.clone().into(), + hostname: sn.into(), + link_name: link.name.to_string().into(), }; let collection_interval = Duration::from_secs(1); let expiry = Duration::from_secs(1); @@ -570,12 +471,12 @@ mod tests { name: link.name.clone(), }; info!(log, "created test etherstub"; "name" => &link.name); - let dl = PhysicalDataLink { + let dl = physical_data_link::PhysicalDataLink { rack_id: RACK_ID, sled_id: SLED_ID, - serial: sn.clone(), - hostname: sn, - link_name: link.name.to_string(), + serial: sn.clone().into(), + hostname: sn.into(), + link_name: link.name.to_string().into(), }; let collection_interval = Duration::from_secs(1); let expiry = Duration::from_secs(1); @@ -617,12 +518,12 @@ mod tests { name: link.name.clone(), }; info!(log, "created test etherstub"; "name" => &link.name); - let dl = PhysicalDataLink { + let dl = physical_data_link::PhysicalDataLink { rack_id: RACK_ID, sled_id: SLED_ID, - serial: sn.clone(), - hostname: sn, - link_name: link.name.to_string(), + serial: sn.clone().into(), + hostname: sn.into(), + link_name: link.name.to_string().into(), }; let collection_interval = Duration::from_secs(1); let expiry = Duration::from_secs(1); diff --git a/oximeter/instruments/src/kstat/mod.rs b/oximeter/instruments/src/kstat/mod.rs index c792a514087..0e4c3d64712 100644 --- a/oximeter/instruments/src/kstat/mod.rs +++ b/oximeter/instruments/src/kstat/mod.rs @@ -97,6 +97,22 @@ pub use sampler::KstatSampler; pub use sampler::TargetId; pub use sampler::TargetStatus; +cfg_if::cfg_if! { + if #[cfg(all(test, target_os = "illumos"))] { + type Timestamp = tokio::time::Instant; + #[inline(always)] + fn now() -> Timestamp { + tokio::time::Instant::now() + } + } else { + type Timestamp = chrono::DateTime; + #[inline(always)] + fn now() -> Timestamp { + chrono::Utc::now() + } + } +} + /// The reason a kstat target was expired and removed from a sampler. #[derive(Clone, Copy, Debug)] pub enum ExpirationReason { @@ -114,10 +130,7 @@ pub struct Expiration { /// The last error before expiration. pub error: Box, /// The time at which the expiration occurred. - #[cfg(test)] - pub expired_at: tokio::time::Instant, - #[cfg(not(test))] - pub expired_at: DateTime, + pub expired_at: Timestamp, } /// Errors resulting from reporting kernel statistics. @@ -191,7 +204,7 @@ pub trait KstatTarget: /// Convert from a high-res timestamp into UTC, if possible. pub fn hrtime_to_utc(hrtime: i64) -> Result, Error> { let utc_now = Utc::now(); - let hrtime_now = unsafe { gethrtime() }; + let hrtime_now = get_hires_time(); match hrtime_now.cmp(&hrtime) { Ordering::Equal => Ok(utc_now), Ordering::Less => { @@ -262,7 +275,35 @@ impl<'a> ConvertNamedData for NamedData<'a> { } } -#[link(name = "c")] -extern "C" { - fn gethrtime() -> i64; +#[cfg(target_os = "illumos")] +mod time { + #[link(name = "c")] + extern "C" { + fn gethrtime() -> i64; + } + + pub fn get_hires_time() -> i64 { + unsafe { gethrtime() } + } +} + +#[cfg(not(target_os = "illumos"))] +mod time { + pub fn get_hires_time() -> i64 { + let mut tp = libc::timespec { tv_sec: 0, tv_nsec: 0 }; + if unsafe { + libc::clock_gettime(libc::CLOCK_MONOTONIC_RAW, &mut tp as *mut _) + } == 0 + { + const NANOS_PER_SEC: i64 = 1_000_000_000; + tp.tv_sec + .checked_mul(NANOS_PER_SEC) + .and_then(|nsec| nsec.checked_add(tp.tv_nsec)) + .unwrap_or(0) + } else { + 0 + } + } } + +use time::get_hires_time; diff --git a/oximeter/instruments/src/kstat/sampler.rs b/oximeter/instruments/src/kstat/sampler.rs index af1b3ba7cf9..74770a6225f 100644 --- a/oximeter/instruments/src/kstat/sampler.rs +++ b/oximeter/instruments/src/kstat/sampler.rs @@ -4,6 +4,8 @@ //! Generate oximeter samples from kernel statistics. +use super::now; +use super::Timestamp; use crate::kstat::hrtime_to_utc; use crate::kstat::Error; use crate::kstat::Expiration; @@ -41,9 +43,6 @@ use tokio::time::interval; use tokio::time::sleep; use tokio::time::Sleep; -#[cfg(test)] -use tokio::time::Instant; - // The `KstatSampler` generates some statistics about its own operation, mostly // for surfacing failures to collect and dropped samples. mod self_stats { @@ -165,12 +164,7 @@ pub enum TargetStatus { /// The target is currently being collected from normally. /// /// The timestamp of the last collection is included. - Ok { - #[cfg(test)] - last_collection: Option, - #[cfg(not(test))] - last_collection: Option>, - }, + Ok { last_collection: Option }, /// The target has been expired. /// /// The details about the expiration are included. @@ -178,10 +172,7 @@ pub enum TargetStatus { reason: ExpirationReason, // NOTE: The error is a string, because it's not cloneable. error: String, - #[cfg(test)] - expired_at: Instant, - #[cfg(not(test))] - expired_at: DateTime, + expired_at: Timestamp, }, } @@ -204,7 +195,7 @@ enum Request { /// Remove a target. RemoveTarget { id: TargetId, reply_tx: oneshot::Sender> }, /// Return the creation times of all tracked / extant kstats. - #[cfg(test)] + #[cfg(all(test, target_os = "illumos"))] CreationTimes { reply_tx: oneshot::Sender>>, }, @@ -218,15 +209,9 @@ struct SampledKstat { /// The details around collection and expiration behavior. details: CollectionDetails, /// The time at which we _added_ this target to the sampler. - #[cfg(test)] - time_added: Instant, - #[cfg(not(test))] - time_added: DateTime, + time_added: Timestamp, /// The last time we successfully collected from the target. - #[cfg(test)] - time_of_last_collection: Option, - #[cfg(not(test))] - time_of_last_collection: Option>, + time_of_last_collection: Option, /// Attempts since we last successfully collected from the target. attempts_since_last_collection: usize, } @@ -384,7 +369,7 @@ fn hostname() -> Option { } /// Stores the number of samples taken, used for testing. -#[cfg(test)] +#[cfg(all(test, target_os = "illumos"))] pub(crate) struct SampleCounts { pub total: usize, pub overflow: usize, @@ -420,7 +405,8 @@ impl KstatSamplerWorker { /// kstats at their intervals. Samples will be pushed onto the queue. async fn run( mut self, - #[cfg(test)] sample_count_tx: mpsc::UnboundedSender, + #[cfg(all(test, target_os = "illumos"))] + sample_count_tx: mpsc::UnboundedSender, ) { let mut sample_timeouts = FuturesUnordered::new(); let mut creation_prune_interval = @@ -487,7 +473,7 @@ impl KstatSamplerWorker { // Send the total number of samples we've actually // taken and the number we've appended over to any // testing code which might be listening. - #[cfg(test)] + #[cfg(all(test, target_os = "illumos"))] sample_count_tx.send(SampleCounts { total: n_samples, overflow: n_overflow_samples, @@ -635,7 +621,7 @@ impl KstatSamplerWorker { ), } } - #[cfg(test)] + #[cfg(all(test, target_os = "illumos"))] Request::CreationTimes { reply_tx } => { debug!(self.log, "request for creation times"); reply_tx.send(self.creation_times.clone()).unwrap(); @@ -734,13 +720,7 @@ impl KstatSamplerWorker { .collect::, _>>(); match kstats { Ok(k) if !k.is_empty() => { - cfg_if::cfg_if! { - if #[cfg(test)] { - sampled_kstat.time_of_last_collection = Some(Instant::now()); - } else { - sampled_kstat.time_of_last_collection = Some(Utc::now()); - } - } + sampled_kstat.time_of_last_collection = Some(now()); sampled_kstat.attempts_since_last_collection = 0; sampled_kstat.target.to_samples(&k).map(Option::Some) } @@ -769,17 +749,10 @@ impl KstatSamplerWorker { if sampled_kstat.attempts_since_last_collection >= n_attempts { - cfg_if::cfg_if! { - if #[cfg(test)] { - let expired_at = Instant::now(); - } else { - let expired_at = Utc::now(); - } - } return Err(Error::Expired(Expiration { reason: ExpirationReason::Attempts(n_attempts), error: Box::new(e), - expired_at, + expired_at: now(), })); } } @@ -790,18 +763,12 @@ impl KstatSamplerWorker { .time_of_last_collection .unwrap_or(sampled_kstat.time_added); let expire_at = start + duration; - cfg_if::cfg_if! { - if #[cfg(test)] { - let now = Instant::now(); - } else { - let now = Utc::now(); - } - } - if now >= expire_at { + let now_ = now(); + if now_ >= expire_at { return Err(Error::Expired(Expiration { reason: ExpirationReason::Duration(duration), error: Box::new(e), - expired_at: now, + expired_at: now_, })); } } @@ -1019,18 +986,10 @@ impl KstatSamplerWorker { None => {} } self.ensure_creation_times_for_target(&*target)?; - - cfg_if::cfg_if! { - if #[cfg(test)] { - let time_added = Instant::now(); - } else { - let time_added = Utc::now(); - } - } let item = SampledKstat { target, details, - time_added, + time_added: now(), time_of_last_collection: None, attempts_since_last_collection: 0, }; @@ -1076,7 +1035,7 @@ pub struct KstatSampler { outbox: mpsc::Sender, self_stat_rx: Arc>>, _worker_task: Arc>, - #[cfg(test)] + #[cfg(all(test, target_os = "illumos"))] sample_count_rx: Arc>>, } @@ -1110,7 +1069,7 @@ impl KstatSampler { samples.clone(), limit, )?; - #[cfg(test)] + #[cfg(all(test, target_os = "illumos"))] let (sample_count_rx, _worker_task) = { let (sample_count_tx, sample_count_rx) = mpsc::unbounded_channel(); ( @@ -1118,14 +1077,14 @@ impl KstatSampler { Arc::new(tokio::task::spawn(worker.run(sample_count_tx))), ) }; - #[cfg(not(test))] + #[cfg(not(all(test, target_os = "illumos")))] let _worker_task = Arc::new(tokio::task::spawn(worker.run())); Ok(Self { samples, outbox, self_stat_rx: Arc::new(Mutex::new(self_stat_rx)), _worker_task, - #[cfg(test)] + #[cfg(all(test, target_os = "illumos"))] sample_count_rx, }) } @@ -1174,7 +1133,7 @@ impl KstatSampler { } /// Return the number of samples pushed by the sampling task, if any. - #[cfg(test)] + #[cfg(all(test, target_os = "illumos"))] pub(crate) fn sample_counts(&self) -> Option { match self.sample_count_rx.lock().unwrap().try_recv() { Ok(c) => Some(c), @@ -1184,7 +1143,7 @@ impl KstatSampler { } /// Return the creation times for all tracked kstats. - #[cfg(test)] + #[cfg(all(test, target_os = "illumos"))] pub(crate) async fn creation_times( &self, ) -> BTreeMap> { diff --git a/oximeter/instruments/src/lib.rs b/oximeter/instruments/src/lib.rs index c1f839c85d2..521034e4230 100644 --- a/oximeter/instruments/src/lib.rs +++ b/oximeter/instruments/src/lib.rs @@ -9,5 +9,5 @@ #[cfg(feature = "http-instruments")] pub mod http; -#[cfg(all(feature = "kstat", target_os = "illumos"))] +#[cfg(feature = "kstat")] pub mod kstat; diff --git a/oximeter/oximeter-macro-impl/src/lib.rs b/oximeter/oximeter-macro-impl/src/lib.rs index f110d00e692..499cd82d0aa 100644 --- a/oximeter/oximeter-macro-impl/src/lib.rs +++ b/oximeter/oximeter-macro-impl/src/lib.rs @@ -153,6 +153,10 @@ fn build_shared_methods(item_name: &Ident, fields: &[&Field]) -> TokenStream { #name } + fn version(&self) -> ::std::num::NonZeroU8 { + unsafe { ::std::num::NonZeroU8::new_unchecked(1) } + } + fn field_names(&self) -> &'static [&'static str] { &[#(#names),*] } diff --git a/oximeter/oximeter/Cargo.toml b/oximeter/oximeter/Cargo.toml index 2445e0483a4..562e106f614 100644 --- a/oximeter/oximeter/Cargo.toml +++ b/oximeter/oximeter/Cargo.toml @@ -9,22 +9,12 @@ license = "MPL-2.0" workspace = true [dependencies] -bytes = { workspace = true, features = [ "serde" ] } -chrono.workspace = true -num.workspace = true -omicron-common.workspace = true +anyhow.workspace = true +clap.workspace = true +oximeter-impl.workspace = true oximeter-macro-impl.workspace = true -regex.workspace = true -schemars = { workspace = true, features = [ "uuid1", "bytes", "chrono" ] } -serde.workspace = true -serde_json.workspace = true -strum.workspace = true -thiserror.workspace = true -uuid.workspace = true +oximeter-timeseries-macro.workspace = true omicron-workspace-hack.workspace = true - -[dev-dependencies] -approx.workspace = true -rstest.workspace = true -serde_json.workspace = true -trybuild.workspace = true +prettyplease.workspace = true +syn.workspace = true +toml.workspace = true diff --git a/oximeter/oximeter/src/bin/oximeter-schema.rs b/oximeter/oximeter/src/bin/oximeter-schema.rs new file mode 100644 index 00000000000..14fb31b1e8c --- /dev/null +++ b/oximeter/oximeter/src/bin/oximeter-schema.rs @@ -0,0 +1,97 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +// Copyright 2024 Oxide Computer Company + +//! CLI tool to understand timeseries schema + +use anyhow::Context as _; +use clap::Parser; +use clap::Subcommand; +use oximeter::schema::ir::TimeseriesDefinition; +use std::num::NonZeroU8; +use std::path::PathBuf; + +#[derive(Debug, Parser)] +struct Args { + #[command(subcommand)] + cmd: Cmd, + /// The path to the schema definition TOML file. + path: PathBuf, +} + +#[derive(Debug, Subcommand)] +enum Cmd { + /// Print the intermediate representation parsed from the schema file + Ir, + + /// Print the derived timeseries schema. + Schema { + /// Show the schema for a specified timeseries by name. + /// + /// If not provided, all timeseries are printed. + #[arg(short, long)] + timeseries: Option, + + /// Show the schema for a specified version. + /// + /// If not provided, all versions are shown. + #[arg(short, long)] + version: Option, + }, + + /// Print the Rust code that would be emitted in the macro format. + Emit, +} + +fn main() -> anyhow::Result<()> { + let args = Args::try_parse()?; + let contents = std::fs::read_to_string(&args.path).with_context(|| { + format!("failed to read from {}", args.path.display()) + })?; + match args.cmd { + Cmd::Ir => { + let def: TimeseriesDefinition = toml::from_str(&contents)?; + println!("{def:#?}"); + } + Cmd::Schema { timeseries, version } => { + let schema = oximeter_impl::schema::ir::load_schema(&contents)?; + match (timeseries, version) { + (None, None) => { + for each in schema.into_iter() { + println!("{each:#?}"); + } + } + (None, Some(version)) => { + for each in + schema.into_iter().filter(|s| s.version == version) + { + println!("{each:#?}"); + } + } + (Some(name), None) => { + for each in + schema.into_iter().filter(|s| s.timeseries_name == name) + { + println!("{each:#?}"); + } + } + (Some(name), Some(version)) => { + for each in schema.into_iter().filter(|s| { + s.timeseries_name == name && s.version == version + }) { + println!("{each:#?}"); + } + } + } + } + Cmd::Emit => { + let code = oximeter::schema::codegen::use_timeseries(&contents)?; + let formatted = + prettyplease::unparse(&syn::parse_file(&format!("{code}"))?); + println!("{formatted}"); + } + } + Ok(()) +} diff --git a/oximeter/oximeter/src/lib.rs b/oximeter/oximeter/src/lib.rs index 1855762abe3..67766a970ad 100644 --- a/oximeter/oximeter/src/lib.rs +++ b/oximeter/oximeter/src/lib.rs @@ -2,6 +2,8 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. +// Copyright 2024 Oxide Computer Company + //! Tools for generating and collecting metric data in the Oxide rack. //! //! Overview @@ -18,33 +20,111 @@ //! sensor. A _target_ is the thing being measured -- the service or the DIMM, in these cases. Both //! targets and metrics may have key-value pairs associated with them, called _fields_. //! -//! Motivating example -//! ------------------ +//! Defining timeseries schema +//! -------------------------- +//! +//! Creating a timeseries starts by defining its schema. This includes the name +//! for the target and metric, as well as other metadata such as descriptions, +//! data types, and version numbers. Let's start by looking at an example: +//! +//! ```text +//! [target] +//! name = "foo" +//! description = "Statistics about the foo server" +//! authz_scope = "fleet" +//! versions = [ +//! { version = 1, fields = ["name", "id"], +//! ] +//! +//! [[metrics]] +//! name = "total_requests" +//! description = "The cumulative number of requests served" +//! datum_type = "cumulative_u64" +//! units = "count" +//! versions = [ +//! { added_in = 1, fields = ["route", "method", "response_code"], +//! ] +//! +//! [fields.name] +//! type = "string" +//! description = "The name of this server" +//! +//! [fields.id] +//! type = "uuid" +//! description = "Unique ID of this server" +//! +//! [fields.route] +//! type = "string" +//! description = "The route used in the HTTP request" +//! +//! [fields.method] +//! type = "string" +//! description = "The method used in the HTTP request" +//! +//! [fields.response_code] +//! type = u16 +//! description = "Status code the server responded with" +//! ``` //! -//! ```rust -//! use uuid::Uuid; -//! use oximeter::{types::Cumulative, Metric, Target}; +//! In this case, our target is an HTTP server, which we identify with the +//! fields "name" and "id". Those fields are described in the `fields` TOML key, +//! and referred to by name in the target definition. A target also needs to +//! have a description and an _authorization scope_, which describes the +//! visibility of the timeseries and its data. See +//! [`crate::schema::AuthzScope`] for details. +//! +//! A target can have one or more metrics defined for it. As with the target, a +//! metric also has a name and description, and additionally a datum type and +//! units. It may also have fields, again referred to by name. +//! +//! Versions +//! -------- +//! +//! Both targets and metrics have version numbers associated with them. For a +//! target, these numbers must be the numbers 1, 2, 3, ... (increasing, no +//! gaps). As the target or any of its metrics evolves, these numbers are +//! incremented, and the new fields for that version are specified. +//! +//! The fields on the metrics may be specified a bit more flexibly. The first +//! version they appear in must have the form: `{ added_in = X, fields = [ ... +//! ] }`. After, the versions may be omitted, meaning that the previous version +//! of the metric is unchanged; or the metric may be removed entirely with a +//! version like `{ removed_in = Y }`. +//! +//! In all cases, the TOML definition is checked for consistency. Any existing +//! metric versions must match up with a target version, and they must have +//! distinct field names (targets and metrics cannot share fields). +//! +//! Using these primitives, fields may be added, removed, or renamed, with one +//! caveat: a field may not **change type**. //! -//! #[derive(Target)] +//! Generated code +//! -------------- +//! +//! This TOML definition can be used in a number of ways, but the most relevant +//! is to actually produce data from the resulting timeseries, the above +//! definition produces the following Rust code: +//! +//! ```rust +//! #[derive(oximeter::Target)] //! struct HttpServer { //! name: String, -//! id: Uuid, +//! id: uuid::Uuid, //! } //! -//! #[derive(Metric)] +//! #[derive(oximeter::Metric)] //! struct TotalRequests { //! route: String, //! method: String, -//! response_code: i64, +//! response_code: u16, //! #[datum] -//! total: Cumulative, +//! total: oximeter::types::Cumulative, //! } //! ``` //! -//! In this case, our target is some HTTP server, which we identify with the fields "name" and -//! "id". The metric of interest is the total count of requests, by route/method/response code, -//! over time. The [`types::Cumulative`] type keeps track of cumulative scalar values, an integer -//! in this case. +//! This code can be used to create **samples** from this timeseries. The target +//! and metric structs can be filled in with the timeseries's _fields_, and the +//! _datum_ may be populated to generate new samples. //! //! Datum, measurement, and samples //! ------------------------------- @@ -95,44 +175,5 @@ //! `Producer`s may be registered with the same `ProducerServer`, each with potentially different //! sampling intervals. -// Copyright 2023 Oxide Computer Company - -pub use oximeter_macro_impl::*; - -// Export the current crate as `oximeter`. The macros defined in `oximeter-macro-impl` generate -// code referring to symbols like `oximeter::traits::Target`. In consumers of this crate, that's -// fine, but internally there _is_ no crate named `oximeter`, it's just `self` or `crate`. -// -// See https://github.com/rust-lang/rust/pull/55275 for the PR introducing this fix, which links to -// lots of related issues and discussion. -extern crate self as oximeter; - -pub mod histogram; -pub mod schema; -pub mod test_util; -pub mod traits; -pub mod types; - -pub use schema::FieldSchema; -pub use schema::TimeseriesName; -pub use schema::TimeseriesSchema; -pub use traits::Metric; -pub use traits::Producer; -pub use traits::Target; -pub use types::Datum; -pub use types::DatumType; -pub use types::Field; -pub use types::FieldType; -pub use types::FieldValue; -pub use types::Measurement; -pub use types::MetricsError; -pub use types::Sample; - -/// Construct the timeseries name for a Target and Metric. -pub fn timeseries_name(target: &T, metric: &M) -> String -where - T: Target, - M: Metric, -{ - format!("{}:{}", target.name(), metric.name()) -} +pub use oximeter_impl::*; +pub use oximeter_timeseries_macro::use_timeseries; diff --git a/oximeter/timeseries-macro/Cargo.toml b/oximeter/timeseries-macro/Cargo.toml new file mode 100644 index 00000000000..db591aed060 --- /dev/null +++ b/oximeter/timeseries-macro/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "oximeter-timeseries-macro" +version = "0.1.0" +edition = "2021" + +[lib] +proc-macro = true + +[dependencies] +omicron-workspace-hack.workspace = true +oximeter-impl.workspace = true +proc-macro2.workspace = true +quote.workspace = true +syn.workspace = true + +[lints] +workspace = true diff --git a/oximeter/timeseries-macro/src/lib.rs b/oximeter/timeseries-macro/src/lib.rs new file mode 100644 index 00000000000..db1befdd623 --- /dev/null +++ b/oximeter/timeseries-macro/src/lib.rs @@ -0,0 +1,60 @@ +extern crate proc_macro; + +#[proc_macro] +pub fn use_timeseries( + tokens: proc_macro::TokenStream, +) -> proc_macro::TokenStream { + match syn::parse::(tokens) { + Ok(path) => { + let filename = + match std::path::Path::new(&path.value()).canonicalize() { + Ok(f) => f.display().to_string(), + Err(e) => { + let msg = format!( + "Failed to canonicalize file from path '{}': {:?}", + path.value(), + e, + ); + return syn::Error::new(path.span(), msg) + .into_compile_error() + .into(); + } + }; + let contents = match std::fs::read_to_string(&filename) { + Ok(c) => c, + Err(e) => { + let msg = format!( + "Failed to read timeseries schema \ + from file '{}': {:?}", + filename, e, + ); + return syn::Error::new(path.span(), msg) + .into_compile_error() + .into(); + } + }; + match oximeter_impl::schema::codegen::use_timeseries(&contents) { + Ok(toks) => { + return quote::quote! { + /// Include the schema file itself to ensure we recompile + /// when that changes. + const _: &str = include_str!(#filename); + #toks + } + .into(); + } + Err(e) => { + let msg = format!( + "Failed to generate timeseries types \ + from '{}': {:?}", + filename, e, + ); + return syn::Error::new(path.span(), msg) + .into_compile_error() + .into(); + } + } + } + Err(e) => return e.into_compile_error().into(), + } +} diff --git a/schema/all-zone-requests.json b/schema/all-zone-requests.json index fde6ee18a4f..f33ac1b430e 100644 --- a/schema/all-zone-requests.json +++ b/schema/all-zone-requests.json @@ -683,6 +683,7 @@ } }, "dataset": { + "default": null, "anyOf": [ { "$ref": "#/definitions/DatasetRequest" diff --git a/schema/rss-sled-plan.json b/schema/rss-sled-plan.json index 04ba5d8d315..a42a22efd8c 100644 --- a/schema/rss-sled-plan.json +++ b/schema/rss-sled-plan.json @@ -195,6 +195,7 @@ }, "checker": { "description": "Checker to apply to incoming messages.", + "default": null, "type": [ "string", "null" @@ -209,6 +210,7 @@ }, "shaper": { "description": "Shaper to apply to outgoing messages.", + "default": null, "type": [ "string", "null" @@ -319,6 +321,7 @@ }, "local_pref": { "description": "Apply a local preference to routes received from this peer.", + "default": null, "type": [ "integer", "null" @@ -328,6 +331,7 @@ }, "md5_auth_key": { "description": "Use the given key for TCP-MD5 authentication with the peer.", + "default": null, "type": [ "string", "null" @@ -335,6 +339,7 @@ }, "min_ttl": { "description": "Require messages from a peer have a minimum IP time to live field.", + "default": null, "type": [ "integer", "null" @@ -344,6 +349,7 @@ }, "multi_exit_discriminator": { "description": "Apply the provided multi-exit discriminator (MED) updates sent to the peer.", + "default": null, "type": [ "integer", "null" @@ -357,6 +363,7 @@ }, "remote_asn": { "description": "Require that a peer has a specified ASN.", + "default": null, "type": [ "integer", "null" @@ -366,6 +373,7 @@ }, "vlan_id": { "description": "Associate a VLAN ID with a BGP peer session.", + "default": null, "type": [ "integer", "null" @@ -894,6 +902,7 @@ }, "vlan_id": { "description": "The VLAN id associated with this route.", + "default": null, "type": [ "integer", "null" @@ -997,6 +1006,7 @@ }, "vlan_id": { "description": "The VLAN id (if any) associated with this address.", + "default": null, "type": [ "integer", "null" diff --git a/sled-agent/src/metrics.rs b/sled-agent/src/metrics.rs index 62eaaf61545..fcb260e93a1 100644 --- a/sled-agent/src/metrics.rs +++ b/sled-agent/src/metrics.rs @@ -8,30 +8,23 @@ use omicron_common::api::internal::nexus::ProducerEndpoint; use omicron_common::api::internal::nexus::ProducerKind; use oximeter::types::MetricsError; use oximeter::types::ProducerRegistry; +use oximeter_instruments::kstat::link; +use oximeter_instruments::kstat::CollectionDetails; +use oximeter_instruments::kstat::Error as KstatError; +use oximeter_instruments::kstat::KstatSampler; +use oximeter_instruments::kstat::TargetId; use oximeter_producer::LogConfig; use oximeter_producer::Server as ProducerServer; use sled_hardware_types::Baseboard; use slog::Logger; +use std::collections::BTreeMap; use std::net::Ipv6Addr; use std::net::SocketAddr; use std::sync::Arc; +use std::sync::Mutex; use std::time::Duration; use uuid::Uuid; -cfg_if::cfg_if! { - if #[cfg(target_os = "illumos")] { - use oximeter_instruments::kstat::link; - use oximeter_instruments::kstat::CollectionDetails; - use oximeter_instruments::kstat::Error as KstatError; - use oximeter_instruments::kstat::KstatSampler; - use oximeter_instruments::kstat::TargetId; - use std::collections::BTreeMap; - use std::sync::Mutex; - } else { - use anyhow::anyhow; - } -} - /// The interval on which we ask `oximeter` to poll us for metric data. pub(crate) const METRIC_COLLECTION_INTERVAL: Duration = Duration::from_secs(30); @@ -44,14 +37,9 @@ const METRIC_REQUEST_MAX_SIZE: usize = 10 * 1024 * 1024; /// An error during sled-agent metric production. #[derive(Debug, thiserror::Error)] pub enum Error { - #[cfg(target_os = "illumos")] #[error("Kstat-based metric failure")] Kstat(#[source] KstatError), - #[cfg(not(target_os = "illumos"))] - #[error("Kstat-based metric failure")] - Kstat(#[source] anyhow::Error), - #[error("Failed to insert metric producer into registry")] Registry(#[source] MetricsError), @@ -70,7 +58,6 @@ pub enum Error { // Basic metadata about the sled agent used when publishing metrics. #[derive(Clone, Debug)] -#[cfg_attr(not(target_os = "illumos"), allow(dead_code))] struct SledIdentifiers { sled_id: Uuid, rack_id: Uuid, @@ -86,13 +73,9 @@ struct SledIdentifiers { // pattern, but until we have more statistics, it's not clear whether that's // worth it right now. #[derive(Clone)] -// NOTE: The ID fields aren't used on non-illumos systems, rather than changing -// the name of fields that are not yet used. -#[cfg_attr(not(target_os = "illumos"), allow(dead_code))] pub struct MetricsManager { metadata: Arc, _log: Logger, - #[cfg(target_os = "illumos")] kstat_sampler: KstatSampler, // TODO-scalability: We may want to generalize this to store any kind of // tracked target, and use a naming scheme that allows us pick out which @@ -103,7 +86,6 @@ pub struct MetricsManager { // for disks or memory. If we wanted to guarantee uniqueness, we could // namespace them internally, e.g., `"datalink:{link_name}"` would be the // real key. - #[cfg(target_os = "illumos")] tracked_links: Arc>>, producer_server: Arc, } @@ -122,23 +104,16 @@ impl MetricsManager { ) -> Result { let producer_server = start_producer_server(&log, sled_id, sled_address)?; - - cfg_if::cfg_if! { - if #[cfg(target_os = "illumos")] { - let kstat_sampler = KstatSampler::new(&log).map_err(Error::Kstat)?; - producer_server - .registry() - .register_producer(kstat_sampler.clone()) - .map_err(Error::Registry)?; - let tracked_links = Arc::new(Mutex::new(BTreeMap::new())); - } - } + let kstat_sampler = KstatSampler::new(&log).map_err(Error::Kstat)?; + producer_server + .registry() + .register_producer(kstat_sampler.clone()) + .map_err(Error::Registry)?; + let tracked_links = Arc::new(Mutex::new(BTreeMap::new())); Ok(Self { metadata: Arc::new(SledIdentifiers { sled_id, rack_id, baseboard }), _log: log, - #[cfg(target_os = "illumos")] kstat_sampler, - #[cfg(target_os = "illumos")] tracked_links, producer_server, }) @@ -178,7 +153,6 @@ fn start_producer_server( ProducerServer::start(&config).map(Arc::new).map_err(Error::ProducerServer) } -#[cfg(target_os = "illumos")] impl MetricsManager { /// Track metrics for a physical datalink. pub async fn track_physical_link( @@ -187,12 +161,12 @@ impl MetricsManager { interval: Duration, ) -> Result<(), Error> { let hostname = hostname()?; - let link = link::PhysicalDataLink { + let link = link::physical_data_link::PhysicalDataLink { rack_id: self.metadata.rack_id, sled_id: self.metadata.sled_id, - serial: self.serial_number(), - hostname, - link_name: link_name.as_ref().to_string(), + serial: self.serial_number().into(), + hostname: hostname.into(), + link_name: link_name.as_ref().to_string().into(), }; let details = CollectionDetails::never(interval); let id = self @@ -224,29 +198,6 @@ impl MetricsManager { } } - /// Track metrics for a virtual datalink. - #[allow(dead_code)] - pub async fn track_virtual_link( - &self, - link_name: impl AsRef, - hostname: impl AsRef, - interval: Duration, - ) -> Result<(), Error> { - let link = link::VirtualDataLink { - rack_id: self.metadata.rack_id, - sled_id: self.metadata.sled_id, - serial: self.serial_number(), - hostname: hostname.as_ref().to_string(), - link_name: link_name.as_ref().to_string(), - }; - let details = CollectionDetails::never(interval); - self.kstat_sampler - .add_target(link, details) - .await - .map(|_| ()) - .map_err(Error::Kstat) - } - // Return the serial number out of the baseboard, if one exists. fn serial_number(&self) -> String { match &self.metadata.baseboard { @@ -257,48 +208,7 @@ impl MetricsManager { } } -#[cfg(not(target_os = "illumos"))] -impl MetricsManager { - /// Track metrics for a physical datalink. - pub async fn track_physical_link( - &self, - _link_name: impl AsRef, - _interval: Duration, - ) -> Result<(), Error> { - Err(Error::Kstat(anyhow!( - "kstat metrics are not supported on this platform" - ))) - } - - /// Stop tracking metrics for a datalink. - /// - /// This works for both physical and virtual links. - #[allow(dead_code)] - pub async fn stop_tracking_link( - &self, - _link_name: impl AsRef, - ) -> Result<(), Error> { - Err(Error::Kstat(anyhow!( - "kstat metrics are not supported on this platform" - ))) - } - - /// Track metrics for a virtual datalink. - #[allow(dead_code)] - pub async fn track_virtual_link( - &self, - _link_name: impl AsRef, - _hostname: impl AsRef, - _interval: Duration, - ) -> Result<(), Error> { - Err(Error::Kstat(anyhow!( - "kstat metrics are not supported on this platform" - ))) - } -} - // Return the current hostname if possible. -#[cfg(target_os = "illumos")] fn hostname() -> Result { // See netdb.h const MAX_LEN: usize = 256; diff --git a/workspace-hack/Cargo.toml b/workspace-hack/Cargo.toml index 1b21b724955..1b8c4f40e31 100644 --- a/workspace-hack/Cargo.toml +++ b/workspace-hack/Cargo.toml @@ -83,13 +83,13 @@ pem-rfc7468 = { version = "0.7.0", default-features = false, features = ["std"] petgraph = { version = "0.6.5", features = ["serde-1"] } postgres-types = { version = "0.2.6", default-features = false, features = ["with-chrono-0_4", "with-serde_json-1", "with-uuid-1"] } predicates = { version = "3.1.0" } -proc-macro2 = { version = "1.0.82" } +proc-macro2 = { version = "1.0.85" } regex = { version = "1.10.4" } regex-automata = { version = "0.4.6", default-features = false, features = ["dfa", "hybrid", "meta", "nfa", "perf", "unicode"] } regex-syntax = { version = "0.8.3" } reqwest = { version = "0.11.27", features = ["blocking", "cookies", "json", "rustls-tls", "stream"] } ring = { version = "0.17.8", features = ["std"] } -schemars = { version = "0.8.20", features = ["bytes", "chrono", "uuid1"] } +schemars = { version = "0.8.21", features = ["bytes", "chrono", "uuid1"] } scopeguard = { version = "1.2.0" } semver = { version = "1.0.23", features = ["serde"] } serde = { version = "1.0.203", features = ["alloc", "derive", "rc"] } @@ -101,7 +101,7 @@ smallvec = { version = "1.13.2", default-features = false, features = ["const_ne spin = { version = "0.9.8" } string_cache = { version = "0.8.7" } subtle = { version = "2.5.0" } -syn-f595c2ba2a3f28df = { package = "syn", version = "2.0.64", features = ["extra-traits", "fold", "full", "visit", "visit-mut"] } +syn-f595c2ba2a3f28df = { package = "syn", version = "2.0.66", features = ["extra-traits", "fold", "full", "visit", "visit-mut"] } time = { version = "0.3.36", features = ["formatting", "local-offset", "macros", "parsing"] } tokio = { version = "1.37.0", features = ["full", "test-util"] } tokio-postgres = { version = "0.7.10", features = ["with-chrono-0_4", "with-serde_json-1", "with-uuid-1"] } @@ -187,13 +187,13 @@ pem-rfc7468 = { version = "0.7.0", default-features = false, features = ["std"] petgraph = { version = "0.6.5", features = ["serde-1"] } postgres-types = { version = "0.2.6", default-features = false, features = ["with-chrono-0_4", "with-serde_json-1", "with-uuid-1"] } predicates = { version = "3.1.0" } -proc-macro2 = { version = "1.0.82" } +proc-macro2 = { version = "1.0.85" } regex = { version = "1.10.4" } regex-automata = { version = "0.4.6", default-features = false, features = ["dfa", "hybrid", "meta", "nfa", "perf", "unicode"] } regex-syntax = { version = "0.8.3" } reqwest = { version = "0.11.27", features = ["blocking", "cookies", "json", "rustls-tls", "stream"] } ring = { version = "0.17.8", features = ["std"] } -schemars = { version = "0.8.20", features = ["bytes", "chrono", "uuid1"] } +schemars = { version = "0.8.21", features = ["bytes", "chrono", "uuid1"] } scopeguard = { version = "1.2.0" } semver = { version = "1.0.23", features = ["serde"] } serde = { version = "1.0.203", features = ["alloc", "derive", "rc"] } @@ -206,7 +206,7 @@ spin = { version = "0.9.8" } string_cache = { version = "0.8.7" } subtle = { version = "2.5.0" } syn-dff4ba8e3ae991db = { package = "syn", version = "1.0.109", features = ["extra-traits", "fold", "full", "visit"] } -syn-f595c2ba2a3f28df = { package = "syn", version = "2.0.64", features = ["extra-traits", "fold", "full", "visit", "visit-mut"] } +syn-f595c2ba2a3f28df = { package = "syn", version = "2.0.66", features = ["extra-traits", "fold", "full", "visit", "visit-mut"] } time = { version = "0.3.36", features = ["formatting", "local-offset", "macros", "parsing"] } time-macros = { version = "0.2.18", default-features = false, features = ["formatting", "parsing"] } tokio = { version = "1.37.0", features = ["full", "test-util"] }