mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-15 09:33:13 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 75882732dc | |||
| 71e83aeec4 |
@@ -94,6 +94,7 @@ jobs:
|
||||
short_sha: ${{ steps.check.outputs.short_sha }}
|
||||
is_prerelease: ${{ steps.check.outputs.is_prerelease }}
|
||||
create_latest: ${{ steps.check.outputs.create_latest }}
|
||||
source_ref: ${{ steps.check.outputs.source_ref }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
@@ -118,6 +119,7 @@ jobs:
|
||||
short_sha=""
|
||||
is_prerelease=false
|
||||
create_latest=false
|
||||
source_ref="$GITHUB_SHA"
|
||||
|
||||
if [[ "${{ github.event_name }}" == "workflow_run" ]]; then
|
||||
# Triggered by build workflow completion
|
||||
@@ -137,6 +139,7 @@ jobs:
|
||||
# Extract version info from commit message or use commit SHA
|
||||
# Use Git to generate consistent short SHA (ensures uniqueness like build.yml)
|
||||
short_sha=$(git rev-parse --short "$HEAD_SHA")
|
||||
source_ref="$HEAD_SHA"
|
||||
|
||||
# Determine build type based on triggering workflow event and ref
|
||||
triggering_event="$TRIGGERING_EVENT"
|
||||
@@ -261,6 +264,23 @@ jobs:
|
||||
echo "⚠️ Only release versions (latest, v1.0.0, 1.0.0) and prereleases (v1.0.0-alpha1, 1.0.0-beta2) are supported"
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ "$should_build" == true && "$input_version" != "latest" ]]; then
|
||||
tag_ref="refs/tags/$input_version"
|
||||
if ! git ls-remote --exit-code origin "$tag_ref" >/dev/null 2>&1; then
|
||||
if [[ "$input_version" == v* ]]; then
|
||||
tag_ref="refs/tags/${input_version#v}"
|
||||
else
|
||||
tag_ref="refs/tags/v$input_version"
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! git ls-remote --exit-code origin "$tag_ref" >/dev/null 2>&1; then
|
||||
echo "❌ Release tag not found for Docker build: $input_version"
|
||||
exit 1
|
||||
fi
|
||||
source_ref="$tag_ref"
|
||||
fi
|
||||
fi
|
||||
|
||||
{
|
||||
@@ -271,6 +291,7 @@ jobs:
|
||||
echo "short_sha=$short_sha"
|
||||
echo "is_prerelease=$is_prerelease"
|
||||
echo "create_latest=$create_latest"
|
||||
echo "source_ref=$source_ref"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "🐳 Docker Build Summary:"
|
||||
@@ -281,6 +302,7 @@ jobs:
|
||||
echo " - Short SHA: $short_sha"
|
||||
echo " - Is prerelease: $is_prerelease"
|
||||
echo " - Create latest: $create_latest"
|
||||
echo " - Source ref: $source_ref"
|
||||
|
||||
# Build multi-arch Docker images
|
||||
# Strategy: Build images using pre-built binaries from dl.rustfs.com
|
||||
@@ -308,6 +330,7 @@ jobs:
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ needs.build-check.outputs.source_ref }}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||
@@ -397,7 +420,8 @@ jobs:
|
||||
LABELS="org.opencontainers.image.title=RustFS"
|
||||
LABELS="$LABELS,org.opencontainers.image.description=RustFS distributed object storage system"
|
||||
LABELS="$LABELS,org.opencontainers.image.version=$VERSION"
|
||||
LABELS="$LABELS,org.opencontainers.image.revision=${{ github.sha }}"
|
||||
SOURCE_REVISION="$(git rev-parse HEAD)"
|
||||
LABELS="$LABELS,org.opencontainers.image.revision=$SOURCE_REVISION"
|
||||
LABELS="$LABELS,org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}"
|
||||
LABELS="$LABELS,org.opencontainers.image.created=$(date -u +'%Y-%m-%dT%H:%M:%SZ')"
|
||||
LABELS="$LABELS,org.opencontainers.image.build-type=$BUILD_TYPE"
|
||||
|
||||
@@ -6014,6 +6014,63 @@ fn peer_edit_fence(queries: &HashMap<String, String>) -> Option<(String, u64)> {
|
||||
Some((origin.clone(), generation))
|
||||
}
|
||||
|
||||
/// The largest generation an incoming fence may carry: this site's clock in
|
||||
/// unix nanoseconds plus a day of cross-site skew. Every site authenticates
|
||||
/// peer traffic with the shared site-replicator service account, so the
|
||||
/// receiver cannot tell WHICH site stamped a fence — a compromised peer can
|
||||
/// claim any origin, and a u64::MAX-scale generation would raise that
|
||||
/// origin's high-water mark past anything the genuine site ever allocates,
|
||||
/// silently fencing out its every future edit. A genuine generation is the
|
||||
/// hybrid clock of [`next_peer_edit_generation`], floored by the sender's
|
||||
/// wall time, so it exceeds this site's clock only by cross-site skew — and
|
||||
/// the mark a ceiling-level forgery can still plant decays as real time
|
||||
/// passes it, within the allowance. Only an origin still allocating with the
|
||||
/// pre-hybrid plain counter cannot outrun such a mark until it upgrades: its
|
||||
/// genuine generations never approach nanosecond scale.
|
||||
fn peer_edit_fence_generation_ceiling(now_unix_nanos: u64) -> u64 {
|
||||
const CROSS_SITE_SKEW_ALLOWANCE_NANOS: u64 = 24 * 60 * 60 * 1_000_000_000;
|
||||
now_unix_nanos.saturating_add(CROSS_SITE_SKEW_ALLOWANCE_NANOS)
|
||||
}
|
||||
|
||||
/// Whether an incoming fence may be honoured, as far as this site can vouch
|
||||
/// for it. The sender's identity is unverifiable (shared service account),
|
||||
/// so the check runs over what the receiving state knows: the claimed origin
|
||||
/// must be a site this state currently replicates with — the same membership
|
||||
/// rule the load-time mark pruning applies, so every mark recorded behind
|
||||
/// this check is one a reload would keep — and not this site itself, which
|
||||
/// never delivers edits to itself; and the generation must sit under
|
||||
/// [`peer_edit_fence_generation_ceiling`]. The caller IGNORES an
|
||||
/// inadmissible fence rather than failing the request: the delivery applies
|
||||
/// exactly as an unstamped (pre-fence) delivery would, no high-water mark is
|
||||
/// read or written, and the worst a forged fence achieves is forfeiting an
|
||||
/// ordering guarantee its sender was never owed.
|
||||
fn peer_edit_fence_is_admissible(
|
||||
state: &SiteReplicationState,
|
||||
local_deployment_id: &str,
|
||||
fence: &(String, u64),
|
||||
generation_ceiling: u64,
|
||||
) -> bool {
|
||||
let (origin, generation) = fence;
|
||||
let result = if origin == local_deployment_id || !state.peers.contains_key(origin) {
|
||||
"fence_origin_not_a_remote_peer"
|
||||
} else if *generation > generation_ceiling {
|
||||
"fence_generation_beyond_ceiling"
|
||||
} else {
|
||||
return true;
|
||||
};
|
||||
warn!(
|
||||
event = EVENT_ADMIN_SITE_REPLICATION_STATE,
|
||||
component = LOG_COMPONENT_ADMIN,
|
||||
subsystem = LOG_SUBSYSTEM_SITE_REPLICATION,
|
||||
result,
|
||||
origin = %origin,
|
||||
generation = *generation,
|
||||
generation_ceiling,
|
||||
"ignoring inadmissible peer-edit fence"
|
||||
);
|
||||
false
|
||||
}
|
||||
|
||||
/// True when a strictly newer edit from the same origin site already landed
|
||||
/// here. No lock on the sending side can order deliveries issued by two
|
||||
/// nodes of that site, so ordering is decided here, on the generation the
|
||||
@@ -9559,6 +9616,7 @@ impl Operation for SRPeerEditHandler {
|
||||
let ilm_expiry_override = sr_edit_ilm_expiry_override(&req.uri);
|
||||
let endpoint_refresh_requested = queries.get("refresh-targets").is_some_and(|value| value == "true");
|
||||
let commit_fence = peer_edit_fence(&queries);
|
||||
let fence_generation_ceiling = peer_edit_fence_generation_ceiling(edit_generation_wall_clock());
|
||||
let local_endpoint = site_replication_local_endpoint(&req.uri, &req.headers);
|
||||
let (refresh_id, incoming) = if endpoint_refresh_requested {
|
||||
let refresh: EndpointRefreshRequest = read_site_replication_json(req, "", false).await?;
|
||||
@@ -9576,6 +9634,11 @@ impl Operation for SRPeerEditHandler {
|
||||
let outcome = update_site_replication_state_when_changed(move |state| {
|
||||
let mut incoming = incoming;
|
||||
let local_peer = local_peer_at_endpoint(commit_endpoint, state);
|
||||
// The fence is self-reported — the shared service account means
|
||||
// the sender cannot be identified — so it is honoured only after
|
||||
// the admissibility check, against the same state it will gate.
|
||||
let commit_fence = commit_fence
|
||||
.filter(|fence| peer_edit_fence_is_admissible(state, &local_peer.deployment_id, fence, fence_generation_ceiling));
|
||||
// Ordering fence: the sending site allocates the generation under
|
||||
// its state-object lock, so a delivery that lost the race carries
|
||||
// a generation this site has already passed. Applying it would
|
||||
@@ -11957,6 +12020,17 @@ mod tests {
|
||||
handler_block.contains("record_applied_peer_edit_generation(state, origin, *generation);"),
|
||||
"SRPeerEditHandler must record the applied generation so later stale deliveries are recognised"
|
||||
);
|
||||
// Fence hardening: origin and generation are self-reported by a
|
||||
// caller the shared service account cannot identify, so the handler
|
||||
// must pass the fence through the admissibility check — against the
|
||||
// same state the fence gates, i.e. inside the transaction — before
|
||||
// reading or raising any high-water mark.
|
||||
assert!(
|
||||
handler_block.contains(
|
||||
".filter(|fence| peer_edit_fence_is_admissible(state, &local_peer.deployment_id, fence, fence_generation_ceiling))"
|
||||
),
|
||||
"SRPeerEditHandler must admit a fence only through peer_edit_fence_is_admissible inside the state transaction"
|
||||
);
|
||||
// P1-15 PR2: both halves of the fence and the edit they fence share
|
||||
// ONE transaction. Checking the fence against a state read outside the
|
||||
// lock would let the check pass on one snapshot and the write land on
|
||||
@@ -13251,6 +13325,94 @@ mod tests {
|
||||
assert!(peer_edit_delivery_is_stale(&state, origin, generation - 1));
|
||||
}
|
||||
|
||||
/// A fence is self-reported: every site authenticates peer traffic with
|
||||
/// the same site-replicator credential, so a compromised peer can stamp
|
||||
/// ANY origin with ANY generation. The receiver must refuse to let such
|
||||
/// a stamp touch the high-water marks — an origin it does not replicate
|
||||
/// with, its own deployment id, and a generation no genuine counter
|
||||
/// could have reached are all ignored, and ignoring one plants no mark.
|
||||
#[test]
|
||||
fn forged_peer_edit_fences_cannot_poison_the_high_water_marks() {
|
||||
let mut state = SiteReplicationState {
|
||||
peers: BTreeMap::from([
|
||||
(
|
||||
"site-local".to_string(),
|
||||
PeerInfo {
|
||||
deployment_id: "site-local".to_string(),
|
||||
..peer("local", "https://local.example:9000")
|
||||
},
|
||||
),
|
||||
(
|
||||
"site-victim".to_string(),
|
||||
PeerInfo {
|
||||
deployment_id: "site-victim".to_string(),
|
||||
..peer("victim", "https://victim.example:9000")
|
||||
},
|
||||
),
|
||||
]),
|
||||
..Default::default()
|
||||
};
|
||||
let ceiling = peer_edit_fence_generation_ceiling(edit_generation_wall_clock());
|
||||
|
||||
// An origin outside the current membership is refused outright...
|
||||
let unknown = ("site-unknown".to_string(), 4u64);
|
||||
assert!(!peer_edit_fence_is_admissible(&state, "site-local", &unknown, ceiling));
|
||||
|
||||
// No site delivers edits to itself: a fence claiming the receiver as
|
||||
// its origin is forged by construction, current peer or not.
|
||||
let own = ("site-local".to_string(), 4u64);
|
||||
assert!(!peer_edit_fence_is_admissible(&state, "site-local", &own, ceiling));
|
||||
|
||||
// A generation past the ceiling (the u64::MAX poisoning) is refused
|
||||
// even when it names a current peer.
|
||||
let poisoned = ("site-victim".to_string(), u64::MAX);
|
||||
assert!(!peer_edit_fence_is_admissible(&state, "site-local", &poisoned, ceiling));
|
||||
let barely_over = ("site-victim".to_string(), ceiling + 1);
|
||||
assert!(!peer_edit_fence_is_admissible(&state, "site-local", &barely_over, ceiling));
|
||||
|
||||
// With no mark planted, the victim's genuine deliveries keep
|
||||
// applying, and its admitted fence works end to end.
|
||||
let genuine = ("site-victim".to_string(), 1u64);
|
||||
assert!(peer_edit_fence_is_admissible(&state, "site-local", &genuine, ceiling));
|
||||
assert!(!peer_edit_delivery_is_stale(&state, &genuine.0, genuine.1));
|
||||
record_applied_peer_edit_generation(&mut state, &genuine.0, genuine.1);
|
||||
assert_eq!(state.applied_edit_generations.get("site-victim"), Some(&1));
|
||||
}
|
||||
|
||||
/// The generation ceiling must admit both live generation shapes: the
|
||||
/// small plain counter pre-hybrid sites still allocate, and the
|
||||
/// wall-clock-floored hybrid of `next_peer_edit_generation` — including
|
||||
/// one running AHEAD of the receiver's clock by cross-site skew, which
|
||||
/// is the whole reason the allowance exists, and the ceiling itself,
|
||||
/// the last admissible value.
|
||||
#[test]
|
||||
fn peer_edit_fence_ceiling_admits_counter_and_clock_seeded_generations() {
|
||||
let state = SiteReplicationState {
|
||||
peers: BTreeMap::from([(
|
||||
"site-origin".to_string(),
|
||||
PeerInfo {
|
||||
deployment_id: "site-origin".to_string(),
|
||||
..peer("origin", "https://origin.example:9000")
|
||||
},
|
||||
)]),
|
||||
..Default::default()
|
||||
};
|
||||
let now = edit_generation_wall_clock();
|
||||
let ceiling = peer_edit_fence_generation_ceiling(now);
|
||||
|
||||
let counter = ("site-origin".to_string(), 42u64);
|
||||
assert!(peer_edit_fence_is_admissible(&state, "site-local", &counter, ceiling));
|
||||
|
||||
// One hour ahead of the receiver's clock: within the skew allowance.
|
||||
// Shrinking the allowance to zero must turn this fence away.
|
||||
let clock_ahead = ("site-origin".to_string(), now + 60 * 60 * 1_000_000_000);
|
||||
assert!(peer_edit_fence_is_admissible(&state, "site-local", &clock_ahead, ceiling));
|
||||
|
||||
// The boundary is inclusive: rejection starts strictly past it.
|
||||
let at_ceiling = ("site-origin".to_string(), ceiling);
|
||||
assert!(peer_edit_fence_is_admissible(&state, "site-local", &at_ceiling, ceiling));
|
||||
}
|
||||
|
||||
/// P1-15 review follow-up: a site that leaves the mesh drops below two
|
||||
/// peers, which clears its state object and restarts its generation
|
||||
/// counter at zero. A mark left over from its previous membership would
|
||||
|
||||
@@ -195,6 +195,13 @@ IFS= read -r -d '' expected_docker_automatic_guard <<'EOF' || true
|
||||
EOF
|
||||
expected_docker_automatic_guard=${expected_docker_automatic_guard%$'\n'}
|
||||
require_job_if "$docker_workflow" "build-check" "$expected_docker_automatic_guard"
|
||||
require_line "$docker_workflow" ' source_ref: ${{ steps.check.outputs.source_ref }}' "Docker source ref output"
|
||||
require_line "$docker_workflow" ' source_ref="$HEAD_SHA"' "automatic Docker source ref"
|
||||
require_line "$docker_workflow" ' source_ref="$tag_ref"' "manual Docker source ref"
|
||||
require_line "$docker_workflow" ' ref: ${{ needs.build-check.outputs.source_ref }}' "Docker release source checkout"
|
||||
require_line "$docker_workflow" ' SOURCE_REVISION="$(git rev-parse HEAD)"' "Docker source revision resolution"
|
||||
require_line "$docker_workflow" ' LABELS="$LABELS,org.opencontainers.image.revision=$SOURCE_REVISION"' "Docker revision label"
|
||||
require_absent "$docker_workflow" 'org.opencontainers.image.revision=${{ github.sha }}' "Docker revision must not use the workflow branch SHA"
|
||||
|
||||
docker_manual_guard=$(awk '
|
||||
$0 == " *-preview*)" { in_preview = 1 }
|
||||
|
||||
Reference in New Issue
Block a user