Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions .github/workflows/rust-vip-linux.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
name: Rust VIP Linux acceptance

on:
pull_request:
paths:
- .github/workflows/rust-vip-linux.yml
- rust/crates/control-config/**
- rust/crates/tiproxy-rs/**
- tests/dataplane/integration/vip-standalone.sh
workflow_dispatch:

permissions:
contents: read

concurrency:
group: rust-vip-linux-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
two-node-vip:
name: Two Linux standalone VIP owners
runs-on: ubuntu-24.04
timeout-minutes: 45
env:
DATAPLANE_ARTIFACT_ROOT: ${{ github.workspace }}/tests/dataplane/integration/artifacts
steps:
- name: Check out source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Select repository Go toolchain
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod
cache: true
- name: Install checksum-pinned TiUP
run: |
source tests/dataplane/integration/versions.env
archive="$RUNNER_TEMP/tiup-v${TIUP_VERSION}-linux-amd64.tar.gz"
install_root="$RUNNER_TEMP/tiup-v${TIUP_VERSION}"
curl --fail --location --retry 3 --proto '=https' --tlsv1.2 \
--output "$archive" \
"https://github.com/pingcap/tiup/releases/download/v${TIUP_VERSION}/tiup-v${TIUP_VERSION}-linux-amd64.tar.gz"
echo "$TIUP_LINUX_AMD64_SHA256 $archive" | sha256sum -c -
mkdir -p "$install_root"
tar -xzf "$archive" -C "$install_root"
"$install_root/bin/tiup" --version
echo "$install_root/bin" >>"$GITHUB_PATH"
echo "TIUP_HOME=$install_root" >>"$GITHUB_ENV"
- name: Preinstall frozen TiUP components
run: bash tests/dataplane/integration/warm-tiup-components.sh
- name: Install Linux network and Rust build prerequisites
run: |
sudo apt-get update
sudo apt-get install --yes iproute2 arping tcpdump protobuf-compiler
command -v mysql
- name: Build standalone Rust executable
run: make rust-build
- name: Prove VIP ownership and SQL continuity
run: bash tests/dataplane/integration/vip-standalone.sh
- name: Upload node and election evidence
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: rust-vip-linux-${{ github.run_id }}
path: tests/dataplane/integration/artifacts/vip-*/diagnostics/**
if-no-files-found: warn
retention-days: 14
2 changes: 1 addition & 1 deletion rust/crates/control-config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ pub use model::{
RoutingBalancePolicy, RoutingConfig, RoutingConnectionFactorConfig, RoutingFactorConfig,
RoutingNamespace, RoutingRule, RoutingSelectionPolicy, ServingConfig, ServingKeepalive,
ServingListener, ServingNamespace, ServingTlsConfig, SourceRevision, TopologyConfig,
TopologyRuntimeIdentity,
TopologyRuntimeIdentity, VipConfig,
};
pub use module::{
ConfigModule, ConfigModuleHandle, ConfigModuleOptions, ConfigMutationError,
Expand Down
100 changes: 100 additions & 0 deletions rust/crates/control-config/src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,45 @@ pub struct ConfigPersistence {
pub cluster_tls: ClientTlsConfig,
}

/// Inputs for the dedicated VIP election and network owner.
///
/// The decision to start is pinned at process startup, matching Go's VIP
/// manager. A missing address/interface pair or a non-singleton backend set
/// disables VIP; neither case grants ownership of a partially configured IP.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct VipConfig {
/// Address as configured, including its optional CIDR prefix.
pub address: Arc<str>,
/// Parsed host IP used for the dedicated election key and GARP.
pub ip: IpAddr,
/// Prefix length to compare and bind on the Linux interface.
pub prefix_len: u8,
/// Linux interface that holds the address.
pub interface: Arc<str>,
/// The sole backend cluster's PD endpoints, which own the VIP election.
pub pd_addrs: Arc<[Arc<str>]>,
/// TLS material for that cluster's PD connection.
pub cluster_tls: ClientTlsConfig,
/// Immediate GARP packets in each burst.
pub garp_burst_count: u64,
/// One-second follow-up GARP bursts after takeover.
pub garp_refresh_count: u64,
}

impl VipConfig {
/// Returns the Go-compatible election prefix for this exact VIP host IP.
#[must_use]
pub fn election_name(&self) -> String {
format!("/tiproxy/vip/{}/owner", self.ip)
}

/// Returns the canonical address and prefix for Linux address operations.
#[must_use]
pub fn cidr(&self) -> String {
format!("{}/{}", self.ip, self.prefix_len)
}
}

/// Health observer policy consumed by CP-TOPO.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct HealthCheckConfig {
Expand Down Expand Up @@ -596,6 +635,46 @@ impl EffectiveConfig {
})
}

/// Projects an enabled VIP only for a single backend cluster. The
/// configured address and interface must both be present. Go elects via
/// that cluster's PD endpoints, which may differ from `proxy.pd-addrs`.
///
/// # Errors
///
/// Returns an invalid VIP address, backend normalization error or an
/// unrepresentable GARP count. Config validation already rejects negative
/// counts and gives a zero burst count the Go-compatible value of one.
pub fn vip(&self) -> Result<Option<VipConfig>, ConfigError> {
if self.ha.virtual_ip.is_empty() || self.ha.interface.is_empty() {
return Ok(None);
}
let clusters = self.normalized_backend_clusters()?;
let [cluster] = clusters.as_slice() else {
return Ok(None);
};
let (ip, prefix_len) = parse_vip_address(&self.ha.virtual_ip)?;
let garp_burst_count =
u64::try_from(self.ha.garp_burst_count).map_err(|_| ConfigError::InvalidField {
field: "ha.garp-burst-count",
class: "out_of_range",
})?;
let garp_refresh_count =
u64::try_from(self.ha.garp_refresh_count).map_err(|_| ConfigError::InvalidField {
field: "ha.garp-refresh-count",
class: "out_of_range",
})?;
Ok(Some(VipConfig {
address: Arc::from(self.ha.virtual_ip.as_str()),
ip,
prefix_len,
interface: Arc::from(self.ha.interface.as_str()),
pd_addrs: Arc::clone(&cluster.pd_addrs),
cluster_tls: client_tls(&self.security.cluster_tls),
garp_burst_count,
garp_refresh_count,
}))
}

/// Returns the complete SQL-serving projection formerly built by Go.
///
/// # Errors
Expand Down Expand Up @@ -2424,6 +2503,27 @@ fn normalize_ip_prefix(value: &str) -> Result<String, ConfigError> {
}
}

fn parse_vip_address(value: &str) -> Result<(IpAddr, u8), ConfigError> {
let failure = || ConfigError::InvalidField {
field: "ha.virtual-ip",
class: "invalid_ip_or_cidr",
};
let (ip, prefix_len) = if let Some((ip, prefix)) = value.split_once('/') {
(
ip.parse::<IpAddr>().map_err(|_| failure())?,
prefix.parse::<u8>().map_err(|_| failure())?,
)
} else {
let ip = value.parse::<IpAddr>().map_err(|_| failure())?;
let prefix_len = if ip.is_ipv4() { 32 } else { 128 };
(ip, prefix_len)
};
if prefix_len > if ip.is_ipv4() { 32 } else { 128 } {
return Err(failure());
}
Ok((ip, prefix_len))
}

fn path_arc(value: &str) -> Option<Arc<Path>> {
(!value.is_empty()).then(|| Arc::from(Path::new(value)))
}
Expand Down
62 changes: 62 additions & 0 deletions rust/crates/control-config/tests/source.rs
Original file line number Diff line number Diff line change
Expand Up @@ -670,6 +670,68 @@ pd-addrs = "routing-pd:2379"
assert_eq!(persistence.pd_addrs[0].as_ref(), "owner-pd:2379");
}

#[test]
fn vip_requires_complete_ha_pair_and_one_backend_cluster() {
let load = |source: &str| {
ConfigNamespaceStore::from_toml(source.as_bytes(), None, current_dir())
.unwrap_or_else(|error| unreachable!("VIP config: {error}"))
};
let basic = r#"
[proxy]
pd-addrs = "pd:2379"
[ha]
virtual-ip = "192.0.2.5/24"
interface = "eth0"
garp-burst-count = 0
garp-refresh-count = 2
"#;
let store = load(basic);
let vip = store
.current()
.effective()
.vip()
.unwrap_or_else(|error| unreachable!("VIP projection: {error}"))
.unwrap_or_else(|| unreachable!("complete single-cluster VIP is enabled"));
assert_eq!(vip.address.as_ref(), "192.0.2.5/24");
assert_eq!(vip.interface.as_ref(), "eth0");
assert_eq!(vip.election_name(), "/tiproxy/vip/192.0.2.5/owner");
assert_eq!(vip.cidr(), "192.0.2.5/24");
assert_eq!(vip.garp_burst_count, 1);
assert_eq!(vip.garp_refresh_count, 2);

let incomplete = load(&basic.replace("interface = \"eth0\"", "interface = \"\""));
assert!(matches!(incomplete.current().effective().vip(), Ok(None)));
let no_pd = load(&basic.replace("pd-addrs = \"pd:2379\"", "pd-addrs = \"\""));
assert!(matches!(no_pd.current().effective().vip(), Ok(None)));
let explicit_cluster = load(&basic.replace(
"[ha]",
"[[proxy.backend-clusters]]\nname = \"only\"\npd-addrs = \"cluster-pd:2379\"\n[ha]",
));
let cluster_vip = explicit_cluster
.current()
.effective()
.vip()
.unwrap_or_else(|error| unreachable!("VIP projection: {error}"))
.unwrap_or_else(|| unreachable!("single explicit backend cluster enables VIP"));
assert_eq!(cluster_vip.pd_addrs[0].as_ref(), "cluster-pd:2379");
let two_clusters = load(
&basic.replace(
"[ha]",
"[[proxy.backend-clusters]]\nname = \"a\"\npd-addrs = \"pd-a:2379\"\n[[proxy.backend-clusters]]\nname = \"b\"\npd-addrs = \"pd-b:2379\"\n[ha]",
),
);
assert!(matches!(two_clusters.current().effective().vip(), Ok(None)));

let invalid_address = load(&basic.replace("192.0.2.5/24", "192.0.2.5/33"));
assert!(matches!(
invalid_address.current().effective().vip(),
Err(ConfigError::InvalidField {
field: "ha.virtual-ip",
class: "invalid_ip_or_cidr"
})
));
}

#[test]
fn config_module_rejects_a_factory_that_disagrees_with_initial_transport() {
let initial = EtcdClientConfig::new(["initial-pd:2379".to_owned()], None)
Expand Down
2 changes: 1 addition & 1 deletion rust/crates/tiproxy-rs/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ if-addrs.workspace = true
libc.workspace = true
rustix.workspace = true
rustls.workspace = true
tokio = { workspace = true, features = ["signal"] }
tokio = { workspace = true, features = ["process", "signal"] }
tokio-rustls.workspace = true

[dev-dependencies]
Expand Down
Loading
Loading