Skip to content

Commit e38128b

Browse files
committed
feat(libstore): support S3 object versioning via versionId parameter
S3 buckets support object versioning to prevent unexpected changes, but Nix previously lacked the ability to fetch specific versions of S3 objects. This adds support for a `versionId` query parameter in S3 URLs, enabling users to pin to specific object versions: ``` s3://bucket/key?region=us-east-1&versionId=abc123 ```
1 parent e213fd6 commit e38128b

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)