Skip to content

Commit 4a1c01c

Browse files
committed
support read only buildcaches; set defaults on mirrors.yaml inputs
1 parent 9c86155 commit 4a1c01c

5 files changed

Lines changed: 72 additions & 33 deletions

File tree

docs/build-caches.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,21 @@ buildcache:
4646
| Field | Required | Description |
4747
|-------|----------|-------------|
4848
| `url` | yes | location of the cache (a `file://` path, or an `http(s)://`, `s3://` or `oci://` URL) |
49-
| `private_key` | yes | PGP key used to sign and push packages (see [Keys](#keys)) |
49+
| `private_key` | no | PGP key used to sign and push packages (see [Keys](#keys)); omit for a read-only cache |
5050
| `public_key` | no | PGP key used to verify downloaded packages |
5151
| `name` | no | name Spack registers the mirror under (default `buildcache`) |
5252
| `mount_specific` | no | store the cache in a per-mount-point sub-directory (default `false`) |
5353

54+
### Read-only build cache
55+
56+
Omit `private_key` to configure a read-only cache: Spack fetches packages from it but never signs or pushes anything back.
57+
This is useful for consuming a shared team cache that you are not permitted to write to.
58+
59+
```yaml title="mirrors.yaml"
60+
buildcache:
61+
url: file:///capstor/scratch/team/uenv-cache
62+
```
63+
5464
### `mount_specific`
5565

5666
Spack binaries embed the install prefix (the image's mount point), so binaries built for `/user-environment` cannot be reused at a different mount point.

stackinator/mirror.py

Lines changed: 15 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,10 @@ class Mirrors:
3636
3737
The kinds of mirror have separate types:
3838
39-
* buildcache - at most one, signs and stores built packages (so it alone
40-
has a private key and the mount_specific flag). None if no
41-
build cache is configured.
39+
* buildcache - at most one, fetches and stores built packages (so it alone
40+
has the mount_specific flag). With a private key it signs
41+
and pushes packages too; without one it is read-only. None
42+
if no build cache is configured.
4243
* bootstrap - at most one, used to bootstrap spack itself. None if absent.
4344
* source_mirrors - a name -> config mapping of any number of read-only source
4445
mirrors (spack mirrors.yaml entries).
@@ -98,12 +99,9 @@ def __init__(
9899
except ValueError as err:
99100
raise MirrorError(f"Mirror config does not comply with schema.\n{err}")
100101

101-
# The build cache, if one is defined in mirrors.yaml.
102-
buildcache = raw_mirrors.get("buildcache")
103-
if buildcache:
104-
if not buildcache.get("private_key"):
105-
raise MirrorError("Mirror build cache config is missing a required 'private_key' path.")
106-
self.buildcache = buildcache
102+
# The build cache, if one is defined in mirrors.yaml. A build cache without
103+
# a private_key is read-only: spack fetches from it but never pushes to it.
104+
self.buildcache = raw_mirrors.get("buildcache")
107105

108106
# A build cache passed via the deprecated cache.yaml file (the --cache CLI
109107
# option) takes precedence over a buildcache defined in mirrors.yaml.
@@ -122,16 +120,16 @@ def __init__(
122120
except ValueError as err:
123121
raise MirrorError(f"Error validating contents of cache config at '{cmdline_cache}'.\n{err}")
124122

123+
# a cache.yaml without a key configures a read-only (fetch-only) cache
125124
self.buildcache = {
126125
"name": "buildcache",
127126
"url": raw_cache["root"],
128127
"description": "Buildcache dest loaded from legacy cache.yaml",
128+
"public_key": None,
129+
"private_key": raw_cache.get("key"),
129130
"mount_specific": True,
130131
"cmdline": True,
131132
}
132-
# a cache.yaml without a key configures a read-only (fetch-only) cache
133-
if raw_cache.get("key") is not None:
134-
self.buildcache["private_key"] = raw_cache["key"]
135133
self._logger.warning(
136134
"Configuring the buildcache from the system cache.yaml file.\n"
137135
"Please switch to using either the '--cache' option or the 'mirrors.yaml' file instead.\n"
@@ -142,7 +140,7 @@ def __init__(
142140
# The bootstrap mirror, the read-only source mirrors, and the writable
143141
# source cache, if any are defined.
144142
self.bootstrap = raw_mirrors.get("bootstrap")
145-
self.source_mirrors = dict(raw_mirrors.get("sourcemirror", {}))
143+
self.source_mirrors = dict(raw_mirrors["sourcemirror"])
146144
self.source_cache = raw_mirrors.get("sourcecache")
147145

148146
# Validate that every mirror url is well-formed (see _validate_url).
@@ -167,15 +165,15 @@ def __init__(
167165

168166
# A build cache that pushes packages signs them with its private key. A build
169167
# cache without a key is read-only, and is fetched from but never pushed to.
170-
if self.buildcache is not None and self.buildcache.get("private_key"):
168+
if self.buildcache is not None and self.buildcache["private_key"] is not None:
171169
name = self.buildcache["name"]
172170
self._key_files.append(
173171
(key_store / f"{name}.priv.gpg", self._read_key(self.buildcache["private_key"], name))
174172
)
175173

176174
# Any mirror may provide a public key, used to verify downloaded packages.
177175
for name, mirror in self._iter_mirrors():
178-
public_key = mirror.get("public_key")
176+
public_key = mirror["public_key"]
179177
if public_key is not None:
180178
self._key_files.append((key_store / f"{name}.pub.gpg", self._read_key(public_key, name)))
181179

@@ -197,7 +195,7 @@ def push_to_build_cache(self) -> Optional[str]:
197195
is read-only - fetched from but never pushed to.
198196
"""
199197

200-
if self.buildcache is not None and self.buildcache.get("private_key"):
198+
if self.buildcache is not None and self.buildcache["private_key"] is not None:
201199
return self.buildcache["name"]
202200
return None
203201

@@ -307,7 +305,7 @@ def config_files(self, config_root: pathlib.Path) -> Dict[pathlib.Path, bytes]:
307305
url = url.rstrip("/") + "/" + self._mount_path.as_posix().lstrip("/")
308306
entry = {"fetch": {"url": url}}
309307
# only a build cache with a signing key is pushed to
310-
if self.buildcache.get("private_key"):
308+
if self.buildcache["private_key"] is not None:
311309
entry["push"] = {"url": url}
312310
spack_mirrors["mirrors"][self.buildcache["name"]] = entry
313311

stackinator/schema/mirror.json

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@
55
"bootstrap": {
66
"type": "object",
77
"properties": {
8-
"description": {"type": "string"},
8+
"description": {"type": "string", "default": ""},
99
"url": {"type": "string"},
10-
"public_key": {"type": "string"}
10+
"public_key": {"type": ["string", "null"], "default": null}
1111
},
1212
"additionalProperties": false,
1313
"required": ["url"]
@@ -19,10 +19,10 @@
1919
"type": "string",
2020
"default": "buildcache"
2121
},
22-
"description": {"type": "string"},
22+
"description": {"type": "string", "default": ""},
2323
"url": {"type": "string"},
24-
"public_key": {"type": "string"},
25-
"private_key": {"type": "string"},
24+
"public_key": {"type": ["string", "null"], "default": null},
25+
"private_key": {"type": ["string", "null"], "default": null},
2626
"mount_specific": {
2727
"type": "boolean",
2828
"default": false
@@ -33,16 +33,17 @@
3333
}
3434
},
3535
"additionalProperties": false,
36-
"required": ["url", "private_key"]
36+
"required": ["url"]
3737
},
3838
"sourcemirror": {
3939
"type": "object",
40+
"default": {},
4041
"additionalProperties": {
4142
"type": "object",
4243
"properties": {
43-
"description": {"type": "string"},
44+
"description": {"type": "string", "default": ""},
4445
"url": {"type": "string"},
45-
"public_key": {"type": "string"}
46+
"public_key": {"type": ["string", "null"], "default": null}
4647
},
4748
"additionalProperties": false,
4849
"required": ["url"]
@@ -51,7 +52,7 @@
5152
"sourcecache": {
5253
"type": "object",
5354
"properties": {
54-
"description": {"type": "string"},
55+
"description": {"type": "string", "default": ""},
5556
"path": {"type": "string"}
5657
},
5758
"additionalProperties": false,
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
buildcache:
2+
url: https://mirror.spack.io

unittests/test_mirrors.py

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,25 +27,31 @@ def test_mirror_init(systems_path, mount_path):
2727
with (systems_path / "../test-gpg-pub.asc").open("rb") as pub_key_file:
2828
pub_key_b64 = base64.b64encode(pub_key_file.read()).decode()
2929

30-
# the build cache, with its schema-defaulted name and flags
30+
# the build cache, with its schema-defaulted name, flags and (empty) fields
3131
assert mirrors_obj.buildcache == {
3232
"name": "buildcache",
33+
"description": "",
3334
"url": "https://mirror.spack.io",
3435
"private_key": "../../test-gpg-priv.asc",
3536
"public_key": pub_key_b64,
3637
"mount_specific": False,
3738
"cmdline": False,
3839
}
3940

40-
assert mirrors_obj.bootstrap == {"url": "https://mirror.spack.io"}
41+
assert mirrors_obj.bootstrap == {
42+
"url": "https://mirror.spack.io",
43+
"description": "",
44+
"public_key": None,
45+
}
4146

47+
# non-required fields are always present, defaulted to "" / None by the schema
4248
assert mirrors_obj.source_mirrors == {
43-
"mirror1": {"url": "https://github.com", "public_key": "../../test-gpg-pub.asc"},
44-
"mirror2": {"url": "https://github.com/spack"},
49+
"mirror1": {"url": "https://github.com", "public_key": "../../test-gpg-pub.asc", "description": ""},
50+
"mirror2": {"url": "https://github.com/spack", "public_key": None, "description": ""},
4551
}
4652

4753
# the writable, populate-as-you-go source cache
48-
assert mirrors_obj.source_cache == {"path": "/scratch/spack-sources"}
54+
assert mirrors_obj.source_cache == {"path": "/scratch/spack-sources", "description": ""}
4955

5056
# the build cache mirror name is derived from the build cache's 'name' field
5157
assert mirrors_obj.build_cache_mirror == "buildcache"
@@ -92,7 +98,7 @@ def test_keyless_command_line_cache(tmp_path, systems_path, mount_path):
9298

9399
# the cache exists (so it is fetched from), but has no signing key ...
94100
assert mirrors.build_cache_mirror == "buildcache"
95-
assert "private_key" not in mirrors.buildcache
101+
assert mirrors.buildcache["private_key"] is None
96102

97103
# ... so it is never pushed to
98104
assert mirrors.push_to_build_cache is None
@@ -107,6 +113,28 @@ def test_keyless_command_line_cache(tmp_path, systems_path, mount_path):
107113
assert data["mirrors"]["buildcache"] == {"fetch": {"url": "/tmp/foo/user-environment"}}
108114

109115

116+
def test_readonly_buildcache(tmp_path, systems_path, mount_path):
117+
"""A buildcache in mirrors.yaml without a private_key is read-only (fetch-only)."""
118+
119+
mirrors = mirror.Mirrors(systems_path / "mirror-readonly-cache", mount_path)
120+
121+
# the cache exists (so it is fetched from), but has no signing key ...
122+
assert mirrors.build_cache_mirror == "buildcache"
123+
assert mirrors.buildcache["private_key"] is None
124+
125+
# ... so it is never pushed to
126+
assert mirrors.push_to_build_cache is None
127+
128+
files = mirrors.config_files(tmp_path)
129+
130+
# no private key is written
131+
assert tmp_path / "key_store" / "buildcache.priv.gpg" not in files
132+
133+
# the mirror is emitted with a fetch url but no push url
134+
data = yaml.safe_load(files[tmp_path / "mirrors.yaml"])
135+
assert data["mirrors"]["buildcache"] == {"fetch": {"url": "https://mirror.spack.io"}}
136+
137+
110138
def test_config_files(tmp_path, systems_path, mount_path):
111139
"""Check that config_files presents the complete set of mirror config artifacts."""
112140

0 commit comments

Comments
 (0)