Skip to content

Commit 332bf32

Browse files
committed
fix: s3 tests
1 parent 441f793 commit 332bf32

8 files changed

Lines changed: 308 additions & 1 deletion

File tree

.github/workflows/ci.yml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,24 @@ jobs:
3838

3939
- name: Build package
4040
run: uv build
41+
42+
s3-integration:
43+
runs-on: ubuntu-latest
44+
45+
steps:
46+
- name: Check out repository
47+
uses: actions/checkout@v4
48+
49+
- name: Set up Python
50+
uses: actions/setup-python@v5
51+
with:
52+
python-version: "3.12"
53+
54+
- name: Set up uv
55+
uses: astral-sh/setup-uv@v4
56+
57+
- name: Install dependencies
58+
run: uv sync --group dev
59+
60+
- name: Run S3 integration tests against MiniStack
61+
run: bash scripts/run_s3_integration.sh

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,22 @@ uv run fluxel verify --repo /tmp/fluxel-demo --ref main --path images --path roo
200200

201201
Fluxel includes `integration`-marked tests for real S3-compatible behavior. The preferred target is Ministack.
202202

203+
For the standard local workflow, run a single command from the repository root:
204+
205+
```bash
206+
bash scripts/run_s3_integration.sh
207+
```
208+
209+
That script starts a temporary Ministack container on `127.0.0.1:4566`, waits for the health endpoint, resets emulator state, runs `tests/test_s3_integration.py`, and cleans up the container when the test run finishes.
210+
211+
If you prefer task-runner aliases, the repo also provides:
212+
213+
```bash
214+
make test-s3-integration
215+
```
216+
217+
GitHub Actions runs the same script in the dedicated S3 integration job.
218+
203219
Start Ministack locally:
204220

