@@ -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+
179184def _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+
207246def 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
241264def 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
980997def 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
10131039def 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+
10671082def _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