Skip to content

Commit f6732f4

Browse files
authored
Merge pull request #158 from basecubedev/fix/appliance-admin-install
Fix/appliance admin install
2 parents 14e4a76 + ee9e052 commit f6732f4

25 files changed

Lines changed: 1080 additions & 116 deletions

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ never replace it.
1010
<!-- gitnexus:start -->
1111
# GitNexus — Code Intelligence
1212

13-
This project is indexed by GitNexus as **ems-solarflow-api-control** (32059 symbols, 77162 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
13+
This project is indexed by GitNexus as **ems-solarflow-api-control** (32385 symbols, 77785 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
1414

1515
> Index stale? Run `node .gitnexus/run.cjs analyze` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? `npx gitnexus analyze` (npm 11 crash → `npm i -g gitnexus`; #1939).
1616

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,7 @@ Update the relevant doc when changing behavior described there.
309309
<!-- gitnexus:start -->
310310
# GitNexus — Code Intelligence
311311

312-
This project is indexed by GitNexus as **ems-solarflow-api-control** (32059 symbols, 77162 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
312+
This project is indexed by GitNexus as **ems-solarflow-api-control** (32385 symbols, 77785 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
313313

314314
> Index stale? Run `node .gitnexus/run.cjs analyze` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? `npx gitnexus analyze` (npm 11 crash → `npm i -g gitnexus`; #1939).
315315

appliance/admin_bootstrap.py

Lines changed: 58 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@
2020
import os
2121
import pwd
2222
import subprocess
23+
from pathlib import PurePosixPath
2324

25+
from appliance.auth import lock_path
2426
from appliance.paths import package_helper
2527

2628
INSTALLER_NAME = "install-admin-console.sh"
@@ -39,10 +41,19 @@ def installer_path():
3941
return package_helper(INSTALLER_NAME)
4042

4143

42-
# Written by the appliance before anything is deployed, so its presence is not
44+
# Written by the appliance before anything is deployed, so their presence is not
4345
# evidence that an installation happened. Named exactly: any other file in a
4446
# root-owned deployment root still refuses adoption.
45-
APPLIANCE_SCAFFOLD_FILES = frozenset({"config/dashboard-auth.json"})
47+
#
48+
# The lock is derived rather than spelled, because it is the store's artifact
49+
# and not this module's fact. Listing only the password was enough to make
50+
# setting one the thing that prevented ever installing Admin: the store takes a
51+
# lock beside the record, the lock outlives the write, and the walk counted it
52+
# as somebody else's installation.
53+
SHARED_PASSWORD_FILE = PurePosixPath("config/dashboard-auth.json")
54+
APPLIANCE_SCAFFOLD_FILES = frozenset(
55+
{SHARED_PASSWORD_FILE.as_posix(), lock_path(SHARED_PASSWORD_FILE).as_posix()}
56+
)
4657

4758

4859
class DeploymentBootstrap:
@@ -102,15 +113,23 @@ def identity(self, *, claim=False):
102113
if entry.st_uid != 0:
103114
return (entry.st_uid, entry.st_gid)
104115

105-
directories = self._unclaimed_directories(root)
106-
if directories is None:
107-
raise BootstrapError(
108-
"deployment_root_root_owned",
109-
f"{root} is owned by root and is not an untouched deployment root — it "
110-
"holds files, or something below it cannot be read; give it a non-root "
111-
"owner before installing Admin from here",
112-
)
113-
return self._adopt(root, directories, claim=claim)
116+
return self._adopt(root, self._unclaimed_directories(root), claim=claim)
117+
118+
@staticmethod
119+
def _not_untouched(root, detail):
120+
"""The refusal, naming the one thing that produced it.
121+
122+
It used to list the possible causes instead -- "it holds files, or
123+
something below it cannot be read" -- which leaves an operator with
124+
three hypotheses, no path, and a walk that knew the answer and threw it
125+
away. The code stays: what changed is that the message can be acted on.
126+
"""
127+
128+
return BootstrapError(
129+
"deployment_root_root_owned",
130+
f"{root} is owned by root and is not an untouched deployment root: "
131+
f"{detail}; give it a non-root owner before installing Admin from here",
132+
)
114133

115134
@staticmethod
116135
def _unclaimed_directories(root):
@@ -120,16 +139,17 @@ def _unclaimed_directories(root):
120139
ever installed, so an empty-directory test on the root alone would
121140
refuse a perfectly fresh appliance. What no installation can be without
122141
is a file: a compose file, an environment file or a configuration. The
123-
first one found ends the walk and the answer is no — as does anything
142+
first one found ends the walk and refuses, naming it — as does anything
124143
that cannot be read or is not a plain directory, because a root this
125144
cannot see all of is not one to take over.
126145
127-
One file is scaffolding rather than an installation: the password the
128-
appliance, the Admin console and the dashboard share. The appliance
129-
writes it on first boot, before anything is deployed, so treating it as
130-
evidence of an installation would make setting a password the thing that
131-
prevents ever installing Admin. It is named exactly, not tolerated as a
132-
class, and it is handed over with the directories.
146+
Two files are scaffolding rather than an installation: the password the
147+
appliance, the Admin console and the dashboard share, and the lock its
148+
store holds while writing it. The appliance writes both on first boot,
149+
before anything is deployed, so treating either as evidence of an
150+
installation makes setting a password the thing that prevents ever
151+
installing Admin. They are named exactly, not tolerated as a class, and
152+
they are handed over with the directories.
133153
"""
134154

135155
claimed = [root]
@@ -138,21 +158,35 @@ def _unclaimed_directories(root):
138158
current = pending.pop()
139159
try:
140160
entries = list(current.iterdir())
141-
except OSError:
142-
return None
161+
except OSError as exc:
162+
raise DeploymentBootstrap._not_untouched(
163+
root, f"{current} could not be read ({exc.__class__.__name__})"
164+
) from exc
143165
for entry in entries:
144166
if entry.is_dir() and not entry.is_symlink():
145167
claimed.append(entry)
146168
pending.append(entry)
147169
continue
148-
if entry.is_symlink() or not entry.is_file():
149-
return None
170+
if entry.is_symlink():
171+
raise DeploymentBootstrap._not_untouched(
172+
root, f"{entry} is a symbolic link"
173+
)
174+
if not entry.is_file():
175+
raise DeploymentBootstrap._not_untouched(
176+
root, f"{entry} is neither a regular file nor a directory"
177+
)
150178
try:
151179
relative = entry.relative_to(root).as_posix()
152180
except ValueError:
153-
return None
181+
raise DeploymentBootstrap._not_untouched(
182+
root, f"{entry} is not inside the deployment root"
183+
) from None
154184
if relative not in APPLIANCE_SCAFFOLD_FILES:
155-
return None
185+
raise DeploymentBootstrap._not_untouched(
186+
root,
187+
f"{entry} is a file this appliance did not put there "
188+
f"(only {', '.join(sorted(APPLIANCE_SCAFFOLD_FILES))} is expected)",
189+
)
156190
claimed.append(entry)
157191
return claimed
158192

appliance/auth.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,21 @@
3232
DEFAULT_FAILURE_WINDOW = 300
3333

3434

35+
def lock_path(path):
36+
"""The lock a writer of ``path`` takes, beside the record it guards.
37+
38+
It outlives the writer on purpose -- ``flock`` needs a file both processes
39+
can open -- so it is a second artifact every writer of the shared password
40+
leaves behind. Anything that reasons about what is in that directory has to
41+
ask here rather than spell the name a second time: the deployment root's
42+
adoption check did spell it, did not have it, and setting a password became
43+
the thing that prevented ever installing Admin.
44+
"""
45+
46+
path = Path(path)
47+
return path.with_name(f".{path.name}.lock")
48+
49+
3550
class AuthError(Exception):
3651
def __init__(self, code, message):
3752
super().__init__(message)
@@ -159,7 +174,7 @@ def _locked(self):
159174
"""
160175

161176
self.path.parent.mkdir(parents=True, exist_ok=True)
162-
lock = self.path.with_name(f".{self.path.name}.lock")
177+
lock = lock_path(self.path)
163178
handle = os.open(str(lock), os.O_WRONLY | os.O_CREAT, 0o600)
164179
try:
165180
fcntl.flock(handle, fcntl.LOCK_EX)

appliance/config.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,10 @@ def load_allowed_images(path):
185185
repositories = []
186186
expected_source = DEFAULT_IMAGE_SOURCE
187187
legacy = []
188-
allow_prerelease = False
188+
# From the dataclass rather than a literal: a file that omits the directive
189+
# and a file that is not there at all must resolve to the same policy, and
190+
# two literals is how they come to disagree.
191+
allow_prerelease = AllowedImages.allow_prerelease
189192

190193
try:
191194
text = Path(path).read_text(encoding="utf-8")
@@ -205,7 +208,10 @@ def load_allowed_images(path):
205208
elif key == "legacy_exempt_tags":
206209
legacy.extend(item.strip() for item in value.split(",") if item.strip())
207210
elif key == "allow_prerelease":
208-
allow_prerelease = value.lower() in ("1", "true", "yes", "on")
211+
# Through the same parser every other boolean uses: reading a
212+
# typo as "false" would silently re-disable release candidates
213+
# on a host whose operator wrote that they are allowed.
214+
allow_prerelease = _as_bool({key: value}, key, allow_prerelease)
209215
else:
210216
raise ConfigError("config_value_invalid", f"unknown image directive {key!r}")
211217
continue

appliance/registry_tags.py

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
# SPDX-License-Identifier: AGPL-3.0-or-later
2+
"""The tags a container repository publishes, read from the registry itself.
3+
4+
The registry is the authority on which Admin versions an appliance can install.
5+
A git tag is not an image -- this project tagged twenty releases before it built
6+
an Admin image for the first one -- and a hand-written index is a third list
7+
that can disagree with both. So the list an operator picks from is asked of the
8+
place the image is pulled from anyway.
9+
10+
Nothing here is trusted: a tag is a candidate until ``validate_release_tag``
11+
accepts it, and the install path still verifies the pulled image's OCI labels.
12+
"""
13+
14+
import json
15+
import re
16+
import time
17+
import urllib.error
18+
import urllib.parse
19+
import urllib.request
20+
21+
DEFAULT_TIMEOUT = 10
22+
MAX_TAGS_BYTES = 512 * 1024
23+
MAX_PAGES = 10
24+
PAGE_SIZE = 100
25+
DEFAULT_REGISTRY = "registry-1.docker.io"
26+
27+
_CHALLENGE_PARAMETER = re.compile(r'(\w+)="([^"]*)"')
28+
_NEXT_LINK = re.compile(r'<([^>]+)>\s*;\s*rel="?next"?')
29+
30+
31+
class RegistryError(Exception):
32+
def __init__(self, code, message):
33+
super().__init__(message)
34+
self.code = code
35+
self.message = message
36+
37+
38+
class _Budget:
39+
"""One wall-clock budget for the whole lookup.
40+
41+
A per-request timeout bounds nothing here: a challenge, its token exchange
42+
and ten pages are eleven requests, and the operator's request would sit
43+
behind all of them long past the agent's own operation timeout.
44+
"""
45+
46+
def __init__(self, seconds, clock):
47+
self._clock = clock
48+
self._deadline = clock() + max(float(seconds), 0.0)
49+
50+
def remaining(self):
51+
left = self._deadline - self._clock()
52+
if left <= 0:
53+
raise RegistryError(
54+
"release_registry_unreachable", "the registry did not answer in time"
55+
)
56+
return left
57+
58+
59+
def split_repository(repository):
60+
"""The registry host and the repository path a reference names."""
61+
62+
text = str(repository or "").strip().strip("/")
63+
if not text:
64+
raise RegistryError("release_registry_invalid", "no image repository is configured")
65+
head, _, rest = text.partition("/")
66+
if rest and ("." in head or ":" in head or head == "localhost"):
67+
return head, rest
68+
return DEFAULT_REGISTRY, text if rest else f"library/{text}"
69+
70+
71+
def _open(opener, url, headers, budget):
72+
if urllib.parse.urlsplit(url).scheme != "https":
73+
raise RegistryError(
74+
"release_registry_unreachable", "only an https registry endpoint is read"
75+
)
76+
request = urllib.request.Request(url, headers=headers)
77+
try:
78+
return opener(request, timeout=budget.remaining())
79+
except urllib.error.HTTPError:
80+
raise
81+
except (urllib.error.URLError, OSError, ValueError) as exc:
82+
raise RegistryError(
83+
"release_registry_unreachable",
84+
f"the registry is unreachable: {exc.__class__.__name__}",
85+
) from exc
86+
87+
88+
def _read(response):
89+
payload = response.read(MAX_TAGS_BYTES + 1)
90+
if len(payload) > MAX_TAGS_BYTES:
91+
raise RegistryError(
92+
"release_registry_invalid",
93+
f"the registry sends more than the {MAX_TAGS_BYTES} bytes this appliance reads",
94+
)
95+
try:
96+
return json.loads(payload.decode("utf-8", errors="replace"))
97+
except ValueError as exc:
98+
raise RegistryError("release_registry_invalid", "the registry answer is not JSON") from exc
99+
100+
101+
def _token(opener, challenge, budget):
102+
"""An anonymous pull token, from the realm the registry's challenge names.
103+
104+
The realm is a URL the registry chose, so it is held to https like every
105+
other endpoint here. No credentials are sent to it; there are none to send.
106+
"""
107+
108+
parameters = dict(_CHALLENGE_PARAMETER.findall(challenge or ""))
109+
realm = parameters.pop("realm", "")
110+
if not realm:
111+
raise RegistryError(
112+
"release_registry_unreachable", "the registry challenge names no token realm"
113+
)
114+
query = urllib.parse.urlencode(
115+
{key: value for key, value in parameters.items() if key in ("service", "scope")}
116+
)
117+
payload, _ = _page(opener, f"{realm}?{query}" if query else realm, {}, budget)
118+
token = ""
119+
if isinstance(payload, dict):
120+
token = payload.get("token") or payload.get("access_token") or ""
121+
if not token:
122+
raise RegistryError("release_registry_invalid", "the token endpoint returned no token")
123+
return str(token)
124+
125+
126+
def _page(opener, url, headers, budget):
127+
try:
128+
with _open(opener, url, headers, budget) as response:
129+
return _read(response), response.headers.get("Link", "")
130+
except urllib.error.HTTPError as exc:
131+
raise RegistryError(
132+
"release_registry_unreachable", f"the registry answered HTTP {exc.code}"
133+
) from exc
134+
135+
136+
def list_tags(repository, *, opener=None, timeout=DEFAULT_TIMEOUT, clock=time.monotonic):
137+
"""Every tag the repository publishes, in the order the registry lists them."""
138+
139+
opener = opener or urllib.request.urlopen
140+
budget = _Budget(timeout, clock)
141+
host, path = split_repository(repository)
142+
base = f"https://{host}"
143+
url = f"{base}/v2/{path}/tags/list?n={PAGE_SIZE}"
144+
headers = {"Accept": "application/json"}
145+
146+
try:
147+
with _open(opener, url, headers, budget) as response:
148+
payload, link = _read(response), response.headers.get("Link", "")
149+
except urllib.error.HTTPError as exc:
150+
if exc.code != 401:
151+
raise RegistryError(
152+
"release_registry_unreachable", f"the registry answered HTTP {exc.code}"
153+
) from exc
154+
token = _token(opener, exc.headers.get("WWW-Authenticate", ""), budget)
155+
headers["Authorization"] = f"Bearer {token}"
156+
payload, link = _page(opener, url, headers, budget)
157+
158+
tags = []
159+
pages = 1
160+
while True:
161+
listed = payload.get("tags") if isinstance(payload, dict) else None
162+
tags.extend(item for item in (listed or []) if isinstance(item, str))
163+
match = _NEXT_LINK.search(link or "")
164+
if not match or pages >= MAX_PAGES:
165+
break
166+
payload, link = _page(opener, urllib.parse.urljoin(base, match.group(1)), headers, budget)
167+
pages += 1
168+
return tags

0 commit comments

Comments
 (0)