diff --git a/compute_tools/src/compute.rs b/compute_tools/src/compute.rs index c591d9711a..9744cc2dac 100644 --- a/compute_tools/src/compute.rs +++ b/compute_tools/src/compute.rs @@ -217,6 +217,46 @@ pub struct ParsedSpec { pub endpoint_storage_token: Option, } +impl ParsedSpec { + pub fn validate(&self) -> Result<(), String> { + // Only Primary nodes are using safekeeper_connstrings, and at the moment + // this method only validates that part of the specs. + if self.spec.mode != ComputeMode::Primary { + return Ok(()); + } + + // While it seems like a good idea to check for an odd number of entries in + // the safekeepers connection string, changes to the list of safekeepers might + // incur appending a new server to a list of 3, in which case a list of 4 + // entries is okay in production. + // + // Still we want unique entries, and at least one entry in the vector + if self.safekeeper_connstrings.is_empty() { + return Err(String::from("safekeeper_connstrings is empty")); + } + + // check for uniqueness of the connection strings in the set + let mut connstrings = self.safekeeper_connstrings.clone(); + + connstrings.sort(); + let mut previous = &connstrings[0]; + + for current in connstrings.iter().skip(1) { + // duplicate entry? + if current == previous { + return Err(format!( + "duplicate entry in safekeeper_connstrings: {}!", + current, + )); + } + + previous = current; + } + + Ok(()) + } +} + impl TryFrom for ParsedSpec { type Error = String; fn try_from(spec: ComputeSpec) -> Result { @@ -246,6 +286,7 @@ impl TryFrom for ParsedSpec { } else { spec.safekeeper_connstrings.clone() }; + let storage_auth_token = spec.storage_auth_token.clone(); let tenant_id: TenantId = if let Some(tenant_id) = spec.tenant_id { tenant_id @@ -280,7 +321,7 @@ impl TryFrom for ParsedSpec { .clone() .or_else(|| spec.cluster.settings.find("neon.endpoint_storage_token")); - Ok(ParsedSpec { + let res = ParsedSpec { spec, pageserver_connstr, safekeeper_connstrings, @@ -289,7 +330,11 @@ impl TryFrom for ParsedSpec { timeline_id, endpoint_storage_addr, endpoint_storage_token, - }) + }; + + // Now check validity of the parsed specification + res.validate()?; + Ok(res) } } @@ -2342,3 +2387,21 @@ impl JoinSetExt for tokio::task::JoinSet { }) } } + +#[cfg(test)] +mod tests { + use std::fs::File; + + use super::*; + + #[test] + fn duplicate_safekeeper_connstring() { + let file = File::open("tests/cluster_spec.json").unwrap(); + let spec: ComputeSpec = serde_json::from_reader(file).unwrap(); + + match ParsedSpec::try_from(spec.clone()) { + Ok(_p) => panic!("Failed to detect duplicate entry"), + Err(e) => assert!(e.starts_with("duplicate entry in safekeeper_connstrings:")), + }; + } +} diff --git a/compute_tools/tests/README.md b/compute_tools/tests/README.md new file mode 100644 index 0000000000..adeb9ef4b6 --- /dev/null +++ b/compute_tools/tests/README.md @@ -0,0 +1,6 @@ +### Test files + +The file `cluster_spec.json` has been copied over from libs/compute_api +tests, with some edits: + + - the neon.safekeepers setting contains a duplicate value diff --git a/compute_tools/tests/cluster_spec.json b/compute_tools/tests/cluster_spec.json new file mode 100644 index 0000000000..5655a94de4 --- /dev/null +++ b/compute_tools/tests/cluster_spec.json @@ -0,0 +1,245 @@ +{ + "format_version": 1.0, + + "timestamp": "2021-05-23T18:25:43.511Z", + "operation_uuid": "0f657b36-4b0f-4a2d-9c2e-1dcd615e7d8b", + + "cluster": { + "cluster_id": "test-cluster-42", + "name": "Zenith Test", + "state": "restarted", + "roles": [ + { + "name": "postgres", + "encrypted_password": "6b1d16b78004bbd51fa06af9eda75972", + "options": null + }, + { + "name": "alexk", + "encrypted_password": null, + "options": null + }, + { + "name": "zenith \"new\"", + "encrypted_password": "5b1d16b78004bbd51fa06af9eda75972", + "options": null + }, + { + "name": "zen", + "encrypted_password": "9b1d16b78004bbd51fa06af9eda75972" + }, + { + "name": "\"name\";\\n select 1;", + "encrypted_password": "5b1d16b78004bbd51fa06af9eda75972" + }, + { + "name": "MyRole", + "encrypted_password": "5b1d16b78004bbd51fa06af9eda75972" + } + ], + "databases": [ + { + "name": "DB2", + "owner": "alexk", + "options": [ + { + "name": "LC_COLLATE", + "value": "C", + "vartype": "string" + }, + { + "name": "LC_CTYPE", + "value": "C", + "vartype": "string" + }, + { + "name": "TEMPLATE", + "value": "template0", + "vartype": "enum" + } + ] + }, + { + "name": "zenith", + "owner": "MyRole" + }, + { + "name": "zen", + "owner": "zen" + } + ], + "settings": [ + { + "name": "fsync", + "value": "off", + "vartype": "bool" + }, + { + "name": "wal_level", + "value": "logical", + "vartype": "enum" + }, + { + "name": "hot_standby", + "value": "on", + "vartype": "bool" + }, + { + "name": "prewarm_lfc_on_startup", + "value": "off", + "vartype": "bool" + }, + { + "name": "neon.safekeepers", + "value": "127.0.0.1:6502,127.0.0.1:6503,127.0.0.1:6501,127.0.0.1:6502", + "vartype": "string" + }, + { + "name": "wal_log_hints", + "value": "on", + "vartype": "bool" + }, + { + "name": "log_connections", + "value": "on", + "vartype": "bool" + }, + { + "name": "shared_buffers", + "value": "32768", + "vartype": "integer" + }, + { + "name": "port", + "value": "55432", + "vartype": "integer" + }, + { + "name": "max_connections", + "value": "100", + "vartype": "integer" + }, + { + "name": "max_wal_senders", + "value": "10", + "vartype": "integer" + }, + { + "name": "listen_addresses", + "value": "0.0.0.0", + "vartype": "string" + }, + { + "name": "wal_sender_timeout", + "value": "0", + "vartype": "integer" + }, + { + "name": "password_encryption", + "value": "md5", + "vartype": "enum" + }, + { + "name": "maintenance_work_mem", + "value": "65536", + "vartype": "integer" + }, + { + "name": "max_parallel_workers", + "value": "8", + "vartype": "integer" + }, + { + "name": "max_worker_processes", + "value": "8", + "vartype": "integer" + }, + { + "name": "neon.tenant_id", + "value": "b0554b632bd4d547a63b86c3630317e8", + "vartype": "string" + }, + { + "name": "max_replication_slots", + "value": "10", + "vartype": "integer" + }, + { + "name": "neon.timeline_id", + "value": "2414a61ffc94e428f14b5758fe308e13", + "vartype": "string" + }, + { + "name": "shared_preload_libraries", + "value": "neon", + "vartype": "string" + }, + { + "name": "synchronous_standby_names", + "value": "walproposer", + "vartype": "string" + }, + { + "name": "neon.pageserver_connstring", + "value": "host=127.0.0.1 port=6400", + "vartype": "string" + }, + { + "name": "test.escaping", + "value": "here's a backslash \\ and a quote ' and a double-quote \" hooray", + "vartype": "string" + } + ] + }, + "delta_operations": [ + { + "action": "delete_db", + "name": "zenith_test" + }, + { + "action": "rename_db", + "name": "DB", + "new_name": "DB2" + }, + { + "action": "delete_role", + "name": "zenith2" + }, + { + "action": "rename_role", + "name": "zenith new", + "new_name": "zenith \"new\"" + } + ], + "remote_extensions": { + "library_index": { + "postgis-3": "postgis", + "libpgrouting-3.4": "postgis", + "postgis_raster-3": "postgis", + "postgis_sfcgal-3": "postgis", + "postgis_topology-3": "postgis", + "address_standardizer-3": "postgis" + }, + "extension_data": { + "postgis": { + "archive_path": "5834329303/v15/extensions/postgis.tar.zst", + "control_data": { + "postgis.control": "# postgis extension\ncomment = ''PostGIS geometry and geography spatial types and functions''\ndefault_version = ''3.3.2''\nmodule_pathname = ''$libdir/postgis-3''\nrelocatable = false\ntrusted = true\n", + "pgrouting.control": "# pgRouting Extension\ncomment = ''pgRouting Extension''\ndefault_version = ''3.4.2''\nmodule_pathname = ''$libdir/libpgrouting-3.4''\nrelocatable = true\nrequires = ''plpgsql''\nrequires = ''postgis''\ntrusted = true\n", + "postgis_raster.control": "# postgis_raster extension\ncomment = ''PostGIS raster types and functions''\ndefault_version = ''3.3.2''\nmodule_pathname = ''$libdir/postgis_raster-3''\nrelocatable = false\nrequires = postgis\ntrusted = true\n", + "postgis_sfcgal.control": "# postgis topology extension\ncomment = ''PostGIS SFCGAL functions''\ndefault_version = ''3.3.2''\nrelocatable = true\nrequires = postgis\ntrusted = true\n", + "postgis_topology.control": "# postgis topology extension\ncomment = ''PostGIS topology spatial types and functions''\ndefault_version = ''3.3.2''\nrelocatable = false\nschema = topology\nrequires = postgis\ntrusted = true\n", + "address_standardizer.control": "# address_standardizer extension\ncomment = ''Used to parse an address into constituent elements. Generally used to support geocoding address normalization step.''\ndefault_version = ''3.3.2''\nrelocatable = true\ntrusted = true\n", + "postgis_tiger_geocoder.control": "# postgis tiger geocoder extension\ncomment = ''PostGIS tiger geocoder and reverse geocoder''\ndefault_version = ''3.3.2''\nrelocatable = false\nschema = tiger\nrequires = ''postgis,fuzzystrmatch''\nsuperuser= false\ntrusted = true\n", + "address_standardizer_data_us.control": "# address standardizer us dataset\ncomment = ''Address Standardizer US dataset example''\ndefault_version = ''3.3.2''\nrelocatable = true\ntrusted = true\n" + } + } + }, + "custom_extensions": [], + "public_extensions": ["postgis"] + }, + "pgbouncer_settings": { + "default_pool_size": "42", + "pool_mode": "session" + } +} diff --git a/test_runner/regress/test_compute_reconfigure.py b/test_runner/regress/test_compute_reconfigure.py index b533d45b1e..cc792333ba 100644 --- a/test_runner/regress/test_compute_reconfigure.py +++ b/test_runner/regress/test_compute_reconfigure.py @@ -9,6 +9,8 @@ from fixtures.utils import wait_until if TYPE_CHECKING: from fixtures.neon_fixtures import NeonEnv +from fixtures.log_helper import log + def test_compute_reconfigure(neon_simple_env: NeonEnv): """ @@ -85,3 +87,57 @@ def test_compute_reconfigure(neon_simple_env: NeonEnv): samples = metrics.query_all("compute_ctl_up", {"build_tag": build_tag}) assert len(samples) == 1 assert samples[0].value == 1 + + +def test_compute_safekeeper_connstrings_duplicate(neon_simple_env: NeonEnv): + """ + Test that we catch duplicate entries in neon.safekeepers. + """ + env = neon_simple_env + + endpoint = env.endpoints.create_start("main") + + # grab the current value of neon.safekeepers + sk_list = [] + with endpoint.cursor() as cursor: + cursor.execute("SHOW neon.safekeepers;") + row = cursor.fetchone() + assert row is not None + + log.info(f' initial neon.safekeepers: "{row}"') + + # build a safekeepers list with a duplicate + sk_list.append(row[0]) + sk_list.append(row[0]) + + safekeepers = ",".join(sk_list) + log.info(f'reconfigure neon.safekeepers: "{safekeepers}"') + + # introduce duplicate entry in neon.safekeepers, on purpose + endpoint.respec_deep( + **{ + "spec": { + "skip_pg_catalog_updates": True, + "cluster": { + "settings": [ + { + "name": "neon.safekeepers", + "vartype": "string", + "value": safekeepers, + } + ] + }, + }, + } + ) + + try: + endpoint.reconfigure() + + # Check that in logs we see that it was actually reconfigured, + # not restarted or something else. + endpoint.log_contains("INFO request{method=POST uri=/configure") + + except Exception as e: + # we except a failure here + log.info(f"RAISED: {e}" % e)