Skip to content

Commit f0b95b6

Browse files
authored
Merge pull request #14274 from lovesegfault/nix-s3-versioned
feat(libstore): support S3 object versioning via versionId parameter
2 parents e213fd6 + e38128b commit f0b95b6

File tree

5 files changed

+123
-5
lines changed

5 files changed

+123
-5
lines changed
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
synopsis: "S3 URLs now support object versioning via versionId parameter"
3+
prs: [14274]
4+
issues: [13955]
5+
---
6+
7+
S3 URLs now support a `versionId` query parameter to fetch specific versions
8+
of objects from S3 buckets with versioning enabled. This allows pinning to
9+
exact object versions for reproducibility and protection against unexpected
10+
changes:
11+
12+
```
13+
s3://bucket/key?region=us-east-1&versionId=abc123def456
14+
```

src/libstore-tests/s3-url.cc

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,25 @@ INSTANTIATE_TEST_SUITE_P(
7070
},
7171
"with_profile_and_region",
7272
},
73+
ParsedS3URLTestCase{
74+
"s3://my-bucket/my-key.txt?versionId=abc123xyz",
75+
{
76+
.bucket = "my-bucket",
77+
.key = {"my-key.txt"},
78+
.versionId = "abc123xyz",
79+
},
80+
"with_versionId",
81+
},
82+
ParsedS3URLTestCase{
83+
"s3://bucket/path/to/object?region=eu-west-1&versionId=version456",
84+
{
85+
.bucket = "bucket",
86+
.key = {"path", "to", "object"},
87+
.region = "eu-west-1",
88+
.versionId = "version456",
89+
},
90+
"with_region_and_versionId",
91+
},
7392
ParsedS3URLTestCase{
7493
"s3://bucket/key?endpoint=https://minio.local&scheme=http",
7594
{
@@ -222,6 +241,37 @@ INSTANTIATE_TEST_SUITE_P(
222241
},
223242
"https://s3.ap-southeast-2.amazonaws.com/bucket/path/to/file.txt",
224243
"complex_path_and_region",
244+
},
245+
S3ToHttpsConversionTestCase{
246+
ParsedS3URL{
247+
.bucket = "my-bucket",
248+
.key = {"my-key.txt"},
249+
.versionId = "abc123xyz",
250+
},
251+
ParsedURL{
252+
.scheme = "https",
253+
.authority = ParsedURL::Authority{.host = "s3.us-east-1.amazonaws.com"},
254+
.path = {"", "my-bucket", "my-key.txt"},
255+
.query = {{"versionId", "abc123xyz"}},
256+
},
257+
"https://s3.us-east-1.amazonaws.com/my-bucket/my-key.txt?versionId=abc123xyz",
258+
"with_versionId",
259+
},
260+
S3ToHttpsConversionTestCase{
261+
ParsedS3URL{
262+
.bucket = "versioned-bucket",
263+
.key = {"path", "to", "object"},
264+
.region = "eu-west-1",
265+
.versionId = "version456",
266+
},
267+
ParsedURL{
268+
.scheme = "https",
269+
.authority = ParsedURL::Authority{.host = "s3.eu-west-1.amazonaws.com"},
270+
.path = {"", "versioned-bucket", "path", "to", "object"},
271+
.query = {{"versionId", "version456"}},
272+
},
273+
"https://s3.eu-west-1.amazonaws.com/versioned-bucket/path/to/object?versionId=version456",
274+
"with_region_and_versionId",
225275
}),
226276
[](const ::testing::TestParamInfo<S3ToHttpsConversionTestCase> & info) { return info.param.description; });
227277

