diff --git a/Cargo.lock b/Cargo.lock index 7239115ca3..002a42f543 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -165,6 +165,7 @@ dependencies = [ "derivative", "derive_more", "dhat", + "diff", "directories", "displaydoc", "flate2", @@ -208,6 +209,7 @@ dependencies = [ "prometheus", "prost 0.9.0", "prost-types 0.9.0", + "proteus", "rand", "redis", "redis_cluster_async", @@ -215,6 +217,7 @@ dependencies = [ "reqwest", "rhai", "router-bridge", + "rust-embed", "schemars", "serde", "serde_json", @@ -1377,6 +1380,12 @@ dependencies = [ "tempfile", ] +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + [[package]] name = "difflib" version = "0.4.0" @@ -1803,6 +1812,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "ghost" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb19fe8de3ea0920d282f7b77dd4227aea6b8b999b42cdf0ca41b2472b14443a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "gimli" version = "0.26.2" @@ -2315,6 +2335,16 @@ dependencies = [ "tracing", ] +[[package]] +name = "inventory" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84344c6e0b90a9e2b6f3f9abe5cc74402684e348df7b32adca28747e0cef091a" +dependencies = [ + "ctor", + "ghost", +] + [[package]] name = "ipnet" version = "2.5.1" @@ -3657,6 +3687,20 @@ dependencies = [ "prost 0.11.2", ] +[[package]] +name = "proteus" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "279396105537894fdecabfba63493bc93192c94a97951bef640d2feac3cfc362" +dependencies = [ + "once_cell", + "regex", + "serde", + "serde_json", + "thiserror", + "typetag", +] + [[package]] name = "protobuf" version = "2.28.0" @@ -4063,6 +4107,40 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rust-embed" +version = "6.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "283ffe2f866869428c92e0d61c2f35dfb4355293cdfdc48f49e895c15f1333d1" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir 2.3.2", +] + +[[package]] +name = "rust-embed-impl" +version = "6.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31ab23d42d71fb9be1b643fe6765d292c5e14d46912d13f3ae2815ca048ea04d" +dependencies = [ + "proc-macro2", + "quote", + "rust-embed-utils", + "syn", + "walkdir 2.3.2", +] + +[[package]] +name = "rust-embed-utils" +version = "7.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1669d81dfabd1b5f8e2856b8bbe146c6192b0ba22162edc738ac0a5de18f054" +dependencies = [ + "sha2", + "walkdir 2.3.2", +] + [[package]] name = "rustc-demangle" version = "0.1.21" @@ -5449,6 +5527,30 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dcf81ac59edc17cc8697ff311e8f5ef2d99fcbd9817b34cec66f90b6c3dfd987" +[[package]] +name = "typetag" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4080564c5b2241b5bff53ab610082234e0c57b0417f4bd10596f183001505b8a" +dependencies = [ + "erased-serde", + "inventory", + "once_cell", + "serde", + "typetag-impl", +] + +[[package]] +name = "typetag-impl" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e60147782cc30833c05fba3bab1d9b5771b2685a2557672ac96fa5d154099c0e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "ucd-trie" version = "0.1.5" diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md index 62868a2d42..8885cd66e2 100644 --- a/NEXT_CHANGELOG.md +++ b/NEXT_CHANGELOG.md @@ -25,6 +25,39 @@ By [@USERNAME](https://github.com/USERNAME) in https://github.com/apollographql/ # [x.x.x] (unreleased) - 2022-mm-dd ## ❗ BREAKING ❗ +### Fix naming inconsistency of telemetry.metrics.common.attributes.router ([Issue #2076](https://github.com/apollographql/router/issues/2076)) + +Mirroring the rest of the config `router` should be `supergraph` + +```yaml +telemetry: + metrics: + common: + attributes: + router: # old +``` +becomes +```yaml +telemetry: + metrics: + common: + attributes: + supergraph: # new +``` + +By [@bryncooke](https://github.com/bryncooke) in https://github.com/apollographql/router/pull/2116 + +### CLI structure changes ([Issue #2123](https://github.com/apollographql/router/issues/2123)) + +As the Router gains functionality the limitations of the current CLI structure are becoming apparent. + +There is now a separate subcommand for config related operations: +* `config` + * `schema` - Output the configuration schema + * `upgrade` - Upgrade the configuration with optional diff support. + +`router --schema` has been deprecated and users should move to `router config schema`. + ## 🚀 Features ### Provide multi-arch (amd64/arm64) Docker images for the Router ([Issue #1932](https://github.com/apollographql/router/pull/2138)) @@ -64,6 +97,43 @@ helm upgrade --install --create-namespace --namespace router-test --set-file sup By [@garypen](https://github.com/garypen) in https://github.com/apollographql/router/pull/2119 +### Configuration upgrades ([Issue #2123](https://github.com/apollographql/router/issues/2123)) + +Occasionally we will make changes to the Router yaml configuration format. +When starting the Router if the configuration can be upgraded it will do so automatically and display a warning: + +``` +2022-11-22T14:01:46.884897Z WARN router configuration contains deprecated options: + + 1. telemetry.tracing.trace_config.attributes.router has been renamed to 'supergraph' for consistency + +These will become errors in the future. Run `router config upgrade ` to see a suggested upgraded configuration. +``` + +Note: If a configuration has errors after upgrading then the configuration will not be upgraded automatically. + +From the CLI users can run: +* `router config upgrade ` to output configuration that has been upgraded to match the latest config format. +* `router config upgrade --diff ` to output a diff e.g. +``` + telemetry: + apollo: + client_name_header: apollographql-client-name + metrics: + common: + attributes: +- router: ++ supergraph: + request: + header: + - named: "1" # foo +``` + +There are situations where comments and whitespace are not preserved. This may be improved in future. + +By [@bryncooke](https://github.com/bryncooke) in https://github.com/apollographql/router/pull/2116 + + ## 🐛 Fixes ### Improve errors when subgraph returns non-GraphQL response with a non-2xx status code ([Issue #2117](https://github.com/apollographql/router/issues/2117)) diff --git a/README.md b/README.md index f529b39272..5a15025f1f 100644 --- a/README.md +++ b/README.md @@ -26,11 +26,40 @@ specified via flag, either by an absolute path, or a path relative to the curren directory. ``` +USAGE: + router [OPTIONS] [SUBCOMMAND] + OPTIONS: - -c, --config Configuration file location - -s, --supergraph Supergraph Schema location - --hr, --hot-reload Watches for changes in the supergraph and configuration file - --schema Prints out a JSON schema of the configuration file + --apollo-uplink-endpoints + The endpoints (comma separated) polled to fetch the latest supergraph schema [env: + APOLLO_UPLINK_ENDPOINTS=] + + --apollo-uplink-poll-interval + The time between polls to Apollo uplink. Minimum 10s [env: APOLLO_UPLINK_POLL_INTERVAL=] + [default: 10s] + + -c, --config + Configuration location relative to the project directory [env: + APOLLO_ROUTER_CONFIG_PATH=] + + -h, --help + Print help information + + --hot-reload + Reload configuration and schema files automatically [env: APOLLO_ROUTER_HOT_RELOAD=] + + --log + Log level (off|error|warn|info|debug|trace) [env: APOLLO_ROUTER_LOG=] [default: info] + + -s, --supergraph + Schema location relative to the project directory [env: APOLLO_ROUTER_SUPERGRAPH_PATH=] + + -V, --version + Display version and exit + +SUBCOMMANDS: + config Configuration subcommands + help Print this message or the help of the given subcommand(s) ``` ## Who is Apollo? diff --git a/apollo-router/Cargo.toml b/apollo-router/Cargo.toml index be39c059b4..91d6c33f59 100644 --- a/apollo-router/Cargo.toml +++ b/apollo-router/Cargo.toml @@ -70,6 +70,7 @@ derive_more = { version = "0.99.17", default-features = false, features = [ "display", ] } dhat = { version = "0.3.2", optional = true } +diff = "0.1.13" directories = "4.0.1" displaydoc = "0.2" flate2 = "1.0.24" @@ -84,6 +85,7 @@ hyper = { version = "0.14.23", features = ["server", "client"] } hyper-rustls = { version = "0.23.1", features = ["http1", "http2"] } indexmap = { version = "1.9.2", features = ["serde-1"] } itertools = "0.10.5" +jsonpath_lib = "0.3.0" jsonschema = { version = "0.16.1", default-features = false } lazy_static = "1.4.0" libc = "0.2.137" @@ -140,6 +142,7 @@ pin-project-lite = "0.2.9" prometheus = "0.13" prost = "0.9.0" prost-types = "0.9.0" +proteus = "0.5.0" rand = "0.8.5" rhai = { version = "1.11.0", features = ["sync", "serde", "internals"] } redis = { version = "0.21.6", optional = true, features = ["cluster", "tokio-comp"] } @@ -151,6 +154,7 @@ reqwest = { version = "0.11.13", default-features = false, features = [ "stream", ] } router-bridge = "0.1.11" +rust-embed="6.4.2" schemars = { version = "0.8.11", features = ["url"] } shellexpand = "2.1.2" sha2 = "0.10.6" @@ -193,7 +197,6 @@ tracing-core = "=0.1.26" tracing-futures = { version = "0.2.5", features = ["futures-03"] } tracing-opentelemetry = "0.17.4" tracing-subscriber = { version = "0.3.11", features = ["env-filter", "json"] } - url = { version = "2.3.1", features = ["serde"] } urlencoding = "2.1.2" uuid = { version = "1.2.2", features = ["serde", "v4"] } @@ -209,7 +212,6 @@ uname = "0.1.1" [dev-dependencies] insta = { version = "1.21.1", features = ["json", "redactions"] } introspector-gadget = "0.1.0" -jsonpath_lib = "0.3.0" maplit = "1.0.2" memchr = { version = "2.5.0", default-features = false } mockall = "0.11.3" diff --git a/apollo-router/src/configuration/expansion.rs b/apollo-router/src/configuration/expansion.rs index 4414d4c6c6..0ceed33868 100644 --- a/apollo-router/src/configuration/expansion.rs +++ b/apollo-router/src/configuration/expansion.rs @@ -84,10 +84,10 @@ impl Expansion { pub(crate) fn expand_env_variables( configuration: &serde_json::Value, - expansion: Expansion, + expansion: &Expansion, ) -> Result { let mut configuration = configuration.clone(); - visit(&mut configuration, &expansion)?; + visit(&mut configuration, expansion)?; Ok(configuration) } diff --git a/apollo-router/src/configuration/migrations/0001-telemetry_router_to_supergraph.yaml b/apollo-router/src/configuration/migrations/0001-telemetry_router_to_supergraph.yaml new file mode 100644 index 0000000000..7061a07d2a --- /dev/null +++ b/apollo-router/src/configuration/migrations/0001-telemetry_router_to_supergraph.yaml @@ -0,0 +1,6 @@ +description: telemetry.tracing.trace_config.attributes.router has been renamed to 'supergraph' for consistency +actions: + - type: move + from: telemetry.metrics.common.attributes.router + to: telemetry.metrics.common.attributes.supergraph + diff --git a/apollo-router/src/configuration/migrations/README.md b/apollo-router/src/configuration/migrations/README.md new file mode 100644 index 0000000000..494831e27b --- /dev/null +++ b/apollo-router/src/configuration/migrations/README.md @@ -0,0 +1,55 @@ +# Configuration migrations +This directory contains configuration migrations that can be applied to a router to a router config to bring it up to date with current config format. + +It uses [proteus](https://github.com/rust-playground/proteus) under the hood, which handles the complexities of merging Json. + +A migration has the following format: + +The filename should begin with a 5 digit numerical prefix. This allows us to apply migrations in a deterministic order. +`Filename: 00001-name.yaml` + +The yaml consists of a description and a number of actions: +```yaml +description: telemetry.tracing.trace_config.attributes.router has been renamed to 'supergraph' for consistency +actions: + - type: move + from: some.source + to: some.destination + - type: copy + from: some.source + to: some.destination + - type: delete + path: some.destination +``` + +Each action is applied in order. Use the following formats for from, to and path. + +## Getter (from) +| syntax | description | +---------|-------------| +| | this will grab the top-level value which could be any valid type: Object, array, ... | +| id | Gets a JSON Object's name. eg. key in HashMap | +| [0] | Gets a JSON Arrays index at the specified index. | +| profile.first_name | Combine Object names with dot notation. | +| profile.address[0].street | Combinations using dot notation and indexes is also supported. | + +## Setter (to, path) +| syntax | description | +---------|-------------| +| | this will set the top-level value in the destination | +| id | By itself any text is considered to be a JSON Object's name. | +| [] | This appends the source **data** to an array, creating it if it doesn't exist and is only valid at the end of set syntax eg. profile.address[] | +| [\+] | The source Array should append all of it's values into the destination Array and is only valid at the end of set syntax eg. profile.address[] | +| [\-] | The source Array values should replace the destination Array's values at the overlapping indexes and is only valid at the end of set syntax eg. profile.address[] | +| {} | This merges the supplied Object overtop of the existing and is only valid at the end of set syntax eg. profile{} | +| profile.first_name | Combine Object names with dot notation. | +| profile.address[0].street | Combinations using dot notation and indexes is also supported. | + +See [proteus](https://github.com/rust-playground/proteus) for more options. + +If a migration is deemed to have changed the configuration then the description of the migration will be output to the user as a warning. + +In future we will be able to use these files to support offline migrations. + +# Testing +Once you have made a new migration place a config file in `testdata/migrations`. It will automatically be picked up by the `upgrade_old_configuration` test. diff --git a/apollo-router/src/configuration/mod.rs b/apollo-router/src/configuration/mod.rs index 22e5d6ebfe..7819360d3b 100644 --- a/apollo-router/src/configuration/mod.rs +++ b/apollo-router/src/configuration/mod.rs @@ -5,6 +5,7 @@ mod expansion; mod schema; #[cfg(test)] mod tests; +mod upgrade; mod yaml; use std::fmt; @@ -20,6 +21,7 @@ use displaydoc::Display; use expansion::*; use itertools::Itertools; pub(crate) use schema::generate_config_schema; +pub(crate) use schema::generate_upgrade; use schemars::gen::SchemaGenerator; use schemars::schema::ObjectValidation; use schemars::schema::Schema; @@ -32,6 +34,7 @@ use serde_json::Map; use serde_json::Value; use thiserror::Error; +use crate::configuration::schema::Mode; use crate::executable::APOLLO_ROUTER_DEV_ENV; use crate::plugin::plugins; @@ -60,6 +63,9 @@ pub enum ConfigurationError { /// APOLLO_ROUTER_CONFIG_SUPPORTED_MODES must be of the format env,file,... Possible modes are 'env' and 'file'. InvalidExpansionModeConfig, + + /// could not migrate configuration: {error}. + MigrationFailure { error: String }, } /// The configuration for the router. @@ -353,7 +359,7 @@ impl FromStr for Configuration { type Err = ConfigurationError; fn from_str(s: &str) -> Result { - schema::validate_yaml_configuration(s, Expansion::default()?)?.validate() + schema::validate_yaml_configuration(s, Expansion::default()?, Mode::Upgrade)?.validate() } } diff --git a/apollo-router/src/configuration/schema.rs b/apollo-router/src/configuration/schema.rs index 258f3fdb4c..1a737b57ec 100644 --- a/apollo-router/src/configuration/schema.rs +++ b/apollo-router/src/configuration/schema.rs @@ -18,6 +18,8 @@ use super::yaml; use super::Configuration; use super::ConfigurationError; use super::APOLLO_PLUGIN_PREFIX; +pub(crate) use crate::configuration::upgrade::generate_upgrade; +pub(crate) use crate::configuration::upgrade::upgrade_configuration; /// Generate a JSON schema for the configuration. pub(crate) fn generate_config_schema() -> RootSchema { @@ -37,6 +39,15 @@ pub(crate) fn generate_config_schema() -> RootSchema { schema } +#[derive(Eq, PartialEq)] +pub(crate) enum Mode { + Upgrade, + + // This is used only in testing to ensure that we don't allow old config in our tests. + #[cfg(test)] + NoUpgrade, +} + /// Validate config yaml against the generated json schema. /// This is a tricky problem, and the solution here is by no means complete. /// In the case that validation cannot be performed then it will let serde validate as normal. The @@ -54,6 +65,7 @@ pub(crate) fn generate_config_schema() -> RootSchema { pub(crate) fn validate_yaml_configuration( raw_yaml: &str, expansion: Expansion, + migration: Mode, ) -> Result { let defaulted_yaml = if raw_yaml.trim().is_empty() { "plugins:".to_string() @@ -61,14 +73,12 @@ pub(crate) fn validate_yaml_configuration( raw_yaml.to_string() }; - let yaml = &serde_yaml::from_str(&defaulted_yaml).map_err(|e| { + let mut yaml = serde_yaml::from_str(&defaulted_yaml).map_err(|e| { ConfigurationError::InvalidConfiguration { message: "failed to parse yaml", error: e.to_string(), } })?; - - let expanded_yaml = expand_env_variables(yaml, expansion)?; let schema = serde_json::to_value(generate_config_schema()).map_err(|e| { ConfigurationError::InvalidConfiguration { message: "failed to parse schema", @@ -82,6 +92,18 @@ pub(crate) fn validate_yaml_configuration( message: "failed to compile schema", error: e.to_string(), })?; + + if migration == Mode::Upgrade { + let upgraded = upgrade_configuration(&yaml, true)?; + let expanded_yaml = expand_env_variables(&upgraded, &expansion)?; + if schema.validate(&expanded_yaml).is_ok() { + yaml = upgraded; + } else { + tracing::warn!("configuration could not be upgraded automatically as it had errors") + } + } + let expanded_yaml = expand_env_variables(&yaml, &expansion)?; + if let Err(errors) = schema.validate(&expanded_yaml) { // Validation failed, translate the errors into something nice for the user // We have to reparse the yaml to get the line number information for each error. diff --git a/apollo-router/src/configuration/snapshots/.skipconfigvalidation b/apollo-router/src/configuration/snapshots/.skipconfigvalidation new file mode 100644 index 0000000000..e69de29bb2 diff --git a/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__schema_generation.snap b/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__schema_generation.snap index 8a249cfcc5..971e0c6891 100644 --- a/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__schema_generation.snap +++ b/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__schema_generation.snap @@ -797,257 +797,6 @@ expression: "&schema" "description": "Configuration to add custom labels/attributes to metrics", "type": "object", "properties": { - "router": { - "description": "Configuration to forward header values or body values from router request/response in metric attributes/labels", - "type": "object", - "properties": { - "context": { - "description": "Configuration to forward values from the context to custom attributes/labels in metrics", - "type": "array", - "items": { - "description": "Configuration to forward context values in metric attributes/labels", - "type": "object", - "required": [ - "named" - ], - "properties": { - "default": { - "type": "string", - "nullable": true - }, - "named": { - "type": "string" - }, - "rename": { - "type": "string", - "nullable": true - } - }, - "additionalProperties": false - }, - "nullable": true - }, - "errors": { - "description": "Configuration to forward values from the error to custom attributes/labels in metrics", - "type": "object", - "properties": { - "extensions": { - "description": "Forward extensions values as custom attributes/labels in metrics", - "type": "array", - "items": { - "description": "Configuration to forward body values in metric attributes/labels", - "type": "object", - "required": [ - "name", - "path" - ], - "properties": { - "default": { - "type": "string", - "nullable": true - }, - "name": { - "type": "string" - }, - "path": { - "type": "string" - } - }, - "additionalProperties": false - }, - "nullable": true - }, - "include_messages": { - "description": "Will include the error message in a \"message\" attribute", - "default": false, - "type": "boolean" - } - }, - "additionalProperties": false, - "nullable": true - }, - "request": { - "description": "Configuration to forward headers or body values from the request to custom attributes/labels in metrics", - "type": "object", - "properties": { - "body": { - "description": "Forward body values as custom attributes/labels in metrics", - "type": "array", - "items": { - "description": "Configuration to forward body values in metric attributes/labels", - "type": "object", - "required": [ - "name", - "path" - ], - "properties": { - "default": { - "type": "string", - "nullable": true - }, - "name": { - "type": "string" - }, - "path": { - "type": "string" - } - }, - "additionalProperties": false - }, - "nullable": true - }, - "header": { - "description": "Forward header values as custom attributes/labels in metrics", - "type": "array", - "items": { - "description": "Configuration to forward header values in metric labels", - "anyOf": [ - { - "description": "Using a named header", - "type": "object", - "required": [ - "named" - ], - "properties": { - "default": { - "type": "string", - "nullable": true - }, - "named": { - "type": "string" - }, - "rename": { - "type": "string", - "nullable": true - } - }, - "additionalProperties": false - }, - { - "description": "Using a regex on the header name", - "type": "object", - "required": [ - "matching" - ], - "properties": { - "matching": { - "type": "string" - } - }, - "additionalProperties": false - } - ] - }, - "nullable": true - } - }, - "additionalProperties": false, - "nullable": true - }, - "response": { - "description": "Configuration to forward headers or body values from the response to custom attributes/labels in metrics", - "type": "object", - "properties": { - "body": { - "description": "Forward body values as custom attributes/labels in metrics", - "type": "array", - "items": { - "description": "Configuration to forward body values in metric attributes/labels", - "type": "object", - "required": [ - "name", - "path" - ], - "properties": { - "default": { - "type": "string", - "nullable": true - }, - "name": { - "type": "string" - }, - "path": { - "type": "string" - } - }, - "additionalProperties": false - }, - "nullable": true - }, - "header": { - "description": "Forward header values as custom attributes/labels in metrics", - "type": "array", - "items": { - "description": "Configuration to forward header values in metric labels", - "anyOf": [ - { - "description": "Using a named header", - "type": "object", - "required": [ - "named" - ], - "properties": { - "default": { - "type": "string", - "nullable": true - }, - "named": { - "type": "string" - }, - "rename": { - "type": "string", - "nullable": true - } - }, - "additionalProperties": false - }, - { - "description": "Using a regex on the header name", - "type": "object", - "required": [ - "matching" - ], - "properties": { - "matching": { - "type": "string" - } - }, - "additionalProperties": false - } - ] - }, - "nullable": true - } - }, - "additionalProperties": false, - "nullable": true - }, - "static": { - "description": "Configuration to insert custom attributes/labels in metrics", - "type": "array", - "items": { - "description": "Configuration to insert custom attributes/labels in metrics", - "type": "object", - "required": [ - "name", - "value" - ], - "properties": { - "name": { - "type": "string" - }, - "value": { - "type": "string" - } - }, - "additionalProperties": false - }, - "nullable": true - } - }, - "additionalProperties": false, - "nullable": true - }, "subgraph": { "description": "Configuration to forward header values or body values from subgraph request/response in metric attributes/labels", "type": "object", @@ -1558,6 +1307,257 @@ expression: "&schema" }, "additionalProperties": false, "nullable": true + }, + "supergraph": { + "description": "Configuration to forward header values or body values from router request/response in metric attributes/labels", + "type": "object", + "properties": { + "context": { + "description": "Configuration to forward values from the context to custom attributes/labels in metrics", + "type": "array", + "items": { + "description": "Configuration to forward context values in metric attributes/labels", + "type": "object", + "required": [ + "named" + ], + "properties": { + "default": { + "type": "string", + "nullable": true + }, + "named": { + "type": "string" + }, + "rename": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + "nullable": true + }, + "errors": { + "description": "Configuration to forward values from the error to custom attributes/labels in metrics", + "type": "object", + "properties": { + "extensions": { + "description": "Forward extensions values as custom attributes/labels in metrics", + "type": "array", + "items": { + "description": "Configuration to forward body values in metric attributes/labels", + "type": "object", + "required": [ + "name", + "path" + ], + "properties": { + "default": { + "type": "string", + "nullable": true + }, + "name": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "additionalProperties": false + }, + "nullable": true + }, + "include_messages": { + "description": "Will include the error message in a \"message\" attribute", + "default": false, + "type": "boolean" + } + }, + "additionalProperties": false, + "nullable": true + }, + "request": { + "description": "Configuration to forward headers or body values from the request to custom attributes/labels in metrics", + "type": "object", + "properties": { + "body": { + "description": "Forward body values as custom attributes/labels in metrics", + "type": "array", + "items": { + "description": "Configuration to forward body values in metric attributes/labels", + "type": "object", + "required": [ + "name", + "path" + ], + "properties": { + "default": { + "type": "string", + "nullable": true + }, + "name": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "additionalProperties": false + }, + "nullable": true + }, + "header": { + "description": "Forward header values as custom attributes/labels in metrics", + "type": "array", + "items": { + "description": "Configuration to forward header values in metric labels", + "anyOf": [ + { + "description": "Using a named header", + "type": "object", + "required": [ + "named" + ], + "properties": { + "default": { + "type": "string", + "nullable": true + }, + "named": { + "type": "string" + }, + "rename": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + { + "description": "Using a regex on the header name", + "type": "object", + "required": [ + "matching" + ], + "properties": { + "matching": { + "type": "string" + } + }, + "additionalProperties": false + } + ] + }, + "nullable": true + } + }, + "additionalProperties": false, + "nullable": true + }, + "response": { + "description": "Configuration to forward headers or body values from the response to custom attributes/labels in metrics", + "type": "object", + "properties": { + "body": { + "description": "Forward body values as custom attributes/labels in metrics", + "type": "array", + "items": { + "description": "Configuration to forward body values in metric attributes/labels", + "type": "object", + "required": [ + "name", + "path" + ], + "properties": { + "default": { + "type": "string", + "nullable": true + }, + "name": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "additionalProperties": false + }, + "nullable": true + }, + "header": { + "description": "Forward header values as custom attributes/labels in metrics", + "type": "array", + "items": { + "description": "Configuration to forward header values in metric labels", + "anyOf": [ + { + "description": "Using a named header", + "type": "object", + "required": [ + "named" + ], + "properties": { + "default": { + "type": "string", + "nullable": true + }, + "named": { + "type": "string" + }, + "rename": { + "type": "string", + "nullable": true + } + }, + "additionalProperties": false + }, + { + "description": "Using a regex on the header name", + "type": "object", + "required": [ + "matching" + ], + "properties": { + "matching": { + "type": "string" + } + }, + "additionalProperties": false + } + ] + }, + "nullable": true + } + }, + "additionalProperties": false, + "nullable": true + }, + "static": { + "description": "Configuration to insert custom attributes/labels in metrics", + "type": "array", + "items": { + "description": "Configuration to insert custom attributes/labels in metrics", + "type": "object", + "required": [ + "name", + "value" + ], + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "additionalProperties": false + }, + "nullable": true + } + }, + "additionalProperties": false, + "nullable": true } }, "additionalProperties": false, diff --git a/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__upgrade_old_configuration@telemetry_router_to_supergraph.router.yaml.snap b/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__upgrade_old_configuration@telemetry_router_to_supergraph.router.yaml.snap new file mode 100644 index 0000000000..1db53b0e56 --- /dev/null +++ b/apollo-router/src/configuration/snapshots/apollo_router__configuration__tests__upgrade_old_configuration@telemetry_router_to_supergraph.router.yaml.snap @@ -0,0 +1,14 @@ +--- +source: apollo-router/src/configuration/tests.rs +expression: new_config +--- +--- +telemetry: + metrics: + common: + attributes: + supergraph: + request: + header: + - named: fd + diff --git a/apollo-router/src/configuration/snapshots/apollo_router__configuration__upgrade__test__copy_array_element.snap b/apollo-router/src/configuration/snapshots/apollo_router__configuration__upgrade__test__copy_array_element.snap new file mode 100644 index 0000000000..7455fbbda0 --- /dev/null +++ b/apollo-router/src/configuration/snapshots/apollo_router__configuration__upgrade__test__copy_array_element.snap @@ -0,0 +1,19 @@ +--- +source: apollo-router/src/configuration/upgrade.rs +expression: "apply_migration(&source_doc(),\n &Migration::builder().action(Action::Copy {\n from: \"arr[0]\".to_string(),\n to: \"new.arr[0]\".to_string(),\n }).description(\"copy arr[0]\").build()).expect(\"expected successful migration\")" +--- +{ + "obj": { + "field1": 1, + "field2": 2 + }, + "arr": [ + "v1", + "v2" + ], + "new": { + "arr": [ + "v1" + ] + } +} diff --git a/apollo-router/src/configuration/snapshots/apollo_router__configuration__upgrade__test__copy_field.snap b/apollo-router/src/configuration/snapshots/apollo_router__configuration__upgrade__test__copy_field.snap new file mode 100644 index 0000000000..029e4e814a --- /dev/null +++ b/apollo-router/src/configuration/snapshots/apollo_router__configuration__upgrade__test__copy_field.snap @@ -0,0 +1,19 @@ +--- +source: apollo-router/src/configuration/upgrade.rs +expression: "apply_migration(&source_doc(),\n &Migration::builder().action(Action::Copy {\n from: \"obj.field1\".to_string(),\n to: \"new.obj.field1\".to_string(),\n }).description(\"copy field1\").build()).expect(\"expected successful migration\")" +--- +{ + "obj": { + "field1": 1, + "field2": 2 + }, + "arr": [ + "v1", + "v2" + ], + "new": { + "obj": { + "field1": 1 + } + } +} diff --git a/apollo-router/src/configuration/snapshots/apollo_router__configuration__upgrade__test__delete_array_element.snap b/apollo-router/src/configuration/snapshots/apollo_router__configuration__upgrade__test__delete_array_element.snap new file mode 100644 index 0000000000..850c1bb543 --- /dev/null +++ b/apollo-router/src/configuration/snapshots/apollo_router__configuration__upgrade__test__delete_array_element.snap @@ -0,0 +1,13 @@ +--- +source: apollo-router/src/configuration/upgrade.rs +expression: "apply_migration(&source_doc(),\n &Migration::builder().action(Action::Delete {\n path: \"arr[0]\".to_string(),\n }).description(\"delete arr[0]\").build()).expect(\"expected successful migration\")" +--- +{ + "obj": { + "field1": 1, + "field2": 2 + }, + "arr": [ + "v2" + ] +} diff --git a/apollo-router/src/configuration/snapshots/apollo_router__configuration__upgrade__test__delete_field.snap b/apollo-router/src/configuration/snapshots/apollo_router__configuration__upgrade__test__delete_field.snap new file mode 100644 index 0000000000..d3e1045312 --- /dev/null +++ b/apollo-router/src/configuration/snapshots/apollo_router__configuration__upgrade__test__delete_field.snap @@ -0,0 +1,13 @@ +--- +source: apollo-router/src/configuration/upgrade.rs +expression: "apply_migration(&source_doc(),\n &Migration::builder().action(Action::Delete {\n path: \"obj.field1\".to_string(),\n }).description(\"delete field1\").build()).expect(\"expected successful migration\")" +--- +{ + "obj": { + "field2": 2 + }, + "arr": [ + "v1", + "v2" + ] +} diff --git a/apollo-router/src/configuration/snapshots/apollo_router__configuration__upgrade__test__diff_upgrade_output.snap b/apollo-router/src/configuration/snapshots/apollo_router__configuration__upgrade__test__diff_upgrade_output.snap new file mode 100644 index 0000000000..7d35fea46b --- /dev/null +++ b/apollo-router/src/configuration/snapshots/apollo_router__configuration__upgrade__test__diff_upgrade_output.snap @@ -0,0 +1,10 @@ +--- +source: apollo-router/src/configuration/upgrade.rs +expression: "generate_upgrade_output(\"changed: bar\\nstable: 1.0\\ndeleted: gone\",\n \"changed: bif\\nstable: 1.0\\nadded: new\",\n true).expect(\"expected successful migration\")" +--- +-changed: bar ++changed: bif + stable: 1.0 +-deleted: gone ++added: new + diff --git a/apollo-router/src/configuration/snapshots/apollo_router__configuration__upgrade__test__move_array_element.snap b/apollo-router/src/configuration/snapshots/apollo_router__configuration__upgrade__test__move_array_element.snap new file mode 100644 index 0000000000..f8e479e750 --- /dev/null +++ b/apollo-router/src/configuration/snapshots/apollo_router__configuration__upgrade__test__move_array_element.snap @@ -0,0 +1,18 @@ +--- +source: apollo-router/src/configuration/upgrade.rs +expression: "apply_migration(&source_doc(),\n &Migration::builder().action(Action::Move {\n from: \"arr[0]\".to_string(),\n to: \"new.arr[0]\".to_string(),\n }).description(\"move arr[0]\").build()).expect(\"expected successful migration\")" +--- +{ + "obj": { + "field1": 1, + "field2": 2 + }, + "arr": [ + "v2" + ], + "new": { + "arr": [ + "v1" + ] + } +} diff --git a/apollo-router/src/configuration/snapshots/apollo_router__configuration__upgrade__test__move_field.snap b/apollo-router/src/configuration/snapshots/apollo_router__configuration__upgrade__test__move_field.snap new file mode 100644 index 0000000000..e8136fb407 --- /dev/null +++ b/apollo-router/src/configuration/snapshots/apollo_router__configuration__upgrade__test__move_field.snap @@ -0,0 +1,18 @@ +--- +source: apollo-router/src/configuration/upgrade.rs +expression: "apply_migration(&source_doc(),\n &Migration::builder().action(Action::Move {\n from: \"obj.field1\".to_string(),\n to: \"new.obj.field1\".to_string(),\n }).description(\"move field1\").build()).expect(\"expected successful migration\")" +--- +{ + "obj": { + "field2": 2 + }, + "arr": [ + "v1", + "v2" + ], + "new": { + "obj": { + "field1": 1 + } + } +} diff --git a/apollo-router/src/configuration/snapshots/apollo_router__configuration__upgrade__test__upgrade_output.snap b/apollo-router/src/configuration/snapshots/apollo_router__configuration__upgrade__test__upgrade_output.snap new file mode 100644 index 0000000000..2f1e6fab76 --- /dev/null +++ b/apollo-router/src/configuration/snapshots/apollo_router__configuration__upgrade__test__upgrade_output.snap @@ -0,0 +1,8 @@ +--- +source: apollo-router/src/configuration/upgrade.rs +expression: "generate_upgrade_output(\"changed: bar\\nstable: 1.0\\ndeleted: gone\",\n \"changed: bif\\nstable: 1.0\\nadded: new\",\n false).expect(\"expected successful migration\")" +--- +changed: bif +stable: 1.0 +added: new + diff --git a/apollo-router/src/configuration/testdata/migrations/.skipconfigvalidation b/apollo-router/src/configuration/testdata/migrations/.skipconfigvalidation new file mode 100644 index 0000000000..e69de29bb2 diff --git a/apollo-router/src/configuration/testdata/migrations/telemetry_router_to_supergraph.router.yaml b/apollo-router/src/configuration/testdata/migrations/telemetry_router_to_supergraph.router.yaml new file mode 100644 index 0000000000..0e44d606a6 --- /dev/null +++ b/apollo-router/src/configuration/testdata/migrations/telemetry_router_to_supergraph.router.yaml @@ -0,0 +1,8 @@ +telemetry: + metrics: + common: + attributes: + router: + request: + header: + - named: "fd" diff --git a/apollo-router/src/configuration/tests.rs b/apollo-router/src/configuration/tests.rs index c05a8e6434..b2fb1733dd 100644 --- a/apollo-router/src/configuration/tests.rs +++ b/apollo-router/src/configuration/tests.rs @@ -6,6 +6,7 @@ use http::Uri; #[cfg(unix)] use insta::assert_json_snapshot; use regex::Regex; +use rust_embed::RustEmbed; #[cfg(unix)] use schemars::gen::SchemaSettings; use walkdir::DirEntry; @@ -166,6 +167,7 @@ subgraphs: account: true "#, Expansion::default().unwrap(), + Mode::NoUpgrade, ) .expect_err("should have resulted in an error"); assert_eq!(error.to_string(), String::from("unknown fields: additional properties are not allowed ('subgraphs' was/were unexpected)")); @@ -179,6 +181,7 @@ unknown: foo: true "#, Expansion::default().unwrap(), + Mode::NoUpgrade, ) .expect_err("should have resulted in an error"); assert_eq!( @@ -195,6 +198,7 @@ fn empty_config() { r#" "#, Expansion::default().unwrap(), + Mode::NoUpgrade, ) .expect("should have been ok with an empty config"); } @@ -221,6 +225,7 @@ telemetry: another_non_existant: 3 "#, Expansion::default().unwrap(), + Mode::NoUpgrade, ) .expect_err("should have resulted in an error"); insta::assert_snapshot!(error.to_string()); @@ -238,6 +243,7 @@ supergraph: another_one: true "#, Expansion::default().unwrap(), + Mode::NoUpgrade, ) .expect_err("should have resulted in an error"); insta::assert_snapshot!(error.to_string()); @@ -253,6 +259,7 @@ supergraph: listen: true "#, Expansion::default().unwrap(), + Mode::NoUpgrade, ) .expect_err("should have resulted in an error"); insta::assert_snapshot!(error.to_string()); @@ -270,6 +277,7 @@ cors: allow_headers: [ Content-Type, 5 ] "#, Expansion::default().unwrap(), + Mode::NoUpgrade, ) .expect_err("should have resulted in an error"); insta::assert_snapshot!(error.to_string()); @@ -289,6 +297,7 @@ cors: - 5 "#, Expansion::default().unwrap(), + Mode::NoUpgrade, ) .expect_err("should have resulted in an error"); insta::assert_snapshot!(error.to_string()); @@ -303,6 +312,7 @@ cors: allow_headers: [ "*" ] "#, Expansion::default().unwrap(), + Mode::NoUpgrade, ) .expect("should not have resulted in an error"); let error = cfg @@ -321,6 +331,7 @@ cors: methods: [ GET, "*" ] "#, Expansion::default().unwrap(), + Mode::NoUpgrade, ) .expect("should not have resulted in an error"); let error = cfg @@ -339,6 +350,7 @@ cors: allow_any_origin: true "#, Expansion::default().unwrap(), + Mode::NoUpgrade, ) .expect("should not have resulted in an error"); let error = cfg @@ -401,7 +413,11 @@ fn validate_project_config_files() { }; for yaml in yamls { - if let Err(e) = validate_yaml_configuration(&yaml, Expansion::default().unwrap()) { + if let Err(e) = validate_yaml_configuration( + &yaml, + Expansion::default().unwrap(), + Mode::NoUpgrade, + ) { panic!( "{} configuration error: \n{}", entry.path().to_string_lossy(), @@ -422,6 +438,7 @@ supergraph: introspection: ${env.TEST_CONFIG_NUMERIC_ENV_UNIQUE:-true} "#, Expansion::default().unwrap(), + Mode::NoUpgrade, ) .expect_err("Must have an error because we expect a boolean"); insta::assert_snapshot!(error.to_string()); @@ -440,6 +457,7 @@ cors: allow_headers: [ Content-Type, "${env.TEST_CONFIG_NUMERIC_ENV_UNIQUE}" ] "#, Expansion::default().unwrap(), + Mode::NoUpgrade, ) .expect_err("should have resulted in an error"); insta::assert_snapshot!(error.to_string()); @@ -461,6 +479,7 @@ cors: - "${env.TEST_CONFIG_NUMERIC_ENV_UNIQUE:-true}" "#, Expansion::default().unwrap(), + Mode::NoUpgrade, ) .expect_err("should have resulted in an error"); insta::assert_snapshot!(error.to_string()); @@ -478,6 +497,7 @@ supergraph: another_one: foo "#, Expansion::default().unwrap(), + Mode::NoUpgrade, ) .expect_err("should have resulted in an error"); insta::assert_snapshot!(error.to_string()); @@ -491,6 +511,7 @@ supergraph: introspection: ${env.TEST_CONFIG_UNKNOWN_WITH_NO_DEFAULT} "#, Expansion::default().unwrap(), + Mode::NoUpgrade, ) .expect_err("must have an error because the env variable is unknown"); insta::assert_snapshot!(error.to_string()); @@ -507,6 +528,7 @@ supergraph: .prefix("TEST_CONFIG") .supported_mode("env") .build(), + Mode::NoUpgrade, ) .expect_err("must have an error because the mode is unknown"); insta::assert_snapshot!(error.to_string()); @@ -524,6 +546,7 @@ supergraph: .prefix("TEST_CONFIG") .supported_mode("env") .build(), + Mode::NoUpgrade, ) .expect("must have expanded successfully"); } @@ -544,8 +567,49 @@ supergraph: path.to_string_lossy() ), Expansion::builder().supported_mode("file").build(), + Mode::NoUpgrade, ) .expect("must have expanded successfully"); assert!(config.supergraph.introspection); } + +#[derive(RustEmbed)] +#[folder = "src/configuration/testdata/migrations"] +struct Asset; + +#[test] +fn upgrade_old_configuration() { + for file_name in Asset::iter() { + if file_name.ends_with(".yaml") { + let source = Asset::get(&file_name).expect("test file must exist"); + let input = std::str::from_utf8(&source.data) + .expect("expected utf8") + .to_string(); + let new_config = crate::configuration::upgrade::upgrade_configuration( + &serde_yaml::from_str(&input).expect("config must be valid yaml"), + true, + ) + .expect("configuration could not be updated"); + let new_config = + serde_yaml::to_string(&new_config).expect("must be able to serialize config"); + + let result = validate_yaml_configuration( + &new_config, + Expansion::builder().build(), + Mode::NoUpgrade, + ); + + match result { + Ok(_) => { + insta::with_settings!({snapshot_suffix => file_name}, { + insta::assert_snapshot!(new_config) + }); + } + Err(e) => { + panic!("migrated configuration had validation errors:\n{}\n\noriginal configuration:\n{}\n\nmigrated configuration:\n{}", e, input, new_config) + } + } + } + } +} diff --git a/apollo-router/src/configuration/upgrade.rs b/apollo-router/src/configuration/upgrade.rs new file mode 100644 index 0000000000..9ed15c5ea8 --- /dev/null +++ b/apollo-router/src/configuration/upgrade.rs @@ -0,0 +1,325 @@ +use std::fmt::Write as _; + +use itertools::Itertools; +use proteus::Parser; +use proteus::TransformBuilder; +use rust_embed::RustEmbed; +use serde::Deserialize; +use serde_json::Value; + +use crate::error::ConfigurationError; + +#[derive(RustEmbed)] +#[folder = "src/configuration/migrations"] +struct Asset; + +#[derive(Deserialize, buildstructor::Builder)] +struct Migration { + description: String, + actions: Vec, +} + +#[derive(Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +enum Action { + Delete { path: String }, + Copy { from: String, to: String }, + Move { from: String, to: String }, +} + +const REMOVAL_VALUE: &str = "__PLEASE_DELETE_ME"; +const REMOVAL_EXPRESSION: &str = r#"const("__PLEASE_DELETE_ME")"#; + +pub(crate) fn upgrade_configuration( + config: &serde_json::Value, + log_warnings: bool, +) -> Result { + // Transformers are loaded from a file and applied in order + let migrations: Vec = Asset::iter() + .sorted() + .filter(|filename| filename.ends_with(".yaml")) + .map(|filename| Asset::get(&filename).expect("migration must exist").data) + .map(|data| serde_yaml::from_slice(&data).expect("migration must be valid")) + .collect(); + + let mut config = config.clone(); + + let mut effective_migrations = Vec::new(); + for migration in &migrations { + let new_config = apply_migration(&config, migration)?; + + // If the config has been modified by the migration then let the user know + if new_config != config { + effective_migrations.push(migration); + } + + // Get ready for the next migration + config = new_config; + } + if !effective_migrations.is_empty() && log_warnings { + tracing::warn!("router configuration contains deprecated options: \n\n{}\n\nThese will become errors in the future. Run `router config upgrade ` to see a suggested upgraded configuration.", effective_migrations.iter().enumerate().map(|(idx, m)|format!(" {}. {}", idx + 1, m.description)).join("\n\n")); + } + Ok(config) +} + +fn apply_migration(config: &Value, migration: &Migration) -> Result { + let mut transformer_builder = TransformBuilder::default(); + //We always copy the entire doc to the destination first + transformer_builder = + transformer_builder.add_action(Parser::parse("", "").expect("migration must be valid")); + for action in &migration.actions { + match action { + Action::Delete { path } => { + // Deleting isn't actually supported by protus so we add a magic value to delete later + transformer_builder = transformer_builder.add_action( + Parser::parse(REMOVAL_EXPRESSION, path).expect("migration must be valid"), + ); + } + Action::Copy { from, to } => { + transformer_builder = transformer_builder + .add_action(Parser::parse(from, to).expect("migration must be valid")); + } + Action::Move { from, to } => { + transformer_builder = transformer_builder + .add_action(Parser::parse(from, to).expect("migration must be valid")); + // Deleting isn't actually supported by protus so we add a magic value to delete later + transformer_builder = transformer_builder.add_action( + Parser::parse(REMOVAL_EXPRESSION, from).expect("migration must be valid"), + ); + } + } + } + let transformer = transformer_builder + .build() + .expect("transformer for migration must be valid"); + let mut new_config = + transformer + .apply(config) + .map_err(|e| ConfigurationError::MigrationFailure { + error: e.to_string(), + })?; + + // Now we need to clean up elements that should be deleted. + cleanup(&mut new_config); + + Ok(new_config) +} + +pub(crate) fn generate_upgrade(config: &str, diff: bool) -> Result { + let parsed_config = + serde_yaml::from_str(config).map_err(|e| ConfigurationError::MigrationFailure { + error: e.to_string(), + })?; + let upgraded_config = upgrade_configuration(&parsed_config, true).map_err(|e| { + ConfigurationError::MigrationFailure { + error: e.to_string(), + } + })?; + let upgraded_config = serde_yaml::to_string(&upgraded_config).map_err(|e| { + ConfigurationError::MigrationFailure { + error: e.to_string(), + } + })?; + generate_upgrade_output(config, &upgraded_config, diff) +} + +pub(crate) fn generate_upgrade_output( + config: &str, + upgraded_config: &str, + diff: bool, +) -> Result { + // serde doesn't deal with whitespace and comments, these are lost in the upgrade process, so instead we try and preserve this in the diff. + // It's not ideal, and ideally the upgrade process should work on a DOM that is not serde, but for now we just make a best effort to preserve comments and whitespace. + // There absolutely are issues where comments will get stripped, but the output should be `correct`. + let mut output = String::new(); + + let diff_result = diff::lines(config, upgraded_config); + + for diff_line in diff_result { + match diff_line { + diff::Result::Left(l) => { + let trimmed = l.trim(); + if !trimmed.starts_with('#') && !trimmed.is_empty() { + if diff { + writeln!(output, "-{}", l).expect("write will never fail"); + } + } else if diff { + writeln!(output, " {}", l).expect("write will never fail"); + } else { + writeln!(output, "{}", l).expect("write will never fail"); + } + } + diff::Result::Both(l, _) => { + if diff { + writeln!(output, " {}", l).expect("write will never fail"); + } else { + writeln!(output, "{}", l).expect("write will never fail"); + } + } + diff::Result::Right(r) => { + let trimmed = r.trim(); + if trimmed != "---" && !trimmed.is_empty() { + if diff { + writeln!(output, "+{}", r).expect("write will never fail"); + } else { + writeln!(output, "{}", r).expect("write will never fail"); + } + } + } + } + } + Ok(output) +} + +fn cleanup(value: &mut Value) { + match value { + Value::Null => {} + Value::Bool(_) => {} + Value::Number(_) => {} + Value::String(_) => {} + Value::Array(a) => { + a.retain(|v| &Value::String(REMOVAL_VALUE.to_string()) != v); + for value in a { + cleanup(value); + } + } + Value::Object(o) => { + o.retain(|_, v| &Value::String(REMOVAL_VALUE.to_string()) != v); + for value in o.values_mut() { + cleanup(value); + } + } + } +} + +#[cfg(test)] +mod test { + use serde_json::json; + use serde_json::Value; + + use crate::configuration::upgrade::apply_migration; + use crate::configuration::upgrade::generate_upgrade_output; + use crate::configuration::upgrade::Action; + use crate::configuration::upgrade::Migration; + + fn source_doc() -> Value { + json!( { + "obj": { + "field1": 1, + "field2": 2 + }, + "arr": [ + "v1", + "v2" + ] + }) + } + + #[test] + fn delete_field() { + insta::assert_json_snapshot!(apply_migration( + &source_doc(), + &Migration::builder() + .action(Action::Delete { + path: "obj.field1".to_string() + }) + .description("delete field1") + .build(), + ) + .expect("expected successful migration")); + } + + #[test] + fn delete_array_element() { + insta::assert_json_snapshot!(apply_migration( + &source_doc(), + &Migration::builder() + .action(Action::Delete { + path: "arr[0]".to_string() + }) + .description("delete arr[0]") + .build(), + ) + .expect("expected successful migration")); + } + + #[test] + fn move_field() { + insta::assert_json_snapshot!(apply_migration( + &source_doc(), + &Migration::builder() + .action(Action::Move { + from: "obj.field1".to_string(), + to: "new.obj.field1".to_string() + }) + .description("move field1") + .build(), + ) + .expect("expected successful migration")); + } + + #[test] + fn move_array_element() { + insta::assert_json_snapshot!(apply_migration( + &source_doc(), + &Migration::builder() + .action(Action::Move { + from: "arr[0]".to_string(), + to: "new.arr[0]".to_string() + }) + .description("move arr[0]") + .build(), + ) + .expect("expected successful migration")); + } + + #[test] + fn copy_field() { + insta::assert_json_snapshot!(apply_migration( + &source_doc(), + &Migration::builder() + .action(Action::Copy { + from: "obj.field1".to_string(), + to: "new.obj.field1".to_string() + }) + .description("copy field1") + .build(), + ) + .expect("expected successful migration")); + } + + #[test] + fn copy_array_element() { + insta::assert_json_snapshot!(apply_migration( + &source_doc(), + &Migration::builder() + .action(Action::Copy { + from: "arr[0]".to_string(), + to: "new.arr[0]".to_string() + }) + .description("copy arr[0]") + .build(), + ) + .expect("expected successful migration")); + } + + #[test] + fn diff_upgrade_output() { + insta::assert_snapshot!(generate_upgrade_output( + "changed: bar\nstable: 1.0\ndeleted: gone", + "changed: bif\nstable: 1.0\nadded: new", + true + ) + .expect("expected successful migration")); + } + + #[test] + fn upgrade_output() { + insta::assert_snapshot!(generate_upgrade_output( + "changed: bar\nstable: 1.0\ndeleted: gone", + "changed: bif\nstable: 1.0\nadded: new", + false + ) + .expect("expected successful migration")); + } +} diff --git a/apollo-router/src/executable.rs b/apollo-router/src/executable.rs index cef3b3bf61..dc807b0091 100644 --- a/apollo-router/src/executable.rs +++ b/apollo-router/src/executable.rs @@ -12,8 +12,10 @@ use anyhow::Context; use anyhow::Result; use clap::AppSettings; use clap::ArgAction; +use clap::Args; use clap::CommandFactory; use clap::Parser; +use clap::Subcommand; use directories::ProjectDirs; use once_cell::sync::OnceCell; use tracing::dispatcher::with_default; @@ -24,6 +26,7 @@ use url::ParseError; use url::Url; use crate::configuration::generate_config_schema; +use crate::configuration::generate_upgrade; use crate::configuration::Configuration; use crate::configuration::ConfigurationError; use crate::router::ConfigurationSource; @@ -97,6 +100,37 @@ extern "C" fn drop_ad_hoc_profiler() { } } +/// Subcommands +#[derive(Subcommand, Debug)] +enum Commands { + /// Configuration subcommands. + Config(ConfigSubcommandArgs), +} + +#[derive(Args, Debug)] +struct ConfigSubcommandArgs { + /// Subcommands + #[clap(subcommand)] + command: ConfigSubcommand, +} + +#[derive(Subcommand, Debug)] +enum ConfigSubcommand { + /// Print the json configuration schema. + Schema, + + /// Print upgraded configuration. + Upgrade { + /// The location of the config to upgrade. + #[clap(parse(from_os_str), env = "APOLLO_ROUTER_CONFIG_PATH")] + config_path: PathBuf, + + /// Print a diff. + #[clap(parse(from_flag), long)] + diff: bool, + }, +} + /// Options for the router #[derive(Parser, Debug)] #[clap( @@ -151,9 +185,13 @@ pub(crate) struct Opt { supergraph_path: Option, /// Prints the configuration schema. - #[clap(long, action(ArgAction::SetTrue))] + #[clap(long, action(ArgAction::SetTrue), hide(true))] schema: bool, + /// Subcommands + #[clap(subcommand)] + command: Option, + /// Your Apollo key. #[clap(skip = std::env::var("APOLLO_KEY").ok())] apollo_key: Option, @@ -294,12 +332,6 @@ impl Executable { copy_args_to_env(); - if opt.schema { - let schema = generate_config_schema(); - println!("{}", serde_json::to_string_pretty(&schema)?); - return Ok(()); - } - let builder = tracing_subscriber::fmt::fmt().with_env_filter( EnvFilter::try_new(&opt.log_level).context("could not parse log configuration")?, ); @@ -315,15 +347,41 @@ impl Executable { }; GLOBAL_ENV_FILTER.set(opt.log_level.clone()).expect( - "failed setting the global env filter. THe start() function should only be called once", + "failed setting the global env filter. The start() function should only be called once", ); - // The dispatcher we created is passed explicitely here to make sure we display the logs - // in the initialization phase and in the state machine code, before a global subscriber - // is set using the configuration file - Self::inner_start(shutdown, schema, config, opt, dispatcher.clone()) - .with_subscriber(dispatcher) - .await + if opt.schema { + eprintln!("`router --schema` is deprecated. Use `router config schema`"); + let schema = generate_config_schema(); + println!("{}", serde_json::to_string_pretty(&schema)?); + return Ok(()); + } + + match opt.command.as_ref() { + Some(Commands::Config(ConfigSubcommandArgs { + command: ConfigSubcommand::Schema, + })) => { + let schema = generate_config_schema(); + println!("{}", serde_json::to_string_pretty(&schema)?); + Ok(()) + } + Some(Commands::Config(ConfigSubcommandArgs { + command: ConfigSubcommand::Upgrade { config_path, diff }, + })) => { + let config_string = std::fs::read_to_string(config_path)?; + let output = generate_upgrade(&config_string, *diff)?; + println!("{}", output); + Ok(()) + } + None => { + // The dispatcher we created is passed explicitly here to make sure we display the logs + // in the initialization phase and in the state machine code, before a global subscriber + // is set using the configuration file + Self::inner_start(shutdown, schema, config, opt, dispatcher.clone()) + .with_subscriber(dispatcher) + .await + } + } } async fn inner_start( diff --git a/apollo-router/src/plugins/telemetry/metrics/mod.rs b/apollo-router/src/plugins/telemetry/metrics/mod.rs index 01a9bccb59..97fa39ca13 100644 --- a/apollo-router/src/plugins/telemetry/metrics/mod.rs +++ b/apollo-router/src/plugins/telemetry/metrics/mod.rs @@ -43,7 +43,7 @@ pub(crate) type MetricsExporterHandle = Box; /// Configuration to add custom attributes/labels on metrics pub(crate) struct MetricsAttributesConf { /// Configuration to forward header values or body values from router request/response in metric attributes/labels - pub(crate) router: Option, + pub(crate) supergraph: Option, /// Configuration to forward header values or body values from subgraph request/response in metric attributes/labels pub(crate) subgraph: Option, } diff --git a/apollo-router/src/plugins/telemetry/mod.rs b/apollo-router/src/plugins/telemetry/mod.rs index aa0d921c9a..d9d7566520 100644 --- a/apollo-router/src/plugins/telemetry/mod.rs +++ b/apollo-router/src/plugins/telemetry/mod.rs @@ -761,7 +761,7 @@ impl Telemetry { if let Some(MetricsCommon { attributes: Some(MetricsAttributesConf { - router: Some(forward_attributes), + supergraph: Some(forward_attributes), .. }), .. @@ -840,7 +840,7 @@ impl Telemetry { .common .as_ref() .and_then(|c| c.attributes.as_ref()) - .and_then(|a| a.router.as_ref()) + .and_then(|a| a.supergraph.as_ref()) { attributes.extend( router_attributes_conf @@ -1041,7 +1041,7 @@ impl Telemetry { .as_ref() .and_then(|m| m.common.as_ref()) .and_then(|c| c.attributes.as_ref()) - .and_then(|c| c.router.as_ref()) + .and_then(|c| c.supergraph.as_ref()) { metric_attrs.extend( subgraph_attributes_conf @@ -1312,7 +1312,7 @@ mod tests { "metrics": { "common": { "attributes": { - "router": { + "supergraph": { "static": [ { "name": "myname", @@ -1513,7 +1513,7 @@ mod tests { "common": { "service_name": "apollo-router", "attributes": { - "router": { + "supergraph": { "static": [ { "name": "myname", diff --git a/docs/source/configuration/metrics.mdx b/docs/source/configuration/metrics.mdx index dc70a6d79f..3d6517633f 100644 --- a/docs/source/configuration/metrics.mdx +++ b/docs/source/configuration/metrics.mdx @@ -92,7 +92,7 @@ telemetry: metrics: common: attributes: - router: # Attribute configuration for requests to/responses from the router + supergraph: # Attribute configuration for requests to/responses from the router static: - name: "version" value: "v1.0.0" diff --git a/docs/source/configuration/overview.mdx b/docs/source/configuration/overview.mdx index b81dc9bc89..2a3d8043a2 100644 --- a/docs/source/configuration/overview.mdx +++ b/docs/source/configuration/overview.mdx @@ -182,7 +182,7 @@ The default value is `10s` (ten seconds), which is also the minimum allowed valu -##### `--schema` +##### `schema` @@ -208,6 +208,48 @@ Prints out the Apollo Router's version. +## Config subcommand + + + + + + + + + + + + + + + + + + + + + + +
Argument / Environment VariableDescription
+ +##### `schema` + + + +Prints out a JSON schema of the Router's configuration file, including [plugin configuration](#plugins). + +
+ +##### `upgrade` + + + +Print configuration that has been upgraded to the current Router version. + +
+ + ## YAML config file The Apollo Router takes an optional YAML configuration file as input via the `--config` option. If the `--hot-reload` flag is also passed (or the `APOLLO_ROUTER_HOT_RELOAD` environment variable is set to `true`), the router automatically restarts when changes to the configuration file are made. @@ -389,7 +431,7 @@ The Apollo Router can generate a JSON schema for config validation in your text Generate the schema with the following command: ```bash -./router --schema > configuration_schema.json +./router config schema > configuration_schema.json ``` After you generate the schema, configure your text editor. Here are the instructions for some commonly used editors: @@ -399,3 +441,25 @@ After you generate the schema, configure your text editor. Here are the instruct - [IntelliJ](https://www.jetbrains.com/help/idea/json.html#ws_json_using_schemas) - [Sublime](https://github.com/sublimelsp/LSP-yaml) - [Vim](https://github.com/Quramy/vison) + +## Upgrading your Router configuration + +Occasionally breaking changes are made to the Apollo Router yaml format. Usually to extend functionality or improve usability. + +When running the Router with old configuration: + +1. if you have errors in your config then a warning will be emitted on startup. +2. if the Router config can be upgraded automatically with no validation errors then it will continue to load. +3. if the Router config had validation errors after automatic upgrade loading will stop. + +When you are notified that configuration can be upgraded use the `router config upgrade` command to see what your configuration should look like: + +```bash +./router config upgrade +``` + +To see what changes were made output the upgraded config diff using: + +```bash +./router config upgrade --diff +```