205221
```bash

scripts/run_s3_integration.sh

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
#!/usr/bin/env bash
2+
3+
set -euo pipefail
4+
5+
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
6+
MINISTACK_IMAGE="${FLUXEL_MINISTACK_IMAGE:-nahuelnucera/ministack}"
7+
MINISTACK_HOST="${FLUXEL_MINISTACK_HOST:-127.0.0.1}"
8+
MINISTACK_PORT="${FLUXEL_MINISTACK_PORT:-4566}"
9+
DEFAULT_ENDPOINT="http://${MINISTACK_HOST}:${MINISTACK_PORT}"
10+
MANAGE_CONTAINER="${FLUXEL_MINISTACK_MANAGE_CONTAINER:-auto}"
11+
12+
export FLUXEL_MINISTACK_ENDPOINT="${FLUXEL_MINISTACK_ENDPOINT:-$DEFAULT_ENDPOINT}"
13+
export FLUXEL_MINISTACK_ACCESS_KEY="${FLUXEL_MINISTACK_ACCESS_KEY:-test}"
14+
export FLUXEL_MINISTACK_SECRET_KEY="${FLUXEL_MINISTACK_SECRET_KEY:-test}"
15+
export FLUXEL_MINISTACK_REGION="${FLUXEL_MINISTACK_REGION:-us-east-1}"
16+
17+
HEALTH_URL="${FLUXEL_MINISTACK_ENDPOINT%/}/_ministack/health"
18+
RESET_URL="${FLUXEL_MINISTACK_ENDPOINT%/}/_ministack/reset"
19+
20+
container_started=0
21+
container_name="fluxel-ministack-${USER:-user}-$$"
22+
23+
cleanup() {
24+
if [[ "$container_started" -eq 1 ]]; then
25+
docker rm -f "$container_name" >/dev/null 2>&1 || true
26+
fi
27+
}
28+
29+
print_docker_daemon_help() {
30+
case "$(uname -s)" in
31+
Darwin)
32+
echo "docker daemon is not running. Start Docker Desktop, Colima, or another local Docker runtime, then rerun this script." >&2
33+
;;
34+
Linux)
35+
echo "docker daemon is not running. Start Docker Engine or point docker at a running daemon, then rerun this script." >&2
36+
;;
37+
*)
38+
echo "docker daemon is not running. Start your local Docker runtime, then rerun this script." >&2
39+
;;
40+
esac
41+
}
42+
43+
wait_for_ministack() {
44+
local attempt
45+
46+
for attempt in $(seq 1 30); do
47+
if curl -fsS "$HEALTH_URL" >/dev/null; then
48+
return 0
49+
fi
50+
sleep 1
51+
done
52+
53+
echo "MiniStack did not become healthy at $HEALTH_URL" >&2
54+
return 1
55+
}
56+
57+
start_ministack_if_needed() {
58+
if curl -fsS "$HEALTH_URL" >/dev/null 2>&1; then
59+
return 0
60+
fi
61+
62+
if [[ "$MANAGE_CONTAINER" == "0" ]]; then
63+
return 0
64+
fi
65+
66+
if [[ "$MANAGE_CONTAINER" == "auto" && "$FLUXEL_MINISTACK_ENDPOINT" != "$DEFAULT_ENDPOINT" ]]; then
67+
return 0
68+
fi
69+
70+
if ! command -v docker >/dev/null 2>&1; then
71+
echo "docker is required to start MiniStack locally" >&2
72+
return 1
73+
fi
74+
75+
if ! docker info >/dev/null 2>&1; then
76+
print_docker_daemon_help
77+
return 1
78+
fi
79+
80+
docker run -d --rm -p "${MINISTACK_PORT}:4566" --name "$container_name" "$MINISTACK_IMAGE" >/dev/null
81+
container_started=1
82+
}
83+
84+
trap cleanup EXIT
85+
86+
start_ministack_if_needed
87+
wait_for_ministack
88+
curl -fsS -X POST "$RESET_URL" >/dev/null 2>&1 || true
89+
90+
cd "$ROOT_DIR"
91+
uv run pytest tests/test_s3_integration.py -m integration "$@"

src/fluxel/core/client_state.py

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,36 @@
11
from __future__ import annotations
22

3+
import json
4+
from dataclasses import dataclass
35
from pathlib import Path
46
from tempfile import NamedTemporaryFile
57

68

79
HEAD_FILE = "HEAD"
810

911

12+
@dataclass(frozen=True)
13+
class LocalBranchSnapshot:
14+
branch: str
15+
commit_id: str | None
16+
version_token: str | None
17+
18+
1019
class LocalClientState:
1120
def __init__(self, root: str | Path) -> None:
1221
self.root = Path(root).resolve()
1322
self.fluxel_dir = self.root / ".fluxel"
1423
self.refs_dir = self.fluxel_dir / "refs"
24+
self.branch_state_dir = self.refs_dir / "heads"
1525
self.staging_dir = self.fluxel_dir / "staging"
1626
self.head_path = self.refs_dir / HEAD_FILE
1727

18-
for path in (self.fluxel_dir, self.refs_dir, self.staging_dir):
28+
for path in (
29+
self.fluxel_dir,
30+
self.refs_dir,
31+
self.branch_state_dir,
32+
self.staging_dir,
33+
):
1934
path.mkdir(parents=True, exist_ok=True)
2035

2136
def ensure_current_branch(self, default_branch: str) -> None:
@@ -44,6 +59,36 @@ def write_staging_payload(self, branch: str, payload: str | None) -> None:
4459
return
4560
self._atomic_write_text(stage_path, payload)
4661

62+
def read_branch_snapshot(self, branch: str) -> LocalBranchSnapshot | None:
63+
snapshot_path = self.branch_snapshot_path(branch)
64+
if not snapshot_path.exists():
65+
return None
66+
payload = json.loads(snapshot_path.read_text(encoding="utf-8"))
67+
return LocalBranchSnapshot(
68+
branch=branch,
69+
commit_id=payload.get("commit_id"),
70+
version_token=payload.get("version_token"),
71+
)
72+
73+
def write_branch_snapshot(
74+
self,
75+
branch: str,
76+
*,
77+
commit_id: str | None,
78+
version_token: str | None,
79+
) -> None:
80+
payload = json.dumps(
81+
{
82+
"commit_id": commit_id,
83+
"version_token": version_token,
84+
},
85+
sort_keys=True,
86+
)
87+
self._atomic_write_text(self.branch_snapshot_path(branch), f"{payload}\n")
88+
89+
def branch_snapshot_path(self, branch: str) -> Path:
90+
return self.branch_state_dir / f"{branch}.json"
91+
4792
def stage_path(self, branch: str) -> Path:
4893
return self.staging_dir / f"{branch}.json"
4994

src/fluxel/core/repository.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,8 +216,16 @@ def branch(self, name: str) -> Path:
216216
name,
217217
head_commit or None,
218218
expected_version_token=None,
219+
expected_commit_id=None,
219220
):
220221
raise ValueError(f"Branch already exists: {name}")
222+
created_state = self.store.read_branch_ref(name)
223+
if created_state is not None:
224+
self.client_state.write_branch_snapshot(
225+
name,
226+
commit_id=created_state.commit_id,
227+
version_token=created_state.version_token,
228+
)
221229
self._resolved_ref_cache.pop(name, None)
222230
return self._branch_path(name)
223231

@@ -1123,9 +1131,22 @@ def _ensure_branch_exists(self, branch: str) -> None:
11231131
self._require_branch_state(branch)
11241132

11251133
def _require_branch_state(self, branch: str) -> BranchRefState:
1134+
cached_state = self.client_state.read_branch_snapshot(branch)
1135+
if cached_state is not None:
1136+
return BranchRefState(
1137+
branch=branch,
1138+
commit_id=cached_state.commit_id,
1139+
version_token=cached_state.version_token,
1140+
)
1141+
11261142
branch_state = self.store.read_branch_ref(branch)
11271143
if branch_state is None:
11281144
raise ValueError(f"Unknown branch: {branch}")
1145+
self.client_state.write_branch_snapshot(
1146+
branch,
1147+
commit_id=branch_state.commit_id,
1148+
version_token=branch_state.version_token,
1149+
)
11291150
return branch_state
11301151

11311152
def _require_commit_for_metadata_mutation(self, branch: str) -> CommitObject:
@@ -1293,14 +1314,26 @@ def _update_branch_ref(
12931314
branch,
12941315
commit_id,
12951316
expected_version_token=expected_version_token,
1317+
expected_commit_id=expected_commit_id,
12961318
)
12971319
if updated:
12981320
self._resolved_ref_cache.pop(branch, None)
12991321
current_state = self.store.read_branch_ref(branch)
13001322
if current_state is not None:
13011323
self._resolved_ref_cache[branch] = current_state
1324+
self.client_state.write_branch_snapshot(
1325+
branch,
1326+
commit_id=current_state.commit_id,
1327+
version_token=current_state.version_token,
1328+
)
13021329
return
13031330
current_state = self.store.read_branch_ref(branch)
1331+
if current_state is not None:
1332+
self.client_state.write_branch_snapshot(
1333+
branch,
1334+
commit_id=current_state.commit_id,
1335+
version_token=current_state.version_token,
1336+
)
13041337
raise RefConflictError(
13051338
branch=branch,
13061339
operation=operation,

src/fluxel/core/repository_store.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ def compare_and_set_branch_ref(
8181
commit_id: str | None,
8282
*,
8383
expected_version_token: str | None,
84+
expected_commit_id: str | None = None,
8485
) -> bool: ...
8586

8687
def read_blob_bytes(self, blob_hash: str) -> bytes: ...
@@ -185,10 +186,15 @@ def compare_and_set_branch_ref(
185186
commit_id: str | None,
186187
*,
187188
expected_version_token: str | None,
189+
expected_commit_id: str | None = None,
188190
) -> bool:
189191
current_token = self.version_token("ref", branch)
190192
if current_token != expected_version_token:
191193
return False
194+
current_state = self.read_branch_ref(branch)
195+
current_commit_id = current_state.commit_id if current_state else None
196+
if current_commit_id != expected_commit_id:
197+
return False
192198
self.write_branch_ref(branch, commit_id)
193199
return True
194200

@@ -414,6 +420,7 @@ def compare_and_set_branch_ref(
414420
commit_id: str | None,
415421
*,
416422
expected_version_token: str | None,
423+
expected_commit_id: str | None = None,
417424
) -> bool:
418425
lock_token = str(uuid4())
419426
if not self._acquire_branch_lock(branch, lock_token):
@@ -423,6 +430,9 @@ def compare_and_set_branch_ref(
423430
current_version = current.version_token if current else None
424431
if current_version != expected_version_token:
425432
return False
433+
current_commit_id = current.commit_id if current else None
434+
if current_commit_id != expected_commit_id:
435+
return False
426436
self.write_branch_ref(branch, commit_id)
427437
return True
428438
finally:

tests/conftest.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ def paginate(self, *, Bucket: str, Prefix: str) -> list[dict[str, object]]:
4747
class FakeS3Client:
4848
def __init__(self, objects: dict[str, dict[str, object]]) -> None:
4949
self._objects = objects
50+
self.fixed_etag: str | None = None
5051

5152
def get_paginator(self, operation_name: str) -> FakeS3Paginator:
5253
assert operation_name == "list_objects_v2"
@@ -116,6 +117,8 @@ def delete_object(self, *, Bucket: str, Key: str) -> dict[str, object]:
116117
return {}
117118

118119
def _etag(self, payload: bytes) -> str:
120+
if self.fixed_etag is not None:
121+
return self.fixed_etag
119122
return f'"{len(payload):x}-{sum(payload):x}"'
120123

121124
def _client_error(self, code: str) -> ClientError:

0 commit comments

Comments
 (0)