src/libstore/include/nix/store/s3-url.hh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ struct ParsedS3URL
2626
std::optional<std::string> profile;
2727
std::optional<std::string> region;
2828
std::optional<std::string> scheme;
29+
std::optional<std::string> versionId;
2930
/**
3031
* The endpoint can be either missing, be an absolute URI (with a scheme like `http:`)
3132
* or an authority (so an IP address or a registered name).

src/libstore/s3-url.cc

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ try {
4848
.profile = getOptionalParam("profile"),
4949
.region = getOptionalParam("region"),
5050
.scheme = getOptionalParam("scheme"),
51+
.versionId = getOptionalParam("versionId"),
5152
.endpoint = [&]() -> decltype(ParsedS3URL::endpoint) {
5253
if (!endpoint)
5354
return std::monostate();
@@ -73,6 +74,12 @@ ParsedURL ParsedS3URL::toHttpsUrl() const
7374
auto regionStr = region.transform(toView).value_or("us-east-1");
7475
auto schemeStr = scheme.transform(toView).value_or("https");
7576

77+
// Build query parameters (e.g., versionId if present)
78+
StringMap queryParams;
79+
if (versionId) {
80+
queryParams["versionId"] = *versionId;
81+
}
82+
7683
// Handle endpoint configuration using std::visit
7784
return std::visit(
7885
overloaded{
@@ -85,6 +92,7 @@ ParsedURL ParsedS3URL::toHttpsUrl() const
8592
.scheme = std::string{schemeStr},
8693
.authority = ParsedURL::Authority{.host = "s3." + regionStr + ".amazonaws.com"},
8794
.path = std::move(path),
95+
.query = std::move(queryParams),
8896
};
8997
},
9098
[&](const ParsedURL::Authority & auth) {
@@ -96,6 +104,7 @@ ParsedURL ParsedS3URL::toHttpsUrl() const
96104
.scheme = std::string{schemeStr},
97105
.authority = auth,
98106
.path = std::move(path),
107+
.query = std::move(queryParams),
99108
};
100109
},
101110
[&](const ParsedURL & endpointUrl) {
@@ -107,6 +116,7 @@ ParsedURL ParsedS3URL::toHttpsUrl() const
107116
.scheme = endpointUrl.scheme,
108117
.authority = endpointUrl.authority,
109118
.path = std::move(path),
119+
.query = std::move(queryParams),
110120
};
111121
},
112122
},

tests/nixos/s3-binary-cache-store.nix

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
{
2-
lib,
32
config,
4-
nixpkgs,
53
...
64
}:
75

@@ -147,7 +145,7 @@ in
147145
else:
148146
machine.fail(f"nix path-info {pkg}")
149147
150-
def setup_s3(populate_bucket=[], public=False):
148+
def setup_s3(populate_bucket=[], public=False, versioned=False):
151149
"""
152150
Decorator that creates/destroys a unique bucket for each test.
153151
Optionally pre-populates bucket with specified packages.
@@ -156,14 +154,17 @@ in
156154
Args:
157155
populate_bucket: List of packages to upload before test runs
158156
public: If True, make the bucket publicly accessible
157+
versioned: If True, enable versioning on the bucket before populating
159158
"""
160159
def decorator(test_func):
161160
def wrapper():
162161
bucket = str(uuid.uuid4())
163162
server.succeed(f"mc mb minio/{bucket}")
164-
if public:
165-
server.succeed(f"mc anonymous set download minio/{bucket}")
166163
try:
164+
if public:
165+
server.succeed(f"mc anonymous set download minio/{bucket}")
166+
if versioned:
167+
server.succeed(f"mc version enable minio/{bucket}")
167168
if populate_bucket:
168169
store_url = make_s3_url(bucket)
169170
for pkg in populate_bucket:
@@ -597,6 +598,47 @@ in
597598
598599
print(" ✓ File content verified correct (hash matches)")
599600
601+
@setup_s3(populate_bucket=[PKGS['A']], versioned=True)
602+
def test_versioned_urls(bucket):
603+
"""Test that versionId parameter is accepted in S3 URLs"""
604+
print("\n=== Testing Versioned URLs ===")
605+
606+
# Get the nix-cache-info file
607+
cache_info_url = make_s3_url(bucket, path="/nix-cache-info")
608+
609+
# Fetch without versionId should work
610+
client.succeed(
611+
f"{ENV_WITH_CREDS} nix eval --impure --expr "
612+
f"'builtins.fetchurl {{ name = \"cache-info\"; url = \"{cache_info_url}\"; }}'"
613+
)
614+
print(" ✓ Fetch without versionId works")
615+
616+
# List versions to get a version ID
617+
# MinIO output format: [timestamp] size tier versionId versionNumber method filename
618+
versions_output = server.succeed(f"mc ls --versions minio/{bucket}/nix-cache-info")
619+
620+
# Extract version ID from output (4th field after STANDARD)
621+
import re
622+
version_match = re.search(r'STANDARD\s+(\S+)\s+v\d+', versions_output)
623+
if not version_match:
624+
print(f"Debug: versions output: {versions_output}")
625+
raise Exception("Could not extract version ID from MinIO output")
626+
627+
version_id = version_match.group(1)
628+
print(f" ✓ Found version ID: {version_id}")
629+
630+
# Version ID should not be "null" since versioning was enabled before upload
631+
if version_id == "null":
632+
raise Exception("Version ID is 'null' - versioning may not be working correctly")
633+
634+
# Fetch with versionId parameter
635+
versioned_url = f"{cache_info_url}&versionId={version_id}"
636+
client.succeed(
637+
f"{ENV_WITH_CREDS} nix eval --impure --expr "
638+
f"'builtins.fetchurl {{ name = \"cache-info-versioned\"; url = \"{versioned_url}\"; }}'"
639+
)
640+
print(" ✓ Fetch with versionId parameter works")
641+
600642
# ============================================================================
601643
# Main Test Execution
602644
# ============================================================================
@@ -626,6 +668,7 @@ in
626668
test_compression_mixed()
627669
test_compression_disabled()
628670
test_nix_prefetch_url()
671+
test_versioned_urls()
629672
630673
print("\n" + "="*80)
631674
print("✓ All S3 Binary Cache Store Tests Passed!")

0 commit comments

Comments
 (0)