Skip to content

Commit af8c016

Browse files
author
ci-localpibox
committed
fix(lpb): never resolve to the unpublishable bare :cli/:web tags
Fresh installs failed with 'manifest unknown': with no pinned last-version, lpb fell back to ghcr.io/lpb-stack/devstack:cli|:web — tags CI has never published (it publishes versioned 0.0.x-lpb[-dev]-* plus the :dev-*, :main-*, :latest-* floating tags). - resolve_*_image: no pin → dev pipeline (remote dev VERSION, offline fallback the floating :dev-{cli,web} tags) instead of dead :cli/:web - --update (no tag): update the default (dev) pipeline like a bare lpb run, always pulling cli+web; the old local-check against :cli/:web produced 'No devstack images found locally' even with dev images local - --update save loop: strip the -cli/-web suffix BEFORE matching the version pattern (old order never matched → last-version never pinned) - --version: read in-repo VERSION, then installed CONFIG_DIR/VERSION, cwd chain, remote — instead of printing LPB_PI_REF ('lpb-dev') - install.sh: install the VERSION file; self_update keeps it in sync - lpb.stack.env: LPB_IMAGE_CLI/WEB fallbacks → :dev-cli/:dev-web (real floating tags); README fork docs follow suit - tests: new coverage for default/offline/pinned resolution, --update version pinning, VERSION sync; fix test_run_shell which asserted the old broken no-tag == CLI_IMAGE behavior
1 parent 79d8060 commit af8c016

5 files changed

Lines changed: 249 additions & 118 deletions

File tree

‎README.md‎

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -282,8 +282,8 @@ LocalPibox originals.
282282
export LPB_PI_REF=main # your branch
283283
export LPB_CONFIG_FORK=https://github.com/<you>/config.git
284284
export LPB_CONFIG_REF=main # your branch
285-
export LPB_IMAGE_CLI=ghcr.io/<you>/devstack:cli
286-
export LPB_IMAGE_WEB=ghcr.io/<you>/devstack:web
285+
export LPB_IMAGE_CLI=ghcr.io/<you>/devstack:dev-cli
286+
export LPB_IMAGE_WEB=ghcr.io/<you>/devstack:dev-web
287287
export LPB_CONTAINER_NAME=mybox # avoid colliding with lpb-stack
288288
```
289289

@@ -293,10 +293,9 @@ LocalPibox originals.
293293
which reads `lpb.stack.env`).
294294
4. Install/run `lpb` — it reads the same `lpb.stack.env` for image/container
295295
names, so it picks up your fork automatically.
296-
5. Point the launcher at your image
297-
`~/.lpb-stack/devstack/config` → `export
298-
LPB_IMAGE_NAME="ghcr.io/<you>/devstack:latest"`, or let the forked
299-
`lpb` handle it.
296+
5. Pin a specific image with `~/.lpb-stack/devstack/config` → `export
297+
LPB_IMAGE_TAG="0.0.1-lpb-dev"` (a version tag on your fork's registry),
298+
or let the forked `lpb` resolve the latest for you.
300299

301300
### Repointing the config preset without a rebuild
302301

‎lpb.stack.env‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,11 @@ LPB_CONFIG_FORK=https://github.com/lpb-stack/config.git
2626
LPB_CONFIG_REF=dev
2727

2828
# ─── Docker Images ──────────────────────────────────────────────────────
29-
LPB_IMAGE_CLI=ghcr.io/lpb-stack/devstack:cli
30-
LPB_IMAGE_WEB=ghcr.io/lpb-stack/devstack:web
29+
# Last-resort fallback tags: used only when no version is pinned and the
30+
# remote VERSION is unreachable. These are the floating dev-pipeline tags
31+
# that CI always publishes — the bare :cli/:web tags do NOT exist.
32+
LPB_IMAGE_CLI=ghcr.io/lpb-stack/devstack:dev-cli
33+
LPB_IMAGE_WEB=ghcr.io/lpb-stack/devstack:dev-web
3134

3235
# ─── GHCR Token ───────────────────────────────────────────────────────
3336
# Read-only PAT for image pulls (baked into image for public access).

‎scripts/install.sh‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ chmod +x "${INSTALL_DIR}/lpb.py"
3838
# stack/repo defaults (a fork's lpb.py loads these via CONFIG_DIR fallback).
3939
curl -fsSL "https://raw.githubusercontent.com/${OWNER_REPO}/main/lpb.stack.env" -o "${CONFIG_DIR}/lpb.stack.env"
4040
curl -fsSL "https://raw.githubusercontent.com/${OWNER_REPO}/main/lpb.conf.env" -o "${CONFIG_DIR}/lpb.conf.env"
41+
# Installed VERSION file (source for `lpb --version`; refreshed by self-update)
42+
curl -fsSL "https://raw.githubusercontent.com/${OWNER_REPO}/main/VERSION" -o "${CONFIG_DIR}/VERSION"
4143

4244
# 4. Add to PATH if needed (warn only, don't modify shell configs)
4345
case ":${PATH}:" in

‎scripts/lpb.py‎

Lines changed: 124 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -176,10 +176,15 @@ def _get_remote_version(branch: str = "dev") -> str:
176176
return ""
177177

178178

179+
# Versioned image tag pattern: 0.0.x-lpb (stable) or 0.0.x-lpb-dev (dev pipeline).
180+
# Floating registry tags (dev-cli, main-cli, latest-cli, ...) deliberately
181+
# do NOT match — they are moving pointers, not versions to pin.
182+
_VERSION_TAG_RE = re.compile(r'^0\.0\.\d+-lpb(-dev)?$')
183+
179184
def _save_version(version: str) -> None:
180185
"""Persist the version tag for reconnection/update (only valid versioned tags)."""
181186
# Only persist tags that match the version pattern (0.0.x-lpb[-dev]), not legacy :cli/:web
182-
if not re.match(r'^0\.0\.\d+-lpb(-dev)?$', version):
187+
if not _VERSION_TAG_RE.match(version):
183188
return
184189
LAST_VERSION_FILE.parent.mkdir(parents=True, exist_ok=True)
185190
with open(LAST_VERSION_FILE, "w") as f:
@@ -204,68 +209,69 @@ def _resolve_version_image(version: str, mode: str) -> str:
204209
return f"ghcr.io/lpb-stack/devstack:{version}-{mode}"
205210

206211

212+
def _resolve_tagged_image(tag: str, mode: str) -> str:
213+
"""Resolve the image for an explicit pipeline/version tag.
214+
215+
dev → latest dev-pipeline version (0.0.x-lpb-dev-{mode})
216+
main / latest → latest main-pipeline version (0.0.x-lpb-{mode})
217+
<version> → that exact version (0.0.x-lpb[-dev]-{mode})
218+
219+
When the remote VERSION is unreachable (offline) the cached
220+
last-version is used; if that is missing too, the floating registry
221+
tag (:dev-{mode} / :main-{mode} / :latest-{mode}) is returned. The
222+
bare :{mode} tag is never used — CI does not publish it, so pulling
223+
it always fails with "manifest unknown".
224+
"""
225+
if tag == "dev":
226+
version = _get_remote_version("dev") or _load_last_version()
227+
if _VERSION_TAG_RE.match(version):
228+
if not version.endswith("-dev"):
229+
version += "-dev" # cached stable pin → dev counterpart
230+
else:
231+
version = "dev" # floating tag — real, always pullable
232+
return _resolve_version_image(version, mode)
233+
234+
if tag in ("main", "latest"):
235+
version = _get_remote_version("main") or _load_last_version()
236+
if _VERSION_TAG_RE.match(version):
237+
version = version.replace("-dev", "")
238+
else:
239+
version = "latest" if tag == "latest" else "main" # floating tag
240+
return _resolve_version_image(version, mode)
241+
242+
# Custom/explicit version tag (e.g. 0.0.52-lpb-dev)
243+
return _resolve_version_image(tag, mode)
244+
245+
207246
def resolve_cli_image(tag: str) -> str:
208247
"""Resolve the final CLI image name from stack config + tag override.
209-
248+
249+
no tag → pinned last-version if any, else the dev pipeline
210250
--tag dev/main → latest versioned image (0.0.x-lpb[-dev]-cli)
211251
--tag <custom> → :<custom>-cli (explicit version)
212252
"""
213253
if not tag:
214254
last = _load_last_version()
215255
if last:
216256
return _resolve_version_image(last, "cli")
217-
return CLI_IMAGE # fallback
218-
219-
if tag == "dev":
220-
# Always try remote first for dev/main tags (get latest)
221-
version = _get_remote_version("dev")
222-
if not version:
223-
version = _load_last_version() # fallback to cached
224-
if not version:
225-
version = "0.0.0-lpb" # last resort
226-
return _resolve_version_image(version if "-dev" in version else version + "-dev", "cli")
227-
228-
if tag == "main" or tag == "latest":
229-
# Always try remote first for dev/main tags (get latest)
230-
version = _get_remote_version("main")
231-
if not version:
232-
version = _load_last_version() # fallback to cached
233-
if not version:
234-
version = "0.0.0-lpb" # last resort
235-
return _resolve_version_image(version.replace("-dev", ""), "cli")
236-
237-
# Custom/explicit version tag
238-
return _resolve_version_image(tag, "cli")
257+
# No pinned version yet — default to the dev pipeline (the active
258+
# mainline). Never fall back to the legacy bare :cli tag: CI only
259+
# publishes versioned tags plus :dev-*, :main-*, :latest-* floats.
260+
return _resolve_tagged_image("dev", "cli")
261+
return _resolve_tagged_image(tag, "cli")
239262

240263

241264
def resolve_web_image(tag: str) -> str:
242-
"""Resolve the final WEB image name from stack config + tag override."""
265+
"""Resolve the final WEB image name from stack config + tag override.
266+
267+
Same resolution rules as resolve_cli_image, for the -web image.
268+
"""
243269
if not tag:
244270
last = _load_last_version()
245271
if last:
246272
return _resolve_version_image(last, "web")
247-
return WEB_IMAGE
248-
249-
if tag == "dev":
250-
# Always try remote first for dev/main tags (get latest)
251-
version = _get_remote_version("dev")
252-
if not version:
253-
version = _load_last_version() # fallback to cached
254-
if not version:
255-
version = "0.0.0-lpb" # last resort
256-
return _resolve_version_image(version if "-dev" in version else version + "-dev", "web")
257-
258-
if tag == "main" or tag == "latest":
259-
# Always try remote first for dev/main tags (get latest)
260-
version = _get_remote_version("main")
261-
if not version:
262-
version = _load_last_version() # fallback to cached
263-
if not version:
264-
version = "0.0.0-lpb" # last resort
265-
return _resolve_version_image(version.replace("-dev", ""), "web")
266-
267-
# Custom/explicit version tag
268-
return _resolve_version_image(tag, "web")
273+
return _resolve_tagged_image("dev", "web")
274+
return _resolve_tagged_image(tag, "web")
269275

270276

271277
# ─── Load runtime configuration ──────────────────────────────────────────
@@ -371,6 +377,17 @@ def self_update() -> None:
371377
# Wrapper (optional — only present in install.sh installs)
372378
if wrapper_path.is_file():
373379
_fetch_file(base_url + "lpb", wrapper_path, staging)
380+
# Keep the installed VERSION file in sync (best effort —
381+
# `lpb --version` reads it; only present in install.sh installs)
382+
version_dest = CONFIG_DIR / "VERSION"
383+
if version_dest.parent.is_dir():
384+
version_url = f"https://raw.githubusercontent.com/lpb-stack/devstack/{branch}/VERSION"
385+
with urllib.request.urlopen(version_url, timeout=10) as resp:
386+
new_v = resp.read().decode().strip()
387+
old_v = version_dest.read_text().strip() if version_dest.is_file() else ""
388+
if new_v and new_v != old_v:
389+
version_dest.write_text(new_v + "\n")
390+
info(f"Updating {version_dest.name}...")
374391
except Exception as exc:
375392
warn(f"self-update skipped: {exc}")
376393
finally:
@@ -978,92 +995,90 @@ def cmd_config():
978995

979996

980997
def cmd_version():
981-
"""Show the stack version from the VERSION file and exit."""
982-
# Search for devstack dir (has both VERSION and lpb.stack.env)
983-
for candidate in (Path(__file__).parent.parent, Path.cwd()):
984-
vf = candidate / "VERSION"
998+
"""Show the stack version and exit.
999+
1000+
Resolution order:
1001+
1. Repo checkout this script lives in (in-tree runs)
1002+
2. Installed VERSION (next to the installed lpb.stack.env)
1003+
3. A VERSION file up the current directory chain
1004+
4. Remote VERSION (dev branch, then main)
1005+
"""
1006+
# 1. In-tree: scripts/lpb.py inside a devstack checkout
1007+
repo = Path(__file__).resolve().parent.parent
1008+
if (repo / "lpb.stack.env").is_file():
1009+
vf = repo / "VERSION"
9851010
if vf.is_file():
986-
version = vf.read_text().strip()
987-
print(f"LocalPibox stack {version}")
1011+
print(f"LocalPibox stack {vf.read_text().strip()}")
9881012
return
989-
# Check parent dirs
990-
p = candidate
991-
for _ in range(10):
992-
p = p.parent
993-
vf = p / "VERSION"
994-
if vf.is_file():
995-
version = vf.read_text().strip()
996-
print(f"LocalPibox stack {version}")
997-
return
998-
if str(p) == "/":
999-
break
10001013

1001-
# Fallback: read from lpb.stack.env
1014+
# 2. Installed layout: VERSION installed by install.sh / self_update
10021015
stack_env = _find_env_file("lpb.stack.env")
1003-
if stack_env and stack_env.is_file():
1004-
cfg_env = _parse_env_file(stack_env)
1005-
version = cfg_env.get("LPB_PI_REF", "unknown")
1006-
print(f"LocalPibox stack {version}")
1007-
return
1016+
if stack_env:
1017+
vf = stack_env.parent / "VERSION"
1018+
if vf.is_file():
1019+
print(f"LocalPibox stack {vf.read_text().strip()}")
1020+
return
10081021

1009-
print("unknown")
1022+
# 3. Up the cwd chain (running from inside a checkout)
1023+
p = Path.cwd()
1024+
for _ in range(10):
1025+
p = p.parent
1026+
vf = p / "VERSION"
1027+
if vf.is_file():
1028+
print(f"LocalPibox stack {vf.read_text().strip()}")
1029+
return
1030+
if str(p) == "/":
1031+
break
1032+
1033+
# 4. Remote (dev branch is the active mainline)
1034+
version = _get_remote_version("dev") or _get_remote_version("main")
1035+
print(f"LocalPibox stack {version}" if version else "unknown")
10101036
sys.exit(0)
10111037

10121038

10131039
def cmd_update():
1014-
"""Self-update the launcher and pull the latest devstack image(s)."""
1040+
"""Self-update the launcher and pull the latest devstack image(s).
1041+
1042+
No tag → updates the default pipeline (dev) — the same images a
1043+
bare `lpb` run would pull. With a tag (dev/main/latest/<version>) →
1044+
updates that pipeline/version. The pinned last-version is refreshed
1045+
so a bare `lpb` reconnects to the updated image.
1046+
"""
10151047
ensure_container_cmd()
10161048
c = client()
10171049

10181050
# Self-update
10191051
self_update()
10201052

1021-
# Determine which image(s) to pull
1022-
images_to_update = []
1023-
1024-
if cfg.image_tag:
1025-
# Resolve versioned image from tag (dev/main/latest/custom)
1026-
cli_img = resolve_cli_image(cfg.image_tag)
1027-
web_img = resolve_web_image(cfg.image_tag)
1028-
# For versioned tags (dev/main), always pull even if not local
1029-
if cfg.image_tag in ("dev", "main"):
1030-
images_to_update = [cli_img, web_img]
1031-
elif c.images_exists(cli_img):
1032-
images_to_update.append(cli_img)
1033-
if c.images_exists(web_img):
1034-
images_to_update.append(web_img)
1035-
else:
1036-
# Custom version not local — fall through to defaults
1037-
pass
1038-
1039-
if not images_to_update:
1040-
# Fall back to default images
1041-
for img in [CLI_IMAGE, WEB_IMAGE]:
1042-
if c.images_exists(img):
1043-
images_to_update.append(img)
1044-
1045-
if not images_to_update:
1046-
err("No devstack images found locally",
1047-
"Run 'lpb' or 'lpb --web' first to pull an image.")
1048-
raise DevstackError
1053+
# No tag = default pipeline (dev), same as a bare `lpb` run.
1054+
# This is an explicit update request - always pull both images.
1055+
tag = cfg.image_tag or "dev"
1056+
images_to_update = [resolve_cli_image(tag), resolve_web_image(tag)]
10491057

1058+
pulled_ok = []
10501059
for img in images_to_update:
10511060
info(f"Pulling {img}...")
1052-
rc = c.images_pull(img)
1053-
if rc != 0:
1061+
if c.images_pull(img) == 0:
1062+
pulled_ok.append(img)
1063+
else:
10541064
err(f"Failed to pull {img}")
10551065

1056-
# Save last version for next run
1057-
for img in images_to_update:
1058-
last = img.rsplit(":", 1)[-1]
1059-
if not re.match(r'^0\.0\.\d+-lpb(-dev)?$', last):
1060-
continue
1061-
_save_version(last.replace("-cli", "").replace("-web", ""))
1062-
break
1066+
# Save last version for the next run - only for successfully pulled,
1067+
# versioned images. Strip the -cli/-web suffix BEFORE matching the
1068+
# version pattern (the old order never matched, so --update never
1069+
# pinned a version and a bare `lpb` fell back to the dead :cli tag).
1070+
for img in pulled_ok:
1071+
v = img.rsplit(":", 1)[-1]
1072+
if v.endswith("-cli") or v.endswith("-web"):
1073+
v = v[:-4]
1074+
if _VERSION_TAG_RE.match(v):
1075+
_save_version(v)
1076+
break
10631077

10641078
done("Images up to date.")
10651079

10661080

1081+
10671082
def _get_lan_ips():
10681083
"""Discover non-loopback IPv4 addresses on the host (used to build connect URLs)."""
10691084
ipv4_re = re.compile(r'^(\d+\.\d+\.\d+\.\d+)$')

0 commit comments

Comments
 (0)