mirror of
https://github.com/GreptimeTeam/greptimedb.git
synced 2026-08-18 12:08:22 +00:00
feat: support old-stage datanode config overlays (#8647)
* feat: support old-stage datanode config overlays Signed-off-by: discord9 <discord9@163.com> * fix: derive compat overlay policy from WAL config Signed-off-by: discord9 <discord9@163.com> --------- Signed-off-by: discord9 <discord9@163.com>
This commit is contained in:
@@ -28,12 +28,42 @@ This file is intended for AI agents editing compat cases or the compat runner. F
|
||||
- All required fields must be non-empty: `name`, `reason`, `introduced_by`,
|
||||
`topologies`, `from_range`, `to_range`, `features`, `owner`.
|
||||
|
||||
## Old-Stage Datanode Overlays
|
||||
|
||||
- An old-stage datanode overlay is declared only as:
|
||||
|
||||
```toml
|
||||
[old_config]
|
||||
datanode = "old-datanode.overlay.toml"
|
||||
```
|
||||
|
||||
`datanode` is required if `[old_config]` exists. Empty tables and unknown
|
||||
keys are parse errors.
|
||||
- The sidecar path is relative to its case directory and must remain confined
|
||||
there. It contains native datanode TOML and is loaded and preflighted before
|
||||
services or state are created.
|
||||
- The runner merges tables recursively only when both values are tables.
|
||||
Scalars, type mismatches, arrays, and arrays of tables replace atomically;
|
||||
`region_engine` has no special merge behavior.
|
||||
- Do not use an overlay to set runner-owned fields: `mode`, `node_id`,
|
||||
`storage.data_home`, `meta_client_options.metasrv_addrs`, `wal.provider`, or
|
||||
`wal.dir` for Raft WAL / `wal.broker_endpoints` for Kafka WAL. The runner
|
||||
restores or deletes them according to its baseline and warns without showing
|
||||
values.
|
||||
|
||||
## Phase Semantics
|
||||
|
||||
- `setup.sql` runs on the **old (from)** binary. Only success is required;
|
||||
output is not compared to any file.
|
||||
- `verify.sql` runs on the **new (to)** binary. Output is compared against
|
||||
`verify.result`.
|
||||
- Overlays apply only to old-stage datanodes and survive old-stage setup
|
||||
restarts; the current stage uses a clean configuration.
|
||||
- The baseline profile runs first. Cases with semantically equivalent datanode
|
||||
TOML share one sequential, isolated profile, and every profile has an
|
||||
independent state and etcd lifecycle.
|
||||
- Without fail-fast, only cases with successful setup are verified. Fail-fast
|
||||
cleans up the active profile before stopping.
|
||||
|
||||
## PostgreSQL Protocol Cases
|
||||
|
||||
@@ -48,9 +78,10 @@ cargo run -p sqlness-runner -- compat --dry-run [--from-version vX.Y.Z] [--test-
|
||||
```
|
||||
|
||||
The dry-run performs full discovery and filtering (name, topology, metadata
|
||||
validation, namespace dedup, version-range matching) but starts no services,
|
||||
creates no temp dirs, and mutates no files. Use it to check which cases
|
||||
would be selected before a real run.
|
||||
validation, namespace dedup, version-range matching) and displays selected
|
||||
profiles, cases, and sidecar paths without configuration values. It starts no
|
||||
services, creates no temp dirs, and mutates no files. Use it to check which
|
||||
cases would be selected before a real run.
|
||||
|
||||
## CI Version Window
|
||||
|
||||
|
||||
@@ -68,6 +68,34 @@ namespace = "my_explicit_namespace" # defaults to sanitized directory name
|
||||
|
||||
**Required fields**: `name`, `reason`, `introduced_by`, `topologies`, `from_range`, `to_range`, `features`, `owner`.
|
||||
|
||||
### Old-Stage Datanode Configuration Overlay
|
||||
|
||||
To apply a datanode configuration overlay while running the old stage, add this
|
||||
strict optional table to `case.toml`:
|
||||
|
||||
```toml
|
||||
[old_config]
|
||||
datanode = "old-datanode.overlay.toml"
|
||||
```
|
||||
|
||||
`datanode` is required whenever `[old_config]` is present; empty tables and
|
||||
unknown keys are rejected. The reference is relative to the case directory and
|
||||
must remain confined to that directory. The sidecar is native datanode TOML,
|
||||
which the runner loads and preflights before starting services or creating
|
||||
state.
|
||||
|
||||
The runner first applies the datanode baseline, then merges the sidecar. Tables
|
||||
merge recursively only when both values are tables. Scalars, type mismatches,
|
||||
arrays, and arrays of tables replace the baseline value atomically. In
|
||||
particular, `region_engine` has no special merge behavior.
|
||||
|
||||
Runner-owned fields cannot be changed by an overlay: `mode`, `node_id`,
|
||||
`storage.data_home`, `meta_client_options.metasrv_addrs`, and `wal.provider`,
|
||||
plus `wal.dir` for Raft WAL or `wal.broker_endpoints` for Kafka WAL. The runner
|
||||
restores these fields to its baseline values, or deletes them when the baseline
|
||||
has no value. It warns about protected-field overrides without printing their
|
||||
values.
|
||||
|
||||
### Version-Range Filtering
|
||||
|
||||
`from_range` and `to_range` control which binary versions a case applies to:
|
||||
@@ -166,9 +194,19 @@ Each case runs in its own database namespace to prevent cross-case interference:
|
||||
|
||||
## Batch Behavior
|
||||
|
||||
- All cases in a run share one cluster lifecycle: start from-version cluster → run all setups → restart with to-version binary → run all verifies
|
||||
- Cases run **serially** (no parallelism in PR1). Namespace state is session/protocol state and cannot be shared concurrently.
|
||||
- The baseline (no-overlay) profile runs first. Cases whose old datanode TOML
|
||||
is semantically equivalent share one profile; profiles run serially and in
|
||||
isolation.
|
||||
- Each profile has its own state and etcd lifecycle. Its overlay is applied
|
||||
only to old-stage datanodes and remains in effect through old-stage setup
|
||||
restarts. The current stage always uses a clean configuration.
|
||||
- Cases run **serially** (no parallelism in PR1). Namespace state is
|
||||
session/protocol state and cannot be shared concurrently.
|
||||
- Same namespace across cases is rejected.
|
||||
- Without fail-fast, the runner verifies only cases whose setup succeeded.
|
||||
With fail-fast, it cleans up the active profile before stopping.
|
||||
- `--dry-run` displays the selected profiles, cases, and sidecar paths without
|
||||
printing configuration values; it starts no services.
|
||||
|
||||
## xfail Policy (Future)
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
pub(crate) mod bare;
|
||||
pub(crate) mod compat;
|
||||
pub(crate) mod compat_case;
|
||||
pub(crate) mod datanode_overlay;
|
||||
pub(crate) mod kube;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
+669
-118
@@ -12,6 +12,7 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -22,7 +23,10 @@ use sqlness::interceptor::{InterceptorRef, Registry};
|
||||
|
||||
use crate::cmd::bare::ServerAddr;
|
||||
use crate::cmd::compat_case::{self, CompatCase, try_infer_version, version_matches_range};
|
||||
use crate::env::bare::{Env, StoreConfig, WalConfig};
|
||||
use crate::cmd::datanode_overlay::{
|
||||
DatanodeOverlay, DatanodeProtectionPolicy, PreparedDatanodeOverlay,
|
||||
};
|
||||
use crate::env::bare::{Env, GreptimeDB, StoreConfig, WalConfig};
|
||||
use crate::protocol_interceptor::{self, POSTGRES, PROTOCOL_KEY};
|
||||
use crate::util;
|
||||
|
||||
@@ -326,16 +330,12 @@ impl CompatCommand {
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(expected_cases) = self.expect_cases {
|
||||
assert_eq!(
|
||||
cases.len(),
|
||||
expected_cases,
|
||||
"Expected {expected_cases} compatibility cases after all filtering, found {}",
|
||||
cases.len()
|
||||
);
|
||||
}
|
||||
|
||||
if cases.is_empty() {
|
||||
if let Some(expected_cases) = self.expect_cases {
|
||||
panic!(
|
||||
"Expected {expected_cases} compatibility cases after all filtering, found 0"
|
||||
);
|
||||
}
|
||||
if dry_run {
|
||||
println!("DRY-RUN: no compat cases would run after version-range filtering");
|
||||
} else {
|
||||
@@ -344,8 +344,25 @@ impl CompatCommand {
|
||||
return;
|
||||
}
|
||||
|
||||
let wal_config = WalConfig::RaftEngine;
|
||||
let profiles =
|
||||
prepare_compat_profiles(cases, &wal_config).unwrap_or_else(|error| panic!("{error}"));
|
||||
emit_protected_path_warnings(&profiles);
|
||||
|
||||
if let Some(expected_cases) = self.expect_cases {
|
||||
assert_eq!(
|
||||
profiles.case_count(),
|
||||
expected_cases,
|
||||
"Expected {expected_cases} compatibility cases after all filtering, found {}",
|
||||
profiles.case_count()
|
||||
);
|
||||
}
|
||||
|
||||
if dry_run {
|
||||
println!("DRY-RUN: would run {} compat case(s)", cases.len());
|
||||
println!(
|
||||
"DRY-RUN: would run {} compat case(s)",
|
||||
profiles.case_count()
|
||||
);
|
||||
println!(" topology: {}", topology.as_str());
|
||||
println!(
|
||||
" from version: {}",
|
||||
@@ -359,22 +376,30 @@ impl CompatCommand {
|
||||
.as_deref()
|
||||
.unwrap_or("unknown (use --to-bins-dir or build debug binary)")
|
||||
);
|
||||
if pre_filter_count != cases.len() {
|
||||
if pre_filter_count != profiles.case_count() {
|
||||
println!();
|
||||
println!(
|
||||
"Version-range filtering reduced {} → {} cases (see 'Skipping case' messages above)",
|
||||
pre_filter_count,
|
||||
cases.len()
|
||||
profiles.case_count()
|
||||
);
|
||||
}
|
||||
println!();
|
||||
for c in &cases {
|
||||
for c in profiles.cases() {
|
||||
println!(" case: {}", c.metadata.name);
|
||||
println!(" namespace: {}", c.namespace);
|
||||
println!(" from_range: {:?}", c.metadata.from_range);
|
||||
println!(" to_range: {:?}", c.metadata.to_range);
|
||||
println!(" features: {:?}", c.metadata.features);
|
||||
}
|
||||
println!("DRY-RUN: compatibility profiles:");
|
||||
for profile in profiles.iter() {
|
||||
println!(" profile: {}", profile.profile_id());
|
||||
println!(" cases: {}", profile.case_names().join(", "));
|
||||
for source in profile.sources() {
|
||||
println!(" sidecar: {}", source.display());
|
||||
}
|
||||
}
|
||||
println!();
|
||||
println!("Dry run complete. Remove --dry-run to execute.");
|
||||
return;
|
||||
@@ -382,133 +407,499 @@ impl CompatCommand {
|
||||
|
||||
println!(
|
||||
"Running {} compat case(s) with topology {}:",
|
||||
cases.len(),
|
||||
profiles.case_count(),
|
||||
topology.as_str()
|
||||
);
|
||||
for c in &cases {
|
||||
for c in profiles.cases() {
|
||||
println!(
|
||||
" - {} (namespace: {}, topologies: {:?})",
|
||||
c.metadata.name, c.namespace, c.metadata.topologies
|
||||
);
|
||||
}
|
||||
|
||||
// ---- 6. Create temp directory (after filtering so early exits don't leave empty dirs) ----
|
||||
let temp_dir = tempfile::Builder::new()
|
||||
.prefix("sqlness-compat")
|
||||
.tempdir()
|
||||
.unwrap();
|
||||
let sqlness_home = temp_dir.keep();
|
||||
unsafe {
|
||||
std::env::set_var(
|
||||
"SQLNESS_HOME",
|
||||
sqlness_home.join("copy").display().to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
// ---- 7. Build interceptor registry ----
|
||||
// ---- 6. Build interceptor registry ----
|
||||
let interceptor_registry = create_interceptor_registry();
|
||||
|
||||
// ---- 7b. Create Env for the selected topology ----
|
||||
let setup_etcd = topology == CompatTopology::Distributed && self.setup_etcd;
|
||||
let store_config = StoreConfig {
|
||||
store_addrs: if setup_etcd {
|
||||
vec!["127.0.0.1:2379".to_string()]
|
||||
} else {
|
||||
vec![]
|
||||
},
|
||||
setup_etcd,
|
||||
setup_pg: None,
|
||||
setup_mysql: None,
|
||||
enable_flat_format: false,
|
||||
enable_gc: false,
|
||||
};
|
||||
|
||||
let env = Env::new(
|
||||
sqlness_home.clone(),
|
||||
ServerAddr::default(),
|
||||
WalConfig::RaftEngine,
|
||||
self.pull_version_on_need,
|
||||
let to_bins_dir = to_bins_dir.expect("to_bins_dir must be resolved in non-dry-run mode");
|
||||
let profile_config = ProfileRunConfig {
|
||||
interceptor_registry: &interceptor_registry,
|
||||
from_bins_dir,
|
||||
store_config,
|
||||
vec![],
|
||||
);
|
||||
|
||||
// ---- 7c. Etcd cleanup guard ----
|
||||
// Arm this only immediately before starting the cluster. Earlier validation
|
||||
// failures should not stop an unrelated local container named `etcd`.
|
||||
let mut etcd_guard = if setup_etcd {
|
||||
Some(EtcdGuard::new())
|
||||
} else {
|
||||
None
|
||||
to_bins_dir,
|
||||
pull_version_on_need: self.pull_version_on_need,
|
||||
setup_etcd: self.setup_etcd,
|
||||
fail_fast: self.fail_fast,
|
||||
topology,
|
||||
preserve_state: self.preserve_state,
|
||||
wal_config,
|
||||
};
|
||||
|
||||
// ---- 8. Run setup phase on the from-version cluster ----
|
||||
println!("Starting from-version {} cluster...", topology.as_str());
|
||||
let mut db = match topology {
|
||||
CompatTopology::Distributed => env.compat_start_distributed(0).await,
|
||||
CompatTopology::Standalone => env.compat_start_standalone(0).await,
|
||||
};
|
||||
|
||||
println!("Running setup phase...");
|
||||
for case in &cases {
|
||||
run_compat_phase(&db, case, &interceptor_registry, CompatPhase::Setup)
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("Setup failed for case '{}': {e}", case.metadata.name));
|
||||
println!(" Setup: {} - OK", case.metadata.name);
|
||||
let mut failures = Vec::new();
|
||||
let mut preserved_states = Vec::new();
|
||||
for profile in profiles.iter() {
|
||||
let outcome = run_profile(profile, &profile_config).await;
|
||||
failures.extend(outcome.failures);
|
||||
if let Some(state) = outcome.preserved_state {
|
||||
preserved_states.push(state);
|
||||
}
|
||||
if outcome.stop_remaining_profiles {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 9. Switch to the to-version binary and restart cluster ----
|
||||
// to_bins_dir was already resolved during version-range filtering
|
||||
println!("Restarting cluster with to-version binary on preserved state...");
|
||||
let to_bins_dir = to_bins_dir.expect("to_bins_dir must be resolved in non-dry-run mode");
|
||||
env.compat_restart(&db, to_bins_dir).await;
|
||||
for state in &preserved_states {
|
||||
println!("Preserved compat profile state: {}", state.display());
|
||||
}
|
||||
|
||||
if failures.is_empty() {
|
||||
println!("\n\x1b[32mAll compat tests passed!\x1b[0m");
|
||||
} else {
|
||||
panic!("\n\x1b[31mFailed cases: {}\x1b[0m", failures.join(", "));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cases sharing one old-stage datanode configuration lifecycle.
|
||||
#[derive(Debug)]
|
||||
enum CompatProfile {
|
||||
Baseline {
|
||||
cases: Vec<CompatCase>,
|
||||
},
|
||||
Overlay {
|
||||
overlay: Arc<PreparedDatanodeOverlay>,
|
||||
cases: Vec<CompatCase>,
|
||||
sources: Vec<PathBuf>,
|
||||
},
|
||||
}
|
||||
|
||||
impl CompatProfile {
|
||||
fn profile_id(&self) -> &str {
|
||||
match self {
|
||||
Self::Baseline { .. } => "baseline",
|
||||
Self::Overlay { overlay, .. } => overlay.profile_id(),
|
||||
}
|
||||
}
|
||||
|
||||
fn cases(&self) -> &[CompatCase] {
|
||||
match self {
|
||||
Self::Baseline { cases } | Self::Overlay { cases, .. } => cases,
|
||||
}
|
||||
}
|
||||
|
||||
fn case_names(&self) -> Vec<&str> {
|
||||
self.cases()
|
||||
.iter()
|
||||
.map(|case| case.metadata.name.as_str())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn sources(&self) -> &[PathBuf] {
|
||||
match self {
|
||||
Self::Baseline { .. } => &[],
|
||||
Self::Overlay { sources, .. } => sources,
|
||||
}
|
||||
}
|
||||
|
||||
fn overlay(&self) -> Option<Arc<PreparedDatanodeOverlay>> {
|
||||
match self {
|
||||
Self::Baseline { .. } => None,
|
||||
Self::Overlay { overlay, .. } => Some(Arc::clone(overlay)),
|
||||
}
|
||||
}
|
||||
|
||||
fn touched_protected_paths(&self) -> &[crate::cmd::datanode_overlay::DottedPath] {
|
||||
match self {
|
||||
Self::Baseline { .. } => &[],
|
||||
Self::Overlay { overlay, .. } => overlay.touched_protected_paths(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Deterministically ordered compatibility profiles.
|
||||
#[derive(Debug)]
|
||||
struct CompatProfiles(Vec<CompatProfile>);
|
||||
|
||||
impl CompatProfiles {
|
||||
fn iter(&self) -> impl Iterator<Item = &CompatProfile> {
|
||||
self.0.iter()
|
||||
}
|
||||
|
||||
fn cases(&self) -> impl Iterator<Item = &CompatCase> {
|
||||
self.0.iter().flat_map(CompatProfile::cases)
|
||||
}
|
||||
|
||||
fn case_count(&self) -> usize {
|
||||
self.0.iter().map(|profile| profile.cases().len()).sum()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct OverlayProfileGroup {
|
||||
overlay: Arc<PreparedDatanodeOverlay>,
|
||||
cases: Vec<CompatCase>,
|
||||
sources: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
fn prepare_compat_profiles(
|
||||
cases: Vec<CompatCase>,
|
||||
wal_config: &WalConfig,
|
||||
) -> Result<CompatProfiles, String> {
|
||||
let protection = DatanodeProtectionPolicy::for_wal(wal_config);
|
||||
let mut baseline_cases = Vec::new();
|
||||
let mut overlays: BTreeMap<[u8; 32], OverlayProfileGroup> = BTreeMap::new();
|
||||
|
||||
for case in cases {
|
||||
let Some(reference) = case.metadata.old_datanode_overlay() else {
|
||||
baseline_cases.push(case);
|
||||
continue;
|
||||
};
|
||||
let overlay = DatanodeOverlay::load(&case.dir, reference).map_err(|error| {
|
||||
format!(
|
||||
"Failed to load old datanode overlay for compatibility case '{}': {error}",
|
||||
case.metadata.name
|
||||
)
|
||||
})?;
|
||||
let prepared = Arc::new(overlay.prepare(&protection).map_err(|error| {
|
||||
format!(
|
||||
"Failed to prepare old datanode overlay for compatibility case '{}': {error}",
|
||||
case.metadata.name
|
||||
)
|
||||
})?);
|
||||
let key = *prepared.profile_key();
|
||||
let source = prepared.source().to_path_buf();
|
||||
let entry = overlays.entry(key).or_insert_with(|| OverlayProfileGroup {
|
||||
overlay: Arc::clone(&prepared),
|
||||
cases: Vec::new(),
|
||||
sources: Vec::new(),
|
||||
});
|
||||
entry.cases.push(case);
|
||||
entry.sources.push(source);
|
||||
}
|
||||
|
||||
baseline_cases.sort_by(|left, right| left.metadata.name.cmp(&right.metadata.name));
|
||||
let mut profiles = Vec::new();
|
||||
if !baseline_cases.is_empty() {
|
||||
profiles.push(CompatProfile::Baseline {
|
||||
cases: baseline_cases,
|
||||
});
|
||||
}
|
||||
for (
|
||||
_,
|
||||
OverlayProfileGroup {
|
||||
overlay,
|
||||
mut cases,
|
||||
mut sources,
|
||||
},
|
||||
) in overlays
|
||||
{
|
||||
cases.sort_by(|left, right| left.metadata.name.cmp(&right.metadata.name));
|
||||
sources.sort();
|
||||
sources.dedup();
|
||||
profiles.push(CompatProfile::Overlay {
|
||||
overlay,
|
||||
cases,
|
||||
sources,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(CompatProfiles(profiles))
|
||||
}
|
||||
|
||||
fn emit_protected_path_warnings(profiles: &CompatProfiles) {
|
||||
for profile in profiles.iter() {
|
||||
let paths: Vec<_> = profile
|
||||
.touched_protected_paths()
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.collect();
|
||||
if paths.is_empty() {
|
||||
continue;
|
||||
}
|
||||
println!(
|
||||
"{}",
|
||||
format_protected_path_warning(profile.profile_id(), profile.case_names(), paths)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn format_protected_path_warning(
|
||||
profile_id: &str,
|
||||
mut case_names: Vec<&str>,
|
||||
mut paths: Vec<String>,
|
||||
) -> String {
|
||||
case_names.sort_unstable();
|
||||
paths.sort_unstable();
|
||||
format!(
|
||||
"Warning: datanode overlay profile {profile_id} touches runner-owned paths [{}] for cases [{}]",
|
||||
paths.join(", "),
|
||||
case_names.join(", ")
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ProfileOutcome {
|
||||
failures: Vec<String>,
|
||||
stop_remaining_profiles: bool,
|
||||
preserved_state: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// Pure compatibility profile progress policy, independent of process ownership.
|
||||
#[derive(Default)]
|
||||
struct ProfileProgress {
|
||||
successful_setup_indexes: Vec<usize>,
|
||||
failures: Vec<String>,
|
||||
fail_fast_setup_failure: bool,
|
||||
fail_fast_verify_failure: bool,
|
||||
cleanup_failed: bool,
|
||||
}
|
||||
|
||||
impl ProfileProgress {
|
||||
fn record_setup_success(&mut self, case_index: usize) {
|
||||
self.successful_setup_indexes.push(case_index);
|
||||
}
|
||||
|
||||
fn record_setup_failure(&mut self, failure: String, fail_fast: bool) -> bool {
|
||||
self.failures.push(failure);
|
||||
self.fail_fast_setup_failure = fail_fast;
|
||||
fail_fast
|
||||
}
|
||||
|
||||
fn record_verify_failure(&mut self, failure: String, fail_fast: bool) -> bool {
|
||||
self.failures.push(failure);
|
||||
self.fail_fast_verify_failure = fail_fast;
|
||||
fail_fast
|
||||
}
|
||||
|
||||
fn record_cleanup_failure(&mut self, failure: String) {
|
||||
self.failures.push(failure);
|
||||
self.cleanup_failed = true;
|
||||
}
|
||||
|
||||
fn should_transition_to_current(&self) -> bool {
|
||||
!self.successful_setup_indexes.is_empty() && !self.fail_fast_setup_failure
|
||||
}
|
||||
|
||||
fn should_stop_remaining_profiles(&self) -> bool {
|
||||
self.fail_fast_setup_failure || self.fail_fast_verify_failure || self.cleanup_failed
|
||||
}
|
||||
}
|
||||
|
||||
struct ProfileRunConfig<'a> {
|
||||
interceptor_registry: &'a Registry,
|
||||
from_bins_dir: Option<PathBuf>,
|
||||
to_bins_dir: PathBuf,
|
||||
pull_version_on_need: bool,
|
||||
setup_etcd: bool,
|
||||
fail_fast: bool,
|
||||
topology: CompatTopology,
|
||||
preserve_state: bool,
|
||||
wal_config: WalConfig,
|
||||
}
|
||||
|
||||
async fn run_profile(profile: &CompatProfile, config: &ProfileRunConfig<'_>) -> ProfileOutcome {
|
||||
println!("Running compatibility profile {}", profile.profile_id());
|
||||
let temp_dir = tempfile::Builder::new()
|
||||
.prefix(&format!("sqlness-compat-{}-", profile.profile_id()))
|
||||
.tempdir()
|
||||
.unwrap();
|
||||
let profile_state = ProfileStateGuard::new(temp_dir, config.preserve_state);
|
||||
let sqlness_home = profile_state.path().to_path_buf();
|
||||
// Standalone runs need no etcd.
|
||||
let setup_etcd = config.topology == CompatTopology::Distributed && config.setup_etcd;
|
||||
unsafe {
|
||||
std::env::set_var(
|
||||
"SQLNESS_HOME",
|
||||
sqlness_home.join("copy").display().to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let store_config = StoreConfig {
|
||||
store_addrs: setup_etcd
|
||||
.then(|| "127.0.0.1:2379".to_string())
|
||||
.into_iter()
|
||||
.collect(),
|
||||
setup_etcd,
|
||||
setup_pg: None,
|
||||
setup_mysql: None,
|
||||
enable_flat_format: false,
|
||||
enable_gc: false,
|
||||
};
|
||||
let env = Env::new(
|
||||
sqlness_home.clone(),
|
||||
ServerAddr::default(),
|
||||
config.wal_config.clone(),
|
||||
config.pull_version_on_need,
|
||||
config.from_bins_dir.clone(),
|
||||
store_config,
|
||||
vec![],
|
||||
);
|
||||
if let Some(overlay) = profile.overlay() {
|
||||
env.activate_compat_old(overlay);
|
||||
}
|
||||
|
||||
// Arm immediately before starting the profile so earlier preflight failures
|
||||
// cannot remove a developer-owned etcd container.
|
||||
let etcd_guard = setup_etcd.then(EtcdGuard::new);
|
||||
println!(
|
||||
"Starting old-version {} cluster...",
|
||||
config.topology.as_str()
|
||||
);
|
||||
let db = match config.topology {
|
||||
CompatTopology::Distributed => env.compat_start_distributed(0).await,
|
||||
CompatTopology::Standalone => env.compat_start_standalone(0).await,
|
||||
};
|
||||
|
||||
let mut progress = ProfileProgress::default();
|
||||
for (case_index, case) in profile.cases().iter().enumerate() {
|
||||
match run_compat_phase(&db, case, config.interceptor_registry, CompatPhase::Setup).await {
|
||||
Ok(()) => {
|
||||
println!(" Setup: {} - OK", case.metadata.name);
|
||||
progress.record_setup_success(case_index);
|
||||
}
|
||||
Err(error) => {
|
||||
println!(" Setup: {} - FAILED: {error}", case.metadata.name);
|
||||
if progress.record_setup_failure(
|
||||
format!("{} (setup): {error}", case.metadata.name),
|
||||
config.fail_fast,
|
||||
) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if progress.should_transition_to_current() {
|
||||
println!("Restarting cluster with new-version binary on preserved state...");
|
||||
env.activate_compat_current();
|
||||
env.compat_restart(&db, config.to_bins_dir.clone()).await;
|
||||
|
||||
// ---- 10. Run verify phase on the to-version cluster ----
|
||||
println!("Running verify phase...");
|
||||
let mut failed = Vec::new();
|
||||
for case in &cases {
|
||||
match run_compat_phase(&db, case, &interceptor_registry, CompatPhase::Verify).await {
|
||||
for case_index in progress.successful_setup_indexes.clone() {
|
||||
let case = &profile.cases()[case_index];
|
||||
match run_compat_phase(&db, case, config.interceptor_registry, CompatPhase::Verify)
|
||||
.await
|
||||
{
|
||||
Ok(()) => println!(" Verify: {} - PASSED", case.metadata.name),
|
||||
Err(e) => {
|
||||
println!(" Verify: {} - FAILED: {e}", case.metadata.name);
|
||||
failed.push(case.metadata.name.clone());
|
||||
if self.fail_fast {
|
||||
Err(error) => {
|
||||
println!(" Verify: {} - FAILED: {error}", case.metadata.name);
|
||||
if progress.record_verify_failure(
|
||||
format!("{} (verify): {error}", case.metadata.name),
|
||||
config.fail_fast,
|
||||
) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if progress.successful_setup_indexes.is_empty() {
|
||||
println!("Skipping current-version restart: no setup cases succeeded");
|
||||
}
|
||||
|
||||
// ---- 11. Stop cluster ----
|
||||
db.compat_stop();
|
||||
let cleanup = cleanup_profile(db, etcd_guard, setup_etcd, profile_state).await;
|
||||
for failure in cleanup.failures {
|
||||
progress.record_cleanup_failure(failure);
|
||||
}
|
||||
let stop_remaining_profiles = progress.should_stop_remaining_profiles();
|
||||
ProfileOutcome {
|
||||
failures: progress.failures,
|
||||
stop_remaining_profiles,
|
||||
preserved_state: cleanup.preserved_state,
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 12. Cleanup ----
|
||||
// Etcd is always cleaned up; --preserve-state only preserves sqlness_home.
|
||||
if setup_etcd {
|
||||
println!("Stopping etcd");
|
||||
util::stop_rm_etcd();
|
||||
struct CleanupOutcome {
|
||||
failures: Vec<String>,
|
||||
preserved_state: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// Owns one profile state directory until normal cleanup or unwind finalization.
|
||||
struct ProfileStateGuard {
|
||||
temp_dir: Option<tempfile::TempDir>,
|
||||
preserve_state: bool,
|
||||
}
|
||||
|
||||
impl ProfileStateGuard {
|
||||
fn new(temp_dir: tempfile::TempDir, preserve_state: bool) -> Self {
|
||||
Self {
|
||||
temp_dir: Some(temp_dir),
|
||||
preserve_state,
|
||||
}
|
||||
}
|
||||
|
||||
fn path(&self) -> &std::path::Path {
|
||||
self.temp_dir.as_ref().unwrap().path()
|
||||
}
|
||||
|
||||
fn finalize(mut self) -> CleanupOutcome {
|
||||
let temp_dir = self.temp_dir.take().unwrap();
|
||||
let mut failures = Vec::new();
|
||||
if self.preserve_state {
|
||||
let path = temp_dir.keep();
|
||||
if let Err(error) = std::fs::metadata(&path) {
|
||||
failures.push(format!(
|
||||
"failed to confirm preserved profile state {}: {error}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
return CleanupOutcome {
|
||||
failures,
|
||||
preserved_state: Some(path),
|
||||
};
|
||||
}
|
||||
|
||||
if !self.preserve_state {
|
||||
println!("Removing state in {:?}", sqlness_home);
|
||||
tokio::fs::remove_dir_all(sqlness_home)
|
||||
.await
|
||||
.unwrap_or_else(|e| println!("Warning: failed to clean up temp dir: {e}"));
|
||||
println!("Removing state in {}", temp_dir.path().display());
|
||||
if let Err(error) = temp_dir.close() {
|
||||
failures.push(format!("failed to remove profile state: {error}"));
|
||||
}
|
||||
CleanupOutcome {
|
||||
failures,
|
||||
preserved_state: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Disarm the etcd guard now that we've done normal cleanup.
|
||||
if let Some(mut guard) = etcd_guard.take() {
|
||||
guard.disarm();
|
||||
impl Drop for ProfileStateGuard {
|
||||
fn drop(&mut self) {
|
||||
let Some(temp_dir) = self.temp_dir.take() else {
|
||||
return;
|
||||
};
|
||||
if self.preserve_state {
|
||||
let path = temp_dir.keep();
|
||||
println!(
|
||||
"Preserved compat profile state after abnormal exit: {}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if failed.is_empty() {
|
||||
println!("\n\x1b[32mAll compat tests passed!\x1b[0m");
|
||||
} else {
|
||||
println!("\n\x1b[31mFailed cases: {}\x1b[0m", failed.join(", "));
|
||||
// Explicitly drop the guard before exit so it doesn't double-cleanup.
|
||||
std::process::exit(1);
|
||||
async fn cleanup_profile(
|
||||
mut db: GreptimeDB,
|
||||
mut etcd_guard: Option<EtcdGuard>,
|
||||
setup_etcd: bool,
|
||||
profile_state: ProfileStateGuard,
|
||||
) -> CleanupOutcome {
|
||||
db.compat_stop();
|
||||
drop(db);
|
||||
let mut failures = Vec::new();
|
||||
if setup_etcd {
|
||||
println!("Stopping etcd");
|
||||
match util::stop_rm_etcd_checked() {
|
||||
Ok(()) => {
|
||||
if let Some(guard) = etcd_guard.as_mut() {
|
||||
guard.disarm();
|
||||
}
|
||||
}
|
||||
Err(error) => failures.push(format!("profile etcd cleanup: {error}")),
|
||||
}
|
||||
}
|
||||
// On failed checked cleanup this Drop performs one best-effort retry before
|
||||
// profile state is finalized.
|
||||
drop(etcd_guard);
|
||||
|
||||
let mut state_outcome = profile_state.finalize();
|
||||
failures.append(&mut state_outcome.failures);
|
||||
CleanupOutcome {
|
||||
failures,
|
||||
preserved_state: state_outcome.preserved_state,
|
||||
}
|
||||
}
|
||||
|
||||
/// Guard that stops/removes Docker etcd on drop (panic or early exit).
|
||||
@@ -546,13 +937,9 @@ impl Drop for EtcdGuard {
|
||||
fn drop(&mut self) {
|
||||
if self.active {
|
||||
println!("EtcdGuard: emergency etcd cleanup (panic or early exit)");
|
||||
// Best-effort: don't panic in Drop
|
||||
let _ = std::process::Command::new("docker")
|
||||
.args(["container", "stop", "etcd"])
|
||||
.status();
|
||||
let _ = std::process::Command::new("docker")
|
||||
.args(["container", "rm", "etcd"])
|
||||
.status();
|
||||
if let Err(error) = util::stop_rm_etcd_checked() {
|
||||
println!("EtcdGuard: emergency etcd cleanup failed: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -942,7 +1329,36 @@ fn simple_diff(expected: &str, actual: &str) -> String {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::trim_trailing_blank_lines;
|
||||
use std::path::Path;
|
||||
|
||||
use super::*;
|
||||
use crate::cmd::compat_case::{CaseMetadata, OldConfigMetadata};
|
||||
|
||||
fn test_case(temp_dir: &Path, name: &str, overlay: Option<&str>) -> CompatCase {
|
||||
let case_dir = temp_dir.join(name);
|
||||
std::fs::create_dir_all(&case_dir).unwrap();
|
||||
if let Some(overlay) = overlay {
|
||||
std::fs::write(case_dir.join("overlay.toml"), overlay).unwrap();
|
||||
}
|
||||
CompatCase {
|
||||
metadata: CaseMetadata {
|
||||
name: name.to_string(),
|
||||
reason: "test".to_string(),
|
||||
introduced_by: "test".to_string(),
|
||||
topologies: vec![CompatTopology::Distributed.as_str().to_string()],
|
||||
from_range: vec!["*".to_string()],
|
||||
to_range: vec!["*".to_string()],
|
||||
features: vec!["table".to_string()],
|
||||
owner: "test".to_string(),
|
||||
namespace: None,
|
||||
old_config: overlay.map(|_| OldConfigMetadata {
|
||||
datanode: PathBuf::from("overlay.toml"),
|
||||
}),
|
||||
},
|
||||
dir: case_dir,
|
||||
namespace: name.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trim_trailing_blank_lines_preserves_single_final_newline() {
|
||||
@@ -951,4 +1367,139 @@ mod tests {
|
||||
|
||||
assert_eq!(output, "SELECT 1;\n\n+---+\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profiles_are_baseline_first_and_group_by_full_semantic_digest() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let profiles = prepare_compat_profiles(
|
||||
vec![
|
||||
test_case(temp_dir.path(), "overlay_b", Some("[x]\nb = 2\na = 1\n")),
|
||||
test_case(temp_dir.path(), "baseline", None),
|
||||
test_case(temp_dir.path(), "overlay_a", Some("[x]\na = 1\nb = 2\n")),
|
||||
test_case(temp_dir.path(), "overlay_c", Some("value = 3\n")),
|
||||
],
|
||||
&WalConfig::RaftEngine,
|
||||
)
|
||||
.unwrap();
|
||||
let profiles: Vec<_> = profiles.iter().collect();
|
||||
|
||||
assert!(matches!(profiles[0], CompatProfile::Baseline { .. }));
|
||||
assert_eq!(profiles[0].case_names(), ["baseline"]);
|
||||
assert_eq!(profiles[1].case_names(), ["overlay_a", "overlay_b"]);
|
||||
assert_eq!(profiles[1].sources().len(), 2);
|
||||
let overlay_keys: Vec<_> = profiles[1..]
|
||||
.iter()
|
||||
.map(|profile| match profile {
|
||||
CompatProfile::Overlay { overlay, .. } => *overlay.profile_key(),
|
||||
CompatProfile::Baseline { .. } => unreachable!(),
|
||||
})
|
||||
.collect();
|
||||
assert!(overlay_keys.windows(2).all(|keys| keys[0] < keys[1]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_fail_fast_setup_verifies_only_successful_cases() {
|
||||
let mut progress = ProfileProgress::default();
|
||||
progress.record_setup_success(0);
|
||||
assert!(!progress.record_setup_failure("case_b (setup): failed".to_string(), false));
|
||||
progress.record_setup_success(2);
|
||||
|
||||
assert!(progress.should_transition_to_current());
|
||||
assert_eq!(progress.successful_setup_indexes, [0, 2]);
|
||||
assert!(!progress.should_stop_remaining_profiles());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fail_fast_setup_blocks_current_and_later_profiles() {
|
||||
let mut progress = ProfileProgress::default();
|
||||
progress.record_setup_success(0);
|
||||
|
||||
assert!(progress.record_setup_failure("case_b (setup): failed".to_string(), true));
|
||||
assert!(!progress.should_transition_to_current());
|
||||
assert!(progress.should_stop_remaining_profiles());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_successful_setups_skips_current() {
|
||||
let mut progress = ProfileProgress::default();
|
||||
progress.record_setup_failure("case_a (setup): failed".to_string(), false);
|
||||
|
||||
assert!(!progress.should_transition_to_current());
|
||||
assert!(!progress.should_stop_remaining_profiles());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_fail_fast_stops_later_profiles_after_cleanup() {
|
||||
let mut progress = ProfileProgress::default();
|
||||
progress.record_setup_success(0);
|
||||
|
||||
assert!(progress.record_verify_failure("case_a (verify): failed".to_string(), true));
|
||||
assert!(progress.should_stop_remaining_profiles());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_fail_fast_aggregates_failures_but_cleanup_failure_stops_profiles() {
|
||||
let mut progress = ProfileProgress::default();
|
||||
progress.record_setup_failure("case_a (setup): failed".to_string(), false);
|
||||
progress.record_setup_success(1);
|
||||
progress.record_verify_failure("case_b (verify): failed".to_string(), false);
|
||||
|
||||
assert!(!progress.should_stop_remaining_profiles());
|
||||
progress.record_cleanup_failure("failed to remove profile state".to_string());
|
||||
assert_eq!(progress.failures.len(), 3);
|
||||
assert!(progress.should_stop_remaining_profiles());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protected_path_warning_is_sorted_and_value_free() {
|
||||
let warning = format_protected_path_warning(
|
||||
"abcdef123456",
|
||||
vec!["case_z", "case_a"],
|
||||
vec!["wal.provider".to_string(), "mode".to_string()],
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
warning,
|
||||
"Warning: datanode overlay profile abcdef123456 touches runner-owned paths [mode, wal.provider] for cases [case_a, case_z]"
|
||||
);
|
||||
assert!(!warning.contains("secret-overlay-value"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_state_guard_preserves_state_on_forced_unwind() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let path = temp_dir.path().to_path_buf();
|
||||
|
||||
let unwind = std::panic::catch_unwind(|| {
|
||||
let _state = ProfileStateGuard::new(temp_dir, true);
|
||||
panic!("forced profile unwind");
|
||||
});
|
||||
|
||||
assert!(unwind.is_err());
|
||||
assert!(path.is_dir());
|
||||
std::fs::remove_dir_all(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_state_guard_removes_state_on_drop_without_preservation() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let path = temp_dir.path().to_path_buf();
|
||||
|
||||
drop(ProfileStateGuard::new(temp_dir, false));
|
||||
|
||||
assert!(!path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_state_guard_normal_finalization_transfers_ownership_once() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let path = temp_dir.path().to_path_buf();
|
||||
|
||||
let cleanup = ProfileStateGuard::new(temp_dir, true).finalize();
|
||||
|
||||
assert!(cleanup.failures.is_empty());
|
||||
assert_eq!(cleanup.preserved_state.as_deref(), Some(path.as_path()));
|
||||
assert!(path.is_dir());
|
||||
std::fs::remove_dir_all(path).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,17 @@ pub struct CaseMetadata {
|
||||
/// Must match `[a-z0-9_]+`.
|
||||
#[serde(default)]
|
||||
pub namespace: Option<String>,
|
||||
/// Optional old-stage server configuration sidecars.
|
||||
#[serde(default)]
|
||||
pub old_config: Option<OldConfigMetadata>,
|
||||
}
|
||||
|
||||
/// Optional configuration sidecars for the old compatibility stage.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct OldConfigMetadata {
|
||||
/// Datanode TOML sidecar, resolved relative to the compatibility case directory.
|
||||
pub datanode: PathBuf,
|
||||
}
|
||||
|
||||
impl CaseMetadata {
|
||||
@@ -53,6 +64,13 @@ impl CaseMetadata {
|
||||
.clone()
|
||||
.unwrap_or_else(|| sanitize_namespace(case_dir_name))
|
||||
}
|
||||
|
||||
/// Returns the old-stage datanode sidecar reference, if configured.
|
||||
pub fn old_datanode_overlay(&self) -> Option<&Path> {
|
||||
self.old_config
|
||||
.as_ref()
|
||||
.map(|config| config.datanode.as_path())
|
||||
}
|
||||
}
|
||||
|
||||
/// A loaded compatibility case (metadata + file paths).
|
||||
@@ -469,6 +487,74 @@ mod tests {
|
||||
assert_eq!(sanitize_namespace("a.b-c"), "a_b_c");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_case_metadata_parses_old_datanode_overlay() {
|
||||
let metadata: CaseMetadata = toml::from_str(
|
||||
r#"
|
||||
name = "case"
|
||||
reason = "reason"
|
||||
introduced_by = "test"
|
||||
topologies = ["distributed"]
|
||||
from_range = ["*"]
|
||||
to_range = ["*"]
|
||||
features = ["table"]
|
||||
owner = "team"
|
||||
|
||||
[old_config]
|
||||
datanode = "old-datanode.overlay.toml"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
metadata.old_datanode_overlay(),
|
||||
Some(Path::new("old-datanode.overlay.toml"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_case_metadata_rejects_empty_old_config() {
|
||||
let error = toml::from_str::<CaseMetadata>(
|
||||
r#"
|
||||
name = "case"
|
||||
reason = "reason"
|
||||
introduced_by = "test"
|
||||
topologies = ["distributed"]
|
||||
from_range = ["*"]
|
||||
to_range = ["*"]
|
||||
features = ["table"]
|
||||
owner = "team"
|
||||
|
||||
[old_config]
|
||||
"#,
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(error.to_string().contains("missing field `datanode`"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_case_metadata_rejects_unknown_old_config_fields() {
|
||||
let error = toml::from_str::<CaseMetadata>(
|
||||
r#"
|
||||
name = "case"
|
||||
reason = "reason"
|
||||
introduced_by = "test"
|
||||
topologies = ["distributed"]
|
||||
from_range = ["*"]
|
||||
to_range = ["*"]
|
||||
features = ["table"]
|
||||
owner = "team"
|
||||
|
||||
[old_config]
|
||||
unsupported = "value"
|
||||
"#,
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(error.to_string().contains("unknown field `unsupported`"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_cases_metadata_rejects_empty_required_vectors() {
|
||||
let case = CompatCase {
|
||||
@@ -482,6 +568,7 @@ mod tests {
|
||||
features: vec!["table".to_string()],
|
||||
owner: "team".to_string(),
|
||||
namespace: None,
|
||||
old_config: None,
|
||||
},
|
||||
dir: PathBuf::from("case"),
|
||||
namespace: "case".to_string(),
|
||||
@@ -503,6 +590,7 @@ mod tests {
|
||||
features: vec!["table".to_string()],
|
||||
owner: "test".to_string(),
|
||||
namespace: None,
|
||||
old_config: None,
|
||||
},
|
||||
dir: PathBuf::from("bad_constraint"),
|
||||
namespace: "bad_constraint".to_string(),
|
||||
@@ -524,6 +612,7 @@ mod tests {
|
||||
features: vec!["table".to_string()],
|
||||
owner: "test".to_string(),
|
||||
namespace: None,
|
||||
old_config: None,
|
||||
},
|
||||
dir: PathBuf::from("case_a"),
|
||||
namespace: "shared_name".to_string(),
|
||||
@@ -539,6 +628,7 @@ mod tests {
|
||||
features: vec!["table".to_string()],
|
||||
owner: "test".to_string(),
|
||||
namespace: None,
|
||||
old_config: None,
|
||||
},
|
||||
dir: PathBuf::from("case_b"),
|
||||
namespace: "shared_name".to_string(),
|
||||
@@ -563,6 +653,7 @@ mod tests {
|
||||
features: vec!["table".to_string()],
|
||||
owner: "test".to_string(),
|
||||
namespace: Some("shared_name".to_string()),
|
||||
old_config: None,
|
||||
},
|
||||
dir: PathBuf::from("case_a"),
|
||||
namespace: "shared_name".to_string(),
|
||||
@@ -578,6 +669,7 @@ mod tests {
|
||||
features: vec!["table".to_string()],
|
||||
owner: "test".to_string(),
|
||||
namespace: Some("shared_name".to_string()),
|
||||
old_config: None,
|
||||
},
|
||||
dir: PathBuf::from("case_b"),
|
||||
namespace: "shared_name".to_string(),
|
||||
@@ -599,6 +691,7 @@ mod tests {
|
||||
features: vec!["table".to_string()],
|
||||
owner: "team".to_string(),
|
||||
namespace: None,
|
||||
old_config: None,
|
||||
},
|
||||
dir: PathBuf::from("case"),
|
||||
namespace: "case".to_string(),
|
||||
|
||||
@@ -0,0 +1,637 @@
|
||||
// Copyright 2023 Greptime Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::Read;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
use toml::value::Table;
|
||||
|
||||
use crate::env::bare::WalConfig;
|
||||
/// A dotted TOML path owned by the compatibility runner.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub(crate) struct DottedPath(Vec<String>);
|
||||
|
||||
impl DottedPath {
|
||||
fn new(parts: &[&str]) -> Self {
|
||||
Self(parts.iter().map(|part| (*part).to_string()).collect())
|
||||
}
|
||||
|
||||
fn parts(&self) -> impl Iterator<Item = &str> {
|
||||
self.0.iter().map(String::as_str)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for DottedPath {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.0.join("."))
|
||||
}
|
||||
}
|
||||
|
||||
/// Runner-owned datanode configuration paths that an overlay cannot override.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct DatanodeProtectionPolicy {
|
||||
protected_paths: Vec<DottedPath>,
|
||||
}
|
||||
|
||||
impl DatanodeProtectionPolicy {
|
||||
/// Builds the policy for the runner-selected WAL configuration.
|
||||
pub(crate) fn for_wal(wal: &WalConfig) -> Self {
|
||||
let mut protected_paths = vec![
|
||||
DottedPath::new(&["mode"]),
|
||||
DottedPath::new(&["node_id"]),
|
||||
DottedPath::new(&["storage", "data_home"]),
|
||||
DottedPath::new(&["meta_client_options", "metasrv_addrs"]),
|
||||
DottedPath::new(&["wal", "provider"]),
|
||||
];
|
||||
protected_paths.push(match wal {
|
||||
WalConfig::RaftEngine => DottedPath::new(&["wal", "dir"]),
|
||||
WalConfig::Kafka { .. } => DottedPath::new(&["wal", "broker_endpoints"]),
|
||||
});
|
||||
protected_paths.sort();
|
||||
|
||||
Self { protected_paths }
|
||||
}
|
||||
}
|
||||
|
||||
/// A parsed old-stage datanode sidecar, loaded from a confined case directory.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct DatanodeOverlay {
|
||||
source: PathBuf,
|
||||
value: toml::Value,
|
||||
profile_key: [u8; 32],
|
||||
profile_id: String,
|
||||
}
|
||||
|
||||
impl DatanodeOverlay {
|
||||
/// Loads and parses a datanode sidecar referenced relative to `case_dir`.
|
||||
///
|
||||
/// This is intended to prevent accidental case-directory escapes; it is not
|
||||
/// an adversarial concurrent-filesystem security boundary.
|
||||
pub(crate) fn load(case_dir: &Path, relative_ref: &Path) -> Result<Self, String> {
|
||||
validate_relative_reference(relative_ref)?;
|
||||
|
||||
let canonical_case_dir = case_dir.canonicalize().map_err(|error| {
|
||||
format!(
|
||||
"Failed to canonicalize compatibility case directory {}: {error}",
|
||||
case_dir.display()
|
||||
)
|
||||
})?;
|
||||
let requested_path = case_dir.join(relative_ref);
|
||||
let canonical_target = requested_path.canonicalize().map_err(|error| {
|
||||
format!(
|
||||
"Failed to resolve datanode overlay {} relative to case directory {}: {error}",
|
||||
relative_ref.display(),
|
||||
case_dir.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
if !canonical_target.starts_with(&canonical_case_dir) {
|
||||
return Err(format!(
|
||||
"Datanode overlay {} resolves outside compatibility case directory {}",
|
||||
relative_ref.display(),
|
||||
canonical_case_dir.display()
|
||||
));
|
||||
}
|
||||
|
||||
let mut file = File::open(&canonical_target).map_err(|error| {
|
||||
format!(
|
||||
"Failed to open datanode overlay {}: {error}",
|
||||
canonical_target.display()
|
||||
)
|
||||
})?;
|
||||
let metadata = file.metadata().map_err(|error| {
|
||||
format!(
|
||||
"Failed to inspect datanode overlay {}: {error}",
|
||||
canonical_target.display()
|
||||
)
|
||||
})?;
|
||||
if !metadata.is_file() {
|
||||
return Err(format!(
|
||||
"Datanode overlay {} must be a regular file",
|
||||
canonical_target.display()
|
||||
));
|
||||
}
|
||||
|
||||
let mut content = String::new();
|
||||
file.read_to_string(&mut content).map_err(|error| {
|
||||
format!(
|
||||
"Failed to read datanode overlay {}: {error}",
|
||||
canonical_target.display()
|
||||
)
|
||||
})?;
|
||||
let value: toml::Value = toml::from_str(&content).map_err(|error| {
|
||||
format!(
|
||||
"Failed to parse datanode overlay {}: {error}",
|
||||
canonical_target.display()
|
||||
)
|
||||
})?;
|
||||
if !value.is_table() {
|
||||
return Err(format!(
|
||||
"Datanode overlay {} must have a TOML table at its root",
|
||||
canonical_target.display()
|
||||
));
|
||||
}
|
||||
|
||||
let profile_key = semantic_profile_key(&value);
|
||||
let profile_id = hex::encode(profile_key)[..12].to_string();
|
||||
|
||||
Ok(Self {
|
||||
source: canonical_target,
|
||||
value,
|
||||
profile_key,
|
||||
profile_id,
|
||||
})
|
||||
}
|
||||
|
||||
/// Validates runner-owned path conflicts and records overridden protected paths.
|
||||
pub(crate) fn prepare(
|
||||
self,
|
||||
protection: &DatanodeProtectionPolicy,
|
||||
) -> Result<PreparedDatanodeOverlay, String> {
|
||||
let mut touched_protected_paths = Vec::new();
|
||||
for path in &protection.protected_paths {
|
||||
validate_protected_ancestors(&self.value, path).map_err(|error| {
|
||||
format!(
|
||||
"Datanode overlay {} (profile {}): {error}",
|
||||
self.source.display(),
|
||||
self.profile_id
|
||||
)
|
||||
})?;
|
||||
if value_at_path(&self.value, path).is_some() {
|
||||
touched_protected_paths.push(path.clone());
|
||||
}
|
||||
}
|
||||
touched_protected_paths.sort();
|
||||
|
||||
Ok(PreparedDatanodeOverlay {
|
||||
source: self.source,
|
||||
value: self.value,
|
||||
profile_key: self.profile_key,
|
||||
profile_id: self.profile_id,
|
||||
protected_paths: protection.protected_paths.clone(),
|
||||
touched_protected_paths,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A validated datanode overlay ready for application to a rendered baseline.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct PreparedDatanodeOverlay {
|
||||
source: PathBuf,
|
||||
value: toml::Value,
|
||||
profile_key: [u8; 32],
|
||||
profile_id: String,
|
||||
protected_paths: Vec<DottedPath>,
|
||||
touched_protected_paths: Vec<DottedPath>,
|
||||
}
|
||||
|
||||
impl PreparedDatanodeOverlay {
|
||||
/// Returns the canonical full SHA-256 profile key used for grouping.
|
||||
pub(crate) fn profile_key(&self) -> &[u8; 32] {
|
||||
&self.profile_key
|
||||
}
|
||||
|
||||
/// Returns a truncated profile ID suitable only for diagnostics.
|
||||
pub(crate) fn profile_id(&self) -> &str {
|
||||
&self.profile_id
|
||||
}
|
||||
|
||||
/// Returns the canonical sidecar path used for diagnostics.
|
||||
pub(crate) fn source(&self) -> &Path {
|
||||
&self.source
|
||||
}
|
||||
|
||||
/// Returns protected paths declared by the sidecar, in dotted-path order.
|
||||
pub(crate) fn touched_protected_paths(&self) -> &[DottedPath] {
|
||||
&self.touched_protected_paths
|
||||
}
|
||||
|
||||
/// Merges the sidecar into a rendered baseline and restores runner-owned fields.
|
||||
pub(crate) fn apply_to_rendered_baseline(&self, baseline: &str) -> Result<String, String> {
|
||||
let baseline_value: toml::Value = toml::from_str(baseline)
|
||||
.map_err(|error| format!("Failed to parse rendered datanode baseline: {error}"))?;
|
||||
if !baseline_value.is_table() {
|
||||
return Err(
|
||||
"Rendered datanode baseline must have a TOML table at its root".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let mut merged = baseline_value.clone();
|
||||
merge_toml_values(&mut merged, &self.value);
|
||||
|
||||
for path in &self.protected_paths {
|
||||
restore_protected_path(&mut merged, &baseline_value, path)?;
|
||||
}
|
||||
|
||||
toml::to_string(&merged)
|
||||
.map_err(|error| format!("Failed to serialize merged datanode configuration: {error}"))
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_relative_reference(relative_ref: &Path) -> Result<(), String> {
|
||||
if relative_ref.as_os_str().is_empty()
|
||||
|| relative_ref
|
||||
.components()
|
||||
.any(|component| !matches!(component, Component::Normal(_)))
|
||||
{
|
||||
return Err(format!(
|
||||
"Datanode overlay reference {} must be a non-empty relative path with normal components only",
|
||||
relative_ref.display()
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_protected_ancestors(value: &toml::Value, path: &DottedPath) -> Result<(), String> {
|
||||
let mut current = value;
|
||||
let parts: Vec<_> = path.parts().collect();
|
||||
for (index, part) in parts.iter().enumerate().take(parts.len().saturating_sub(1)) {
|
||||
let Some(table) = current.as_table() else {
|
||||
return Err(format!(
|
||||
"Datanode overlay makes ancestor {} of protected path {} non-table",
|
||||
parts[..index].join("."),
|
||||
path
|
||||
));
|
||||
};
|
||||
let Some(next) = table.get(*part) else {
|
||||
return Ok(());
|
||||
};
|
||||
if !next.is_table() {
|
||||
return Err(format!(
|
||||
"Datanode overlay makes ancestor {} of protected path {} non-table",
|
||||
parts[..=index].join("."),
|
||||
path
|
||||
));
|
||||
}
|
||||
current = next;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn value_at_path<'a>(value: &'a toml::Value, path: &DottedPath) -> Option<&'a toml::Value> {
|
||||
let mut current = value;
|
||||
for part in path.parts() {
|
||||
current = current.as_table()?.get(part)?;
|
||||
}
|
||||
Some(current)
|
||||
}
|
||||
|
||||
fn merge_toml_values(baseline: &mut toml::Value, overlay: &toml::Value) {
|
||||
match (baseline, overlay) {
|
||||
(toml::Value::Table(baseline), toml::Value::Table(overlay)) => {
|
||||
for (key, overlay_value) in overlay {
|
||||
match baseline.get_mut(key) {
|
||||
Some(baseline_value) => merge_toml_values(baseline_value, overlay_value),
|
||||
None => {
|
||||
baseline.insert(key.clone(), overlay_value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(baseline, overlay) => *baseline = overlay.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn restore_protected_path(
|
||||
merged: &mut toml::Value,
|
||||
baseline: &toml::Value,
|
||||
path: &DottedPath,
|
||||
) -> Result<(), String> {
|
||||
let Some(merged_table) = merged.as_table_mut() else {
|
||||
return Err("Merged datanode configuration must have a TOML table at its root".to_string());
|
||||
};
|
||||
let baseline_value = value_at_path(baseline, path).cloned();
|
||||
restore_path_in_table(merged_table, &path.0, baseline_value);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn restore_path_in_table(table: &mut Table, parts: &[String], baseline_value: Option<toml::Value>) {
|
||||
let Some((part, remaining)) = parts.split_first() else {
|
||||
return;
|
||||
};
|
||||
if remaining.is_empty() {
|
||||
match baseline_value {
|
||||
Some(value) => {
|
||||
table.insert(part.clone(), value);
|
||||
}
|
||||
None => {
|
||||
table.remove(part);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(value) = table.get_mut(part) else {
|
||||
return;
|
||||
};
|
||||
if let Some(table) = value.as_table_mut() {
|
||||
restore_path_in_table(table, remaining, baseline_value);
|
||||
}
|
||||
}
|
||||
|
||||
fn semantic_profile_key(value: &toml::Value) -> [u8; 32] {
|
||||
let mut encoded = Vec::new();
|
||||
encode_semantic_value(&mut encoded, value);
|
||||
Sha256::digest(encoded).into()
|
||||
}
|
||||
|
||||
fn encode_semantic_value(output: &mut Vec<u8>, value: &toml::Value) {
|
||||
match value {
|
||||
toml::Value::String(value) => encode_bytes(output, b"string", value.as_bytes()),
|
||||
toml::Value::Integer(value) => {
|
||||
encode_bytes(output, b"integer", &value.to_be_bytes());
|
||||
}
|
||||
toml::Value::Float(value) => {
|
||||
encode_bytes(output, b"float", &value.to_bits().to_be_bytes());
|
||||
}
|
||||
toml::Value::Boolean(value) => {
|
||||
encode_bytes(output, b"boolean", &[u8::from(*value)]);
|
||||
}
|
||||
toml::Value::Datetime(value) => {
|
||||
encode_bytes(output, b"datetime", value.to_string().as_bytes())
|
||||
}
|
||||
toml::Value::Array(values) => {
|
||||
let mut payload = Vec::new();
|
||||
encode_length(&mut payload, values.len());
|
||||
for value in values {
|
||||
encode_semantic_value(&mut payload, value);
|
||||
}
|
||||
encode_bytes(output, b"array", &payload);
|
||||
}
|
||||
toml::Value::Table(table) => {
|
||||
let mut payload = Vec::new();
|
||||
encode_length(&mut payload, table.len());
|
||||
let mut entries: Vec<_> = table.iter().collect();
|
||||
entries.sort_unstable_by(|(left, _), (right, _)| left.as_bytes().cmp(right.as_bytes()));
|
||||
for (key, value) in entries {
|
||||
encode_bytes(&mut payload, b"key", key.as_bytes());
|
||||
encode_semantic_value(&mut payload, value);
|
||||
}
|
||||
encode_bytes(output, b"table", &payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_bytes(output: &mut Vec<u8>, tag: &[u8], value: &[u8]) {
|
||||
encode_length(output, tag.len());
|
||||
output.extend_from_slice(tag);
|
||||
encode_length(output, value.len());
|
||||
output.extend_from_slice(value);
|
||||
}
|
||||
|
||||
fn encode_length(output: &mut Vec<u8>, length: usize) {
|
||||
output.extend_from_slice(&(length as u64).to_be_bytes());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn load_overlay(content: &str) -> DatanodeOverlay {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let path = temp_dir.path().join("overlay.toml");
|
||||
std::fs::write(&path, content).unwrap();
|
||||
let overlay = DatanodeOverlay::load(temp_dir.path(), Path::new("overlay.toml")).unwrap();
|
||||
// `load` owns the parsed TOML, so the temporary source need not outlive it.
|
||||
overlay
|
||||
}
|
||||
|
||||
fn prepared_overlay(content: &str, wal: &WalConfig) -> PreparedDatanodeOverlay {
|
||||
load_overlay(content)
|
||||
.prepare(&DatanodeProtectionPolicy::for_wal(wal))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merges_recursive_tables_and_replaces_non_tables_atomically() {
|
||||
let prepared = prepared_overlay(
|
||||
r#"
|
||||
scalar = "replacement"
|
||||
primitive_array = [3, 2, 1]
|
||||
array_of_tables = [{ name = "new" }]
|
||||
[nested]
|
||||
new_key = true
|
||||
old_key = "replacement"
|
||||
"#,
|
||||
&WalConfig::RaftEngine,
|
||||
);
|
||||
let merged = prepared
|
||||
.apply_to_rendered_baseline(
|
||||
r#"
|
||||
scalar = 1
|
||||
primitive_array = [1, 2]
|
||||
array_of_tables = [{ name = "old" }, { name = "older" }]
|
||||
untouched = "kept"
|
||||
[nested]
|
||||
old_key = 1
|
||||
retained = "kept"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let value: toml::Value = toml::from_str(&merged).unwrap();
|
||||
|
||||
assert_eq!(value["scalar"].as_str(), Some("replacement"));
|
||||
assert_eq!(value["primitive_array"].as_array().unwrap().len(), 3);
|
||||
assert_eq!(value["array_of_tables"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(value["nested"]["old_key"].as_str(), Some("replacement"));
|
||||
assert_eq!(value["nested"]["new_key"].as_bool(), Some(true));
|
||||
assert_eq!(value["nested"]["retained"].as_str(), Some("kept"));
|
||||
assert_eq!(value["untouched"].as_str(), Some("kept"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restores_protected_paths_and_deletes_absent_raft_wal_dir() {
|
||||
let prepared = prepared_overlay(
|
||||
r#"
|
||||
mode = "standalone"
|
||||
node_id = 99
|
||||
[storage]
|
||||
data_home = "/overlay/data"
|
||||
[meta_client_options]
|
||||
metasrv_addrs = ["overlay:3002"]
|
||||
[wal]
|
||||
provider = "kafka"
|
||||
dir = "/overlay/wal"
|
||||
tuning = 42
|
||||
"#,
|
||||
&WalConfig::RaftEngine,
|
||||
);
|
||||
let merged = prepared
|
||||
.apply_to_rendered_baseline(
|
||||
r#"
|
||||
mode = "distributed"
|
||||
node_id = 1
|
||||
[storage]
|
||||
data_home = "/runner/data"
|
||||
[meta_client_options]
|
||||
metasrv_addrs = ["runner:3002"]
|
||||
[wal]
|
||||
provider = "raft_engine"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let value: toml::Value = toml::from_str(&merged).unwrap();
|
||||
|
||||
assert_eq!(value["mode"].as_str(), Some("distributed"));
|
||||
assert_eq!(value["node_id"].as_integer(), Some(1));
|
||||
assert_eq!(value["storage"]["data_home"].as_str(), Some("/runner/data"));
|
||||
assert_eq!(
|
||||
value["meta_client_options"]["metasrv_addrs"][0].as_str(),
|
||||
Some("runner:3002")
|
||||
);
|
||||
assert_eq!(value["wal"]["provider"].as_str(), Some("raft_engine"));
|
||||
assert!(value["wal"].get("dir").is_none());
|
||||
assert_eq!(value["wal"]["tuning"].as_integer(), Some(42));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kafka_policy_restores_broker_endpoints() {
|
||||
let prepared = prepared_overlay(
|
||||
r#"
|
||||
[wal]
|
||||
broker_endpoints = ["overlay:9092"]
|
||||
provider = "raft_engine"
|
||||
"#,
|
||||
&WalConfig::Kafka {
|
||||
needs_kafka_cluster: false,
|
||||
broker_endpoints: vec![],
|
||||
},
|
||||
);
|
||||
let merged = prepared
|
||||
.apply_to_rendered_baseline(
|
||||
r#"
|
||||
[wal]
|
||||
provider = "kafka"
|
||||
broker_endpoints = ["runner:9092"]
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let value: toml::Value = toml::from_str(&merged).unwrap();
|
||||
|
||||
assert_eq!(value["wal"]["provider"].as_str(), Some("kafka"));
|
||||
assert_eq!(
|
||||
value["wal"]["broker_endpoints"][0].as_str(),
|
||||
Some("runner:9092")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_table_protected_ancestor() {
|
||||
let overlay = load_overlay("wal = \"not a table\"");
|
||||
let error = overlay
|
||||
.prepare(&DatanodeProtectionPolicy::for_wal(&WalConfig::RaftEngine))
|
||||
.unwrap_err();
|
||||
|
||||
assert!(error.contains("ancestor wal"));
|
||||
assert!(error.contains("profile"));
|
||||
assert!(error.contains("wal.dir") || error.contains("wal.provider"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collects_sorted_touched_protected_paths() {
|
||||
let prepared = prepared_overlay(
|
||||
r#"
|
||||
node_id = 4
|
||||
[wal]
|
||||
dir = "/overlay/wal"
|
||||
provider = "kafka"
|
||||
"#,
|
||||
&WalConfig::RaftEngine,
|
||||
);
|
||||
|
||||
let paths: Vec<_> = prepared
|
||||
.touched_protected_paths()
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.collect();
|
||||
assert_eq!(paths, ["node_id", "wal.dir", "wal.provider"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn semantic_identity_ignores_formatting_and_table_order() {
|
||||
let first = load_overlay("[settings]\nb = 2\na = \"value\"\n");
|
||||
let second = load_overlay("# comment\n[settings]\na = \"value\"\nb = 2\n");
|
||||
|
||||
assert_eq!(first.profile_key, second.profile_key);
|
||||
assert_eq!(first.profile_id, second.profile_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn semantic_identity_preserves_scalar_types_and_array_order() {
|
||||
let integer = load_overlay("value = 1");
|
||||
let float = load_overlay("value = 1.0");
|
||||
let first_order = load_overlay("values = [1, 2]");
|
||||
let second_order = load_overlay("values = [2, 1]");
|
||||
|
||||
assert_ne!(integer.profile_key, float.profile_key);
|
||||
assert_ne!(first_order.profile_key, second_order.profile_key);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepared_overlay_exposes_profile_and_source_for_runner_grouping() {
|
||||
let overlay = load_overlay("value = 1");
|
||||
let expected_key = overlay.profile_key;
|
||||
let expected_id = overlay.profile_id.clone();
|
||||
let prepared = overlay
|
||||
.prepare(&DatanodeProtectionPolicy::for_wal(&WalConfig::RaftEngine))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(prepared.profile_key(), &expected_key);
|
||||
assert_eq!(prepared.profile_id(), expected_id);
|
||||
assert!(prepared.source().ends_with("overlay.toml"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_absolute_and_traversal_references() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let absolute = temp_dir.path().join("overlay.toml");
|
||||
std::fs::write(&absolute, "value = 1").unwrap();
|
||||
|
||||
assert!(DatanodeOverlay::load(temp_dir.path(), &absolute).is_err());
|
||||
assert!(DatanodeOverlay::load(temp_dir.path(), Path::new("../overlay.toml")).is_err());
|
||||
assert!(DatanodeOverlay::load(temp_dir.path(), Path::new("./overlay.toml")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_directories_and_parse_errors() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir(temp_dir.path().join("directory.toml")).unwrap();
|
||||
std::fs::write(temp_dir.path().join("broken.toml"), "[broken").unwrap();
|
||||
|
||||
let directory_error =
|
||||
DatanodeOverlay::load(temp_dir.path(), Path::new("directory.toml")).unwrap_err();
|
||||
assert!(directory_error.contains("regular file"));
|
||||
let parse_error =
|
||||
DatanodeOverlay::load(temp_dir.path(), Path::new("broken.toml")).unwrap_err();
|
||||
assert!(parse_error.contains("Failed to parse datanode overlay"));
|
||||
assert!(parse_error.contains("broken.toml"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn rejects_symlink_escape() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let case_dir = tempfile::tempdir().unwrap();
|
||||
let outside_dir = tempfile::tempdir().unwrap();
|
||||
let outside_file = outside_dir.path().join("outside.toml");
|
||||
std::fs::write(&outside_file, "value = 1").unwrap();
|
||||
symlink(&outside_file, case_dir.path().join("escape.toml")).unwrap();
|
||||
|
||||
let error = DatanodeOverlay::load(case_dir.path(), Path::new("escape.toml")).unwrap_err();
|
||||
assert!(error.contains("outside compatibility case directory"));
|
||||
}
|
||||
}
|
||||
Vendored
+191
-23
@@ -31,6 +31,7 @@ use tokio::sync::Mutex as TokioMutex;
|
||||
use crate::client::MultiProtocolClient;
|
||||
use crate::cmd::bare::ServerAddr;
|
||||
use crate::cmd::compat_case::try_infer_version;
|
||||
use crate::cmd::datanode_overlay::PreparedDatanodeOverlay;
|
||||
use crate::formatter::{ErrorFormatter, MysqlFormatter, OutputFormatter, PostgresqlFormatter};
|
||||
use crate::protocol_interceptor::{MYSQL, PROTOCOL_KEY};
|
||||
use crate::server_mode::{GrpcArgStyle, ServerMode};
|
||||
@@ -104,6 +105,39 @@ pub struct Env {
|
||||
extra_args: Vec<String>,
|
||||
/// Cache for the inferred gRPC argument style per `bins_dir`.
|
||||
grpc_arg_style_cache: Arc<Mutex<HashMap<PathBuf, GrpcArgStyle>>>,
|
||||
compat_config_stage: Arc<Mutex<CompatConfigStage>>,
|
||||
}
|
||||
|
||||
/// Kills a process unless ownership has been transferred to [`GreptimeDB`].
|
||||
struct ChildGuard(Option<Child>);
|
||||
|
||||
impl ChildGuard {
|
||||
fn new(child: Child) -> Self {
|
||||
Self(Some(child))
|
||||
}
|
||||
|
||||
fn into_inner(mut self) -> Child {
|
||||
self.0.take().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ChildGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Some(child) = self.0.as_mut() {
|
||||
Env::stop_server(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compatibility configuration selected for future server renders.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) enum CompatConfigStage {
|
||||
/// Ordinary and baseline compatibility renders use the template unchanged.
|
||||
Baseline,
|
||||
/// Old compatibility renders apply this datanode overlay.
|
||||
Old(Arc<PreparedDatanodeOverlay>),
|
||||
/// Current compatibility renders use the template unchanged.
|
||||
Current,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -157,9 +191,25 @@ impl Env {
|
||||
store_config,
|
||||
extra_args,
|
||||
grpc_arg_style_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
compat_config_stage: Arc::new(Mutex::new(CompatConfigStage::Baseline)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Selects the old-stage overlay for subsequent compatibility renders.
|
||||
pub(crate) fn activate_compat_old(&self, overlay: Arc<PreparedDatanodeOverlay>) {
|
||||
*self.compat_config_stage.lock().unwrap() = CompatConfigStage::Old(overlay);
|
||||
}
|
||||
|
||||
/// Selects clean current-stage rendering for subsequent compatibility renders.
|
||||
pub(crate) fn activate_compat_current(&self) {
|
||||
*self.compat_config_stage.lock().unwrap() = CompatConfigStage::Current;
|
||||
}
|
||||
|
||||
/// Takes a cheap compatibility-stage snapshot before rendering or spawning.
|
||||
pub(crate) fn compat_config_stage(&self) -> CompatConfigStage {
|
||||
self.compat_config_stage.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
async fn start_standalone(&self, id: usize) -> GreptimeDB {
|
||||
println!("Starting standalone instance id: {id}");
|
||||
|
||||
@@ -176,7 +226,8 @@ impl Env {
|
||||
let server_process = self.start_server(server_mode, &db_ctx, id, true).await;
|
||||
|
||||
let mut greptimedb = self.connect_db(&server_addr, id).await;
|
||||
greptimedb.server_processes = Some(Arc::new(Mutex::new(vec![server_process])));
|
||||
greptimedb.server_processes =
|
||||
Some(Arc::new(Mutex::new(vec![server_process.into_inner()])));
|
||||
greptimedb.is_standalone = true;
|
||||
greptimedb.ctx = db_ctx;
|
||||
|
||||
@@ -237,10 +288,12 @@ impl Env {
|
||||
|
||||
let mut greptimedb = self.connect_db(&server_addr, id).await;
|
||||
|
||||
greptimedb.metasrv_process = Some(meta_server).into();
|
||||
greptimedb.server_processes = Some(Arc::new(Mutex::new(datanodes)));
|
||||
greptimedb.frontend_process = Some(frontend).into();
|
||||
greptimedb.flownode_process = Some(flownode).into();
|
||||
greptimedb.metasrv_process = Some(meta_server.into_inner()).into();
|
||||
greptimedb.server_processes = Some(Arc::new(Mutex::new(
|
||||
datanodes.into_iter().map(ChildGuard::into_inner).collect(),
|
||||
)));
|
||||
greptimedb.frontend_process = Some(frontend.into_inner()).into();
|
||||
greptimedb.flownode_process = Some(flownode.into_inner()).into();
|
||||
greptimedb.is_standalone = false;
|
||||
greptimedb.ctx = db_ctx;
|
||||
|
||||
@@ -311,7 +364,7 @@ impl Env {
|
||||
db_ctx: &GreptimeDBContext,
|
||||
id: usize,
|
||||
truncate_log: bool,
|
||||
) -> Child {
|
||||
) -> ChildGuard {
|
||||
let bins_dir = self.bins_dir.lock().unwrap().clone().expect(
|
||||
"GreptimeDB binary is not available. Please pass in the path to the directory that contains the pre-built GreptimeDB binary. Or you may call `self.build_db()` beforehand.",
|
||||
);
|
||||
@@ -327,7 +380,7 @@ impl Env {
|
||||
id: usize,
|
||||
truncate_log: bool,
|
||||
bins_dir: PathBuf,
|
||||
) -> Child {
|
||||
) -> ChildGuard {
|
||||
let log_file_name = match mode {
|
||||
ServerMode::Datanode { node_id, .. } => {
|
||||
db_ctx.incr_datanode_id();
|
||||
@@ -351,7 +404,15 @@ impl Env {
|
||||
.unwrap();
|
||||
|
||||
let arg_style = self.infer_grpc_arg_style(&bins_dir);
|
||||
let args = mode.get_args(&self.sqlness_home, self, db_ctx, id, arg_style);
|
||||
let compat_stage = self.compat_config_stage();
|
||||
let args = mode.get_args(
|
||||
&self.sqlness_home,
|
||||
self,
|
||||
db_ctx,
|
||||
id,
|
||||
arg_style,
|
||||
&compat_stage,
|
||||
);
|
||||
let check_ip_addrs = mode.check_addrs();
|
||||
|
||||
for check_ip_addr in &check_ip_addrs {
|
||||
@@ -369,7 +430,7 @@ impl Env {
|
||||
.canonicalize()
|
||||
.expect("Failed to canonicalize bins_dir");
|
||||
|
||||
let mut process = Command::new(abs_bins_dir.join(program))
|
||||
let process = Command::new(abs_bins_dir.join(program))
|
||||
.current_dir(bins_dir.clone())
|
||||
.env("TZ", "UTC")
|
||||
.args(args)
|
||||
@@ -382,10 +443,10 @@ impl Env {
|
||||
bins_dir.join(program)
|
||||
);
|
||||
});
|
||||
let process = ChildGuard::new(process);
|
||||
|
||||
for check_ip_addr in &check_ip_addrs {
|
||||
if !util::check_port(check_ip_addr.parse().unwrap(), Duration::from_secs(30)).await {
|
||||
Env::stop_server(&mut process);
|
||||
panic!(
|
||||
"{} doesn't up in 30 seconds, check {} for more details.",
|
||||
mode.name(),
|
||||
@@ -454,6 +515,7 @@ impl Env {
|
||||
vec![new_server_process]
|
||||
} else {
|
||||
db.ctx.reset_datanode_id();
|
||||
let mut new_metasrv = None;
|
||||
if is_full_restart {
|
||||
let metasrv_mode = db
|
||||
.ctx
|
||||
@@ -469,10 +531,7 @@ impl Env {
|
||||
bins_dir.clone(),
|
||||
)
|
||||
.await;
|
||||
db.metasrv_process
|
||||
.lock()
|
||||
.expect("lock poisoned")
|
||||
.replace(metasrv);
|
||||
new_metasrv = Some(metasrv);
|
||||
|
||||
// wait for metasrv to start
|
||||
// since it seems older version of db might take longer to complete election
|
||||
@@ -498,6 +557,7 @@ impl Env {
|
||||
processes.push(new_server_process);
|
||||
}
|
||||
|
||||
let mut new_frontend = None;
|
||||
if is_full_restart {
|
||||
let frontend_mode = db
|
||||
.ctx
|
||||
@@ -514,10 +574,6 @@ impl Env {
|
||||
bins_dir.clone(),
|
||||
)
|
||||
.await;
|
||||
db.frontend_process
|
||||
.lock()
|
||||
.expect("lock poisoned")
|
||||
.replace(frontend);
|
||||
|
||||
// Reconnect protocol clients to the new frontend process
|
||||
// so that MySQL/Postgres queries use the restarted frontend,
|
||||
@@ -529,9 +585,11 @@ impl Env {
|
||||
client
|
||||
.reconnect_pg_client(server_addr.pg_server_addr.as_ref().unwrap())
|
||||
.await;
|
||||
new_frontend = Some(frontend);
|
||||
}
|
||||
|
||||
// Restart flownode.
|
||||
let mut new_flownode = None;
|
||||
if let Some(flownode_mode) = db.ctx.get_server_mode(SERVER_MODE_FLOWNODE_IDX).cloned() {
|
||||
let flownode = self
|
||||
.start_server_with_bins_dir(
|
||||
@@ -542,10 +600,20 @@ impl Env {
|
||||
bins_dir.clone(),
|
||||
)
|
||||
.await;
|
||||
db.flownode_process
|
||||
.lock()
|
||||
.expect("lock poisoned")
|
||||
.replace(flownode);
|
||||
new_flownode = Some(flownode);
|
||||
}
|
||||
|
||||
if let Some(metasrv) = new_metasrv {
|
||||
let mut metasrv_process = db.metasrv_process.lock().expect("lock poisoned");
|
||||
metasrv_process.replace(metasrv.into_inner());
|
||||
}
|
||||
if let Some(frontend) = new_frontend {
|
||||
let mut frontend_process = db.frontend_process.lock().expect("lock poisoned");
|
||||
frontend_process.replace(frontend.into_inner());
|
||||
}
|
||||
if let Some(flownode) = new_flownode {
|
||||
let mut flownode_process = db.flownode_process.lock().expect("lock poisoned");
|
||||
flownode_process.replace(flownode.into_inner());
|
||||
}
|
||||
|
||||
processes
|
||||
@@ -553,7 +621,10 @@ impl Env {
|
||||
|
||||
if let Some(server_processes) = db.server_processes.clone() {
|
||||
let mut server_processes = server_processes.lock().unwrap();
|
||||
*server_processes = new_server_processes;
|
||||
*server_processes = new_server_processes
|
||||
.into_iter()
|
||||
.map(ChildGuard::into_inner)
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -969,3 +1040,100 @@ impl GreptimeDBContext {
|
||||
self.server_modes.get(idx)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::cmd::bare::ServerAddr;
|
||||
use crate::cmd::datanode_overlay::{DatanodeOverlay, DatanodeProtectionPolicy};
|
||||
|
||||
fn test_env(temp_dir: &Path) -> Env {
|
||||
Env::new(
|
||||
temp_dir.to_path_buf(),
|
||||
ServerAddr::default(),
|
||||
WalConfig::RaftEngine,
|
||||
false,
|
||||
None,
|
||||
StoreConfig {
|
||||
store_addrs: vec![],
|
||||
setup_etcd: false,
|
||||
setup_pg: None,
|
||||
setup_mysql: None,
|
||||
enable_flat_format: false,
|
||||
enable_gc: false,
|
||||
},
|
||||
vec![],
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compat_config_stage_is_shared_by_env_clones_and_transitions_cleanly() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(temp_dir.path().join("overlay.toml"), "value = 1").unwrap();
|
||||
let overlay = DatanodeOverlay::load(temp_dir.path(), Path::new("overlay.toml"))
|
||||
.unwrap()
|
||||
.prepare(&DatanodeProtectionPolicy::for_wal(&WalConfig::RaftEngine))
|
||||
.unwrap();
|
||||
let env = test_env(temp_dir.path());
|
||||
let clone = env.clone();
|
||||
|
||||
assert!(matches!(
|
||||
clone.compat_config_stage(),
|
||||
CompatConfigStage::Baseline
|
||||
));
|
||||
env.activate_compat_old(Arc::new(overlay));
|
||||
assert!(matches!(
|
||||
clone.compat_config_stage(),
|
||||
CompatConfigStage::Old(_)
|
||||
));
|
||||
clone.activate_compat_current();
|
||||
assert!(matches!(
|
||||
env.compat_config_stage(),
|
||||
CompatConfigStage::Current
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn child_guard_kills_untransferred_processes() {
|
||||
let child = std::process::Command::new("sleep")
|
||||
.arg("60")
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let pid = child.id().to_string();
|
||||
|
||||
drop(ChildGuard::new(child));
|
||||
|
||||
assert!(
|
||||
!std::process::Command::new("kill")
|
||||
.args(["-0", &pid])
|
||||
.status()
|
||||
.unwrap()
|
||||
.success()
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn child_guard_kills_process_on_forced_unwind() {
|
||||
let child = std::process::Command::new("sleep")
|
||||
.arg("60")
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let pid = child.id().to_string();
|
||||
|
||||
let unwind = std::panic::catch_unwind(|| {
|
||||
let _guard = ChildGuard::new(child);
|
||||
panic!("forced startup unwind");
|
||||
});
|
||||
|
||||
assert!(unwind.is_err());
|
||||
assert!(
|
||||
!std::process::Command::new("kill")
|
||||
.args(["-0", &pid])
|
||||
.status()
|
||||
.unwrap()
|
||||
.success()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+159
-17
@@ -21,7 +21,7 @@ use tinytemplate::TinyTemplate;
|
||||
|
||||
use crate::cmd::bare::ServerAddr;
|
||||
use crate::cmd::compat_case::Version;
|
||||
use crate::env::bare::{Env, GreptimeDBContext, ServiceProvider};
|
||||
use crate::env::bare::{CompatConfigStage, Env, GreptimeDBContext, ServiceProvider};
|
||||
use crate::util;
|
||||
|
||||
const DEFAULT_LOG_LEVEL: &str = "--log-level=debug,hyper=warn,tower=warn,datafusion=warn,reqwest=warn,sqlparser=warn,h2=info,opendal=info";
|
||||
@@ -305,6 +305,7 @@ impl ServerMode {
|
||||
sqlness_home: &Path,
|
||||
db_ctx: &GreptimeDBContext,
|
||||
id: usize,
|
||||
compat_stage: &CompatConfigStage,
|
||||
) -> String {
|
||||
let mut tt = TinyTemplate::new();
|
||||
|
||||
@@ -365,9 +366,34 @@ impl ServerMode {
|
||||
};
|
||||
|
||||
let rendered = tt.render(self.name(), &ctx).unwrap();
|
||||
let rendered = match (self, compat_stage) {
|
||||
(ServerMode::Datanode { .. }, CompatConfigStage::Old(overlay)) => overlay
|
||||
.apply_to_rendered_baseline(&rendered)
|
||||
.unwrap_or_else(|error| {
|
||||
panic!(
|
||||
"Failed to apply old datanode overlay {}: {error}",
|
||||
overlay.source().display()
|
||||
)
|
||||
}),
|
||||
_ => rendered,
|
||||
};
|
||||
|
||||
let stage_suffix = match compat_stage {
|
||||
CompatConfigStage::Baseline => "baseline",
|
||||
CompatConfigStage::Old(_) => "old",
|
||||
CompatConfigStage::Current => "current",
|
||||
};
|
||||
let conf_file = data_home
|
||||
.join(format!("{}-{}-{}.toml", self.name(), id, db_ctx.time()))
|
||||
.join(if matches!(self, ServerMode::Datanode { .. }) {
|
||||
format!(
|
||||
"{}-{}-{}-{stage_suffix}.toml",
|
||||
self.name(),
|
||||
id,
|
||||
db_ctx.time()
|
||||
)
|
||||
} else {
|
||||
format!("{}-{}-{}.toml", self.name(), id, db_ctx.time())
|
||||
})
|
||||
.display()
|
||||
.to_string();
|
||||
println!(
|
||||
@@ -387,6 +413,7 @@ impl ServerMode {
|
||||
db_ctx: &GreptimeDBContext,
|
||||
id: usize,
|
||||
arg_style: GrpcArgStyle,
|
||||
compat_stage: &CompatConfigStage,
|
||||
) -> Vec<String> {
|
||||
let mut args = env
|
||||
.extra_args()
|
||||
@@ -410,7 +437,7 @@ impl ServerMode {
|
||||
id
|
||||
),
|
||||
"-c".to_string(),
|
||||
self.generate_config_file(sqlness_home, db_ctx, id),
|
||||
self.generate_config_file(sqlness_home, db_ctx, id, compat_stage),
|
||||
format!("--http-addr={http_addr}"),
|
||||
format!("{}={rpc_bind_addr}", arg_style.bind_addr_arg()),
|
||||
format!("--mysql-addr={mysql_addr}"),
|
||||
@@ -439,7 +466,7 @@ impl ServerMode {
|
||||
id
|
||||
),
|
||||
"-c".to_string(),
|
||||
self.generate_config_file(sqlness_home, db_ctx, id),
|
||||
self.generate_config_file(sqlness_home, db_ctx, id, compat_stage),
|
||||
]);
|
||||
}
|
||||
ServerMode::Metasrv {
|
||||
@@ -461,7 +488,7 @@ impl ServerMode {
|
||||
id
|
||||
),
|
||||
"-c".to_string(),
|
||||
self.generate_config_file(sqlness_home, db_ctx, id),
|
||||
self.generate_config_file(sqlness_home, db_ctx, id, compat_stage),
|
||||
]);
|
||||
|
||||
if matches!(
|
||||
@@ -540,7 +567,7 @@ impl ServerMode {
|
||||
format!("--log-dir={}/logs", data_home.display()),
|
||||
format!("--node-id={node_id}"),
|
||||
"-c".to_string(),
|
||||
self.generate_config_file(sqlness_home, db_ctx, id),
|
||||
self.generate_config_file(sqlness_home, db_ctx, id, compat_stage),
|
||||
format!("--metasrv-addrs={metasrv_addr}"),
|
||||
]);
|
||||
}
|
||||
@@ -573,8 +600,10 @@ impl ServerMode {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::*;
|
||||
use crate::cmd::datanode_overlay::{DatanodeOverlay, DatanodeProtectionPolicy};
|
||||
use crate::env::bare::{StoreConfig, WalConfig};
|
||||
|
||||
fn test_env(sqlness_home: &Path) -> (Env, GreptimeDBContext) {
|
||||
@@ -600,6 +629,19 @@ mod tests {
|
||||
(env, db_ctx)
|
||||
}
|
||||
|
||||
fn old_stage(temp_dir: &Path) -> CompatConfigStage {
|
||||
std::fs::write(
|
||||
temp_dir.join("old-datanode.toml"),
|
||||
"old_only = \"applied\"\nmode = \"standalone\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let overlay = DatanodeOverlay::load(temp_dir, Path::new("old-datanode.toml"))
|
||||
.unwrap()
|
||||
.prepare(&DatanodeProtectionPolicy::for_wal(&WalConfig::RaftEngine))
|
||||
.unwrap();
|
||||
CompatConfigStage::Old(Arc::new(overlay))
|
||||
}
|
||||
|
||||
fn has_arg(args: &[String], name: &str) -> bool {
|
||||
let prefix = format!("{name}=");
|
||||
args.iter()
|
||||
@@ -641,7 +683,14 @@ mod tests {
|
||||
postgres_addr: "127.0.0.1:4003".to_string(),
|
||||
};
|
||||
assert_uses_style(
|
||||
&standalone.get_args(temp_dir, env, db_ctx, 0, style),
|
||||
&standalone.get_args(
|
||||
temp_dir,
|
||||
env,
|
||||
db_ctx,
|
||||
0,
|
||||
style,
|
||||
&CompatConfigStage::Baseline,
|
||||
),
|
||||
style,
|
||||
false,
|
||||
);
|
||||
@@ -654,7 +703,14 @@ mod tests {
|
||||
metasrv_addr: "127.0.0.1:4001".to_string(),
|
||||
};
|
||||
assert_uses_style(
|
||||
&frontend.get_args(temp_dir, env, db_ctx, 0, style),
|
||||
&frontend.get_args(
|
||||
temp_dir,
|
||||
env,
|
||||
db_ctx,
|
||||
0,
|
||||
style,
|
||||
&CompatConfigStage::Baseline,
|
||||
),
|
||||
style,
|
||||
true,
|
||||
);
|
||||
@@ -665,7 +721,14 @@ mod tests {
|
||||
http_addr: "127.0.0.1:4200".to_string(),
|
||||
};
|
||||
assert_uses_style(
|
||||
&metasrv.get_args(temp_dir, env, db_ctx, 0, style),
|
||||
&metasrv.get_args(
|
||||
temp_dir,
|
||||
env,
|
||||
db_ctx,
|
||||
0,
|
||||
style,
|
||||
&CompatConfigStage::Baseline,
|
||||
),
|
||||
style,
|
||||
true,
|
||||
);
|
||||
@@ -678,7 +741,14 @@ mod tests {
|
||||
node_id: 0,
|
||||
};
|
||||
assert_uses_style(
|
||||
&datanode.get_args(temp_dir, env, db_ctx, 0, style),
|
||||
&datanode.get_args(
|
||||
temp_dir,
|
||||
env,
|
||||
db_ctx,
|
||||
0,
|
||||
style,
|
||||
&CompatConfigStage::Baseline,
|
||||
),
|
||||
style,
|
||||
true,
|
||||
);
|
||||
@@ -691,7 +761,14 @@ mod tests {
|
||||
node_id: 0,
|
||||
};
|
||||
assert_uses_style(
|
||||
&flownode.get_args(temp_dir, env, db_ctx, 0, style),
|
||||
&flownode.get_args(
|
||||
temp_dir,
|
||||
env,
|
||||
db_ctx,
|
||||
0,
|
||||
style,
|
||||
&CompatConfigStage::Baseline,
|
||||
),
|
||||
style,
|
||||
true,
|
||||
);
|
||||
@@ -759,14 +836,79 @@ mod tests {
|
||||
node_id: 0,
|
||||
};
|
||||
|
||||
let metasrv_config =
|
||||
std::fs::read_to_string(metasrv.generate_config_file(temp_dir.path(), &db_ctx, 0))
|
||||
.unwrap();
|
||||
let datanode_config =
|
||||
std::fs::read_to_string(datanode.generate_config_file(temp_dir.path(), &db_ctx, 0))
|
||||
.unwrap();
|
||||
let metasrv_config = std::fs::read_to_string(metasrv.generate_config_file(
|
||||
temp_dir.path(),
|
||||
&db_ctx,
|
||||
0,
|
||||
&CompatConfigStage::Baseline,
|
||||
))
|
||||
.unwrap();
|
||||
let datanode_config = std::fs::read_to_string(datanode.generate_config_file(
|
||||
temp_dir.path(),
|
||||
&db_ctx,
|
||||
0,
|
||||
&CompatConfigStage::Baseline,
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
assert!(metasrv_config.contains("[gc]\nenable = true"));
|
||||
assert!(datanode_config.contains("[region_engine.mito.gc]\nenable = true"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_datanode_rendering_applies_only_old_stage_with_distinct_filenames() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let (_, db_ctx) = test_env(temp_dir.path());
|
||||
let datanode = ServerMode::Datanode {
|
||||
rpc_bind_addr: "127.0.0.1:4301".to_string(),
|
||||
rpc_server_addr: "127.0.0.1:4301".to_string(),
|
||||
http_addr: "127.0.0.1:4300".to_string(),
|
||||
metasrv_addr: "127.0.0.1:4201".to_string(),
|
||||
node_id: 0,
|
||||
};
|
||||
let baseline_path = datanode.generate_config_file(
|
||||
temp_dir.path(),
|
||||
&db_ctx,
|
||||
0,
|
||||
&CompatConfigStage::Baseline,
|
||||
);
|
||||
let old_path =
|
||||
datanode.generate_config_file(temp_dir.path(), &db_ctx, 0, &old_stage(temp_dir.path()));
|
||||
let current_path =
|
||||
datanode.generate_config_file(temp_dir.path(), &db_ctx, 0, &CompatConfigStage::Current);
|
||||
|
||||
let baseline = std::fs::read_to_string(&baseline_path).unwrap();
|
||||
let old = std::fs::read_to_string(&old_path).unwrap();
|
||||
let current = std::fs::read_to_string(¤t_path).unwrap();
|
||||
assert!(baseline_path.ends_with("-baseline.toml"));
|
||||
assert!(old_path.ends_with("-old.toml"));
|
||||
assert!(current_path.ends_with("-current.toml"));
|
||||
assert!(!baseline.contains("old_only"));
|
||||
assert!(old.contains("old_only = \"applied\""));
|
||||
assert!(!current.contains("old_only"));
|
||||
assert_eq!(
|
||||
toml::from_str::<toml::Value>(&old).unwrap()["mode"].as_str(),
|
||||
toml::from_str::<toml::Value>(&baseline).unwrap()["mode"].as_str()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_non_datanode_ignores_old_overlay() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let (_, db_ctx) = test_env(temp_dir.path());
|
||||
let metasrv = ServerMode::Metasrv {
|
||||
rpc_bind_addr: "127.0.0.1:4201".to_string(),
|
||||
rpc_server_addr: "127.0.0.1:4201".to_string(),
|
||||
http_addr: "127.0.0.1:4200".to_string(),
|
||||
};
|
||||
let config_path =
|
||||
metasrv.generate_config_file(temp_dir.path(), &db_ctx, 0, &old_stage(temp_dir.path()));
|
||||
|
||||
assert!(!config_path.ends_with("-old.toml"));
|
||||
assert!(
|
||||
!std::fs::read_to_string(config_path)
|
||||
.unwrap()
|
||||
.contains("old_only")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+46
-16
@@ -285,25 +285,55 @@ pub fn setup_etcd(client_ports: Vec<u16>, peer_port: Option<u16>, etcd_version:
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop and remove the etcd container
|
||||
pub fn stop_rm_etcd() {
|
||||
/// Stop and remove the etcd container, failing if it cannot be confirmed absent.
|
||||
pub fn stop_rm_etcd_checked() -> Result<(), String> {
|
||||
let status = std::process::Command::new("docker")
|
||||
.args(["container", "stop", "etcd"])
|
||||
.status();
|
||||
if status.is_err() {
|
||||
panic!("Failed to stop etcd: {:?}", status);
|
||||
} else {
|
||||
println!("Stopped etcd");
|
||||
}
|
||||
// rm the container
|
||||
let status = std::process::Command::new("docker")
|
||||
.args(["container", "rm", "etcd"])
|
||||
.status();
|
||||
if status.is_err() {
|
||||
panic!("Failed to remove etcd container: {:?}", status);
|
||||
} else {
|
||||
.args(["container", "rm", "--force", "etcd"])
|
||||
.status()
|
||||
.map_err(|error| format!("Failed to run Docker while removing etcd: {error}"))?;
|
||||
if status.success() {
|
||||
println!("Removed etcd container");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let listed = std::process::Command::new("docker")
|
||||
.args([
|
||||
"container",
|
||||
"ls",
|
||||
"--all",
|
||||
"--filter",
|
||||
"name=^/etcd$",
|
||||
"--format",
|
||||
"{{.ID}}",
|
||||
])
|
||||
.output()
|
||||
.map_err(|error| {
|
||||
format!(
|
||||
"Docker failed to remove etcd ({status}) and could not verify its absence: {error}"
|
||||
)
|
||||
})?;
|
||||
if !listed.status.success() {
|
||||
return Err(format!(
|
||||
"Docker failed to remove etcd ({status}) and listing containers failed: {}",
|
||||
String::from_utf8_lossy(&listed.stderr).trim()
|
||||
));
|
||||
}
|
||||
if String::from_utf8_lossy(&listed.stdout).trim().is_empty() {
|
||||
println!("Etcd container is already absent");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"Docker failed to remove etcd container (status {status}); the container still exists"
|
||||
))
|
||||
}
|
||||
|
||||
/// Stop and remove the etcd container.
|
||||
///
|
||||
/// Legacy callers retain panic-on-cleanup-failure behavior. Compatibility
|
||||
/// profiles use [`stop_rm_etcd_checked`] to report the failure structurally.
|
||||
pub fn stop_rm_etcd() {
|
||||
stop_rm_etcd_checked().unwrap_or_else(|error| panic!("{error}"));
|
||||
}
|
||||
|
||||
/// Set up a PostgreSQL server in docker.
|
||||
|
||||
Reference in New Issue
Block a user