Skip to content

Commit 53c8e2e

Browse files
authored
Merge pull request #702 from algorandfoundation/feat/faster-localnet-start-reset
feat: reduce localnet start/reset time
2 parents 04e935e + 0f6183a commit 53c8e2e

18 files changed

+344
-43
lines changed

docs/cli/index.md

Lines changed: 38 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -127,30 +127,34 @@
127127
- [Options](#options-17)
128128
- [--update, --no-update](#--update---no-update)
129129
- [-P, --config-dir ](#-p---config-dir-)
130+
- [--check](#--check)
130131
- [start](#start)
131132
- [Options](#options-18)
132133
- [-n, --name ](#-n---name--1)
133134
- [-P, --config-dir ](#-p---config-dir--1)
134135
- [-d, --dev, --no-dev](#-d---dev---no-dev)
135136
- [--force](#--force)
137+
- [--check](#--check-1)
136138
- [status](#status)
139+
- [Options](#options-19)
140+
- [--check](#--check-2)
137141
- [stop](#stop)
138142
- [project](#project)
139143
- [bootstrap](#bootstrap)
140-
- [Options](#options-19)
141-
- [--force](#--force-1)
142144
- [Options](#options-20)
145+
- [--force](#--force-1)
146+
- [Options](#options-21)
143147
- [--interactive, --no-ci, --non-interactive, --ci](#--interactive---no-ci---non-interactive---ci)
144148
- [-p, --project-name ](#-p---project-name-)
145149
- [-t, --type ](#-t---type-)
146-
- [Options](#options-21)
147-
- [--interactive, --non-interactive, --ci](#--interactive---non-interactive---ci)
148150
- [Options](#options-22)
149-
- [--ci, --no-ci](#--ci---no-ci)
151+
- [--interactive, --non-interactive, --ci](#--interactive---non-interactive---ci)
150152
- [Options](#options-23)
153+
- [--ci, --no-ci](#--ci---no-ci)
154+
- [Options](#options-24)
151155
- [--ci, --no-ci](#--ci---no-ci-1)
152156
- [deploy](#deploy)
153-
- [Options](#options-24)
157+
- [Options](#options-25)
154158
- [-C, -c, --command ](#-c--c---command-)
155159
- [--interactive, --non-interactive, --ci](#--interactive---non-interactive---ci-1)
156160
- [-P, --path ](#-p---path-)
@@ -161,7 +165,7 @@
161165
- [ENVIRONMENT_NAME](#environment_name)
162166
- [EXTRA_ARGS](#extra_args)
163167
- [link](#link)
164-
- [Options](#options-25)
168+
- [Options](#options-26)
165169
- [-p, --project-name ](#-p---project-name--2)
166170
- [-l, --language ](#-l---language--1)
167171
- [-a, --all](#-a---all)
@@ -173,7 +177,7 @@
173177
- [run](#run)
174178
- [task](#task)
175179
- [analyze](#analyze)
176-
- [Options](#options-26)
180+
- [Options](#options-27)
177181
- [-r, --recursive](#-r---recursive)
178182
- [--force](#--force-2)
179183
- [--diff](#--diff)
@@ -182,11 +186,11 @@
182186
- [Arguments](#arguments-15)
183187
- [INPUT_PATHS](#input_paths)
184188
- [ipfs](#ipfs)
185-
- [Options](#options-27)
189+
- [Options](#options-28)
186190
- [-f, --file ](#-f---file--1)
187191
- [-n, --name ](#-n---name--2)
188192
- [mint](#mint)
189-
- [Options](#options-28)
193+
- [Options](#options-29)
190194
- [--creator ](#--creator-)
191195
- [--name ](#--name-)
192196
- [-u, --unit ](#-u---unit-)
@@ -198,45 +202,45 @@
198202
- [--mutable, --immutable](#--mutable---immutable)
199203
- [-n, --network ](#-n---network-)
200204
- [nfd-lookup](#nfd-lookup)
201-
- [Options](#options-29)
205+
- [Options](#options-30)
202206
- [-o, --output ](#-o---output--3)
203207
- [Arguments](#arguments-16)
204208
- [VALUE](#value)
205209
- [opt-in](#opt-in)
206-
- [Options](#options-30)
210+
- [Options](#options-31)
207211
- [-a, --account ](#-a---account-)
208212
- [-n, --network ](#-n---network--1)
209213
- [Arguments](#arguments-17)
210214
- [ASSET_IDS](#asset_ids)
211215
- [opt-out](#opt-out)
212-
- [Options](#options-31)
216+
- [Options](#options-32)
213217
- [-a, --account ](#-a---account--1)
214218
- [--all](#--all)
215219
- [-n, --network ](#-n---network--2)
216220
- [Arguments](#arguments-18)
217221
- [ASSET_IDS](#asset_ids-1)
218222
- [send](#send)
219-
- [Options](#options-32)
223+
- [Options](#options-33)
220224
- [-f, --file ](#-f---file--2)
221225
- [-t, --transaction ](#-t---transaction-)
222226
- [-n, --network ](#-n---network--3)
223227
- [sign](#sign)
224-
- [Options](#options-33)
228+
- [Options](#options-34)
225229
- [-a, --account ](#-a---account--2)
226230
- [-f, --file ](#-f---file--3)
227231
- [-t, --transaction ](#-t---transaction--1)
228232
- [-o, --output ](#-o---output--4)
229233
- [--force](#--force-3)
230234
- [transfer](#transfer)
231-
- [Options](#options-34)
235+
- [Options](#options-35)
232236
- [-s, --sender ](#-s---sender-)
233237
- [-r, --receiver ](#-r---receiver--1)
234238
- [--asset, --id ](#--asset---id-)
235239
- [-a, --amount ](#-a---amount--1)
236240
- [--whole-units](#--whole-units-2)
237241
- [-n, --network ](#-n---network--4)
238242
- [vanity-address](#vanity-address)
239-
- [Options](#options-35)
243+
- [Options](#options-36)
240244
- [-m, --match ](#-m---match-)
241245
- [-o, --output ](#-o---output--5)
242246
- [-a, --alias ](#-a---alias-)
@@ -245,19 +249,19 @@
245249
- [Arguments](#arguments-19)
246250
- [KEYWORD](#keyword)
247251
- [wallet](#wallet)
248-
- [Options](#options-36)
252+
- [Options](#options-37)
249253
- [-a, --address ](#-a---address-)
250254
- [-m, --mnemonic](#-m---mnemonic)
251255
- [-f, --force](#-f---force-4)
252256
- [Arguments](#arguments-20)
253257
- [ALIAS_NAME](#alias_name)
254258
- [Arguments](#arguments-21)
255259
- [ALIAS](#alias)
256-
- [Options](#options-37)
260+
- [Options](#options-38)
257261
- [-f, --force](#-f---force-5)
258262
- [Arguments](#arguments-22)
259263
- [ALIAS](#alias-1)
260-
- [Options](#options-38)
264+
- [Options](#options-39)
261265
- [-f, --force](#-f---force-6)
262266

263267
# algokit
@@ -918,6 +922,10 @@ Enable or disable updating to the latest available LocalNet version, default: do
918922
### -P, --config-dir <config_path>
919923
Specify the custom localnet configuration directory.
920924

925+
926+
### --check
927+
Force check the Docker registry for new LocalNet image versions, ignoring the version check cache.
928+
921929
### start
922930

923931
Start the AlgoKit LocalNet.
@@ -944,6 +952,10 @@ Control whether to launch 'algod' in developer mode or not. Defaults to 'yes'.
944952
### --force
945953
Ignore the prompt to stop the LocalNet if it's already running.
946954

955+
956+
### --check
957+
Force check the Docker registry for new LocalNet image versions, ignoring the version check cache.
958+
947959
### status
948960

949961
Check the status of the AlgoKit LocalNet.
@@ -952,6 +964,12 @@ Check the status of the AlgoKit LocalNet.
952964
algokit localnet status [OPTIONS]
953965
```
954966

967+
### Options
968+
969+
970+
### --check
971+
Force check the Docker registry for new LocalNet image versions, ignoring the version check cache.
972+
955973
### stop
956974

957975
Stop the AlgoKit LocalNet.

pyproject.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,9 @@ suppress-none-returning = true
168168
pythonpath = ["src", "tests"]
169169
markers = [
170170
"mock_platform_system",
171-
"pyinstaller_binary_tests"
171+
"pyinstaller_binary_tests",
172+
"use_real_image_version_cache: opt-out of the auto-mocked image version cache checks"
173+
172174
]
173175
addopts = "-m 'not pyinstaller_binary_tests'" # by default, exclude pyinstaller_binary_tests
174176
[tool.mypy]

src/algokit/cli/localnet.py

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,14 @@
2424

2525
logger = logging.getLogger(__name__)
2626

27+
check_option = click.option(
28+
"check",
29+
"--check",
30+
is_flag=True,
31+
default=False,
32+
help="Force check the Docker registry for new LocalNet image versions, ignoring the version check cache.",
33+
)
34+
2735

2836
@click.group("localnet", short_help="Manage the AlgoKit LocalNet.")
2937
@click.pass_context
@@ -157,7 +165,10 @@ def config_command(*, engine: str | None, force: bool) -> None:
157165
default=False,
158166
help="Ignore the prompt to stop the LocalNet if it's already running.",
159167
)
160-
def start_localnet(*, name: str | None, config_path: Path | None, algod_dev_mode: bool, force: bool) -> None:
168+
@check_option
169+
def start_localnet(
170+
*, name: str | None, config_path: Path | None, algod_dev_mode: bool, force: bool, check: bool
171+
) -> None:
161172
sandbox = ComposeSandbox.from_environment()
162173
full_name = f"{SANDBOX_BASE_NAME}_{name}" if name is not None else SANDBOX_BASE_NAME
163174
if sandbox is not None and full_name != sandbox.name:
@@ -168,7 +179,7 @@ def start_localnet(*, name: str | None, config_path: Path | None, algod_dev_mode
168179
raise click.ClickException("LocalNet is already running. Please stop it first")
169180
sandbox = ComposeSandbox(SANDBOX_BASE_NAME, config_path) if name is None else ComposeSandbox(name, config_path)
170181
compose_file_status = sandbox.compose_file_status()
171-
sandbox.check_docker_compose_for_new_image_versions()
182+
sandbox.check_docker_compose_for_new_image_versions(force=check)
172183
if compose_file_status is ComposeFileStatus.MISSING:
173184
logger.debug("LocalNet compose file does not exist yet; writing it out for the first time")
174185
sandbox.write_compose_file()
@@ -227,7 +238,8 @@ def stop_localnet() -> None:
227238
required=False,
228239
help="Specify the custom localnet configuration directory.",
229240
)
230-
def reset_localnet(*, update: bool, config_path: Path | None) -> None:
241+
@check_option
242+
def reset_localnet(*, update: bool, config_path: Path | None, check: bool) -> None:
231243
sandbox = ComposeSandbox.from_environment()
232244
if sandbox is None:
233245
sandbox = ComposeSandbox(config_path=config_path)
@@ -243,7 +255,7 @@ def reset_localnet(*, update: bool, config_path: Path | None) -> None:
243255
if update:
244256
sandbox.pull()
245257
else:
246-
sandbox.check_docker_compose_for_new_image_versions()
258+
sandbox.check_docker_compose_for_new_image_versions(force=check)
247259
elif update:
248260
if click.confirm(
249261
f"A named LocalNet is running, are you sure you want to reset the LocalNet configuration "
@@ -265,11 +277,14 @@ def reset_localnet(*, update: bool, config_path: Path | None) -> None:
265277

266278

267279
@localnet_group.command("status", short_help="Check the status of the AlgoKit LocalNet.")
268-
def localnet_status() -> None:
280+
@check_option
281+
def localnet_status(*, check: bool) -> None:
269282
sandbox = ComposeSandbox.from_environment()
270283
if sandbox is None:
271284
sandbox = ComposeSandbox()
272285

286+
sandbox.check_docker_compose_for_new_image_versions(force=check)
287+
273288
logger.info("# container engine")
274289
logger.info(
275290
"Name: " + click.style(get_container_engine(), bold=True) + " (change with `algokit config container-engine`)"

src/algokit/core/sandbox.py

Lines changed: 82 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
11
from __future__ import annotations
22

3+
import dataclasses
34
import enum
45
import json
56
import logging
67
import re
78
import time
9+
from datetime import timedelta
810
from pathlib import Path
911
from typing import Any, cast
1012

1113
import httpx
1214

13-
from algokit.core.conf import get_app_config_dir
15+
from algokit.core.conf import get_app_config_dir, get_app_state_dir
1416
from algokit.core.config_commands.container_engine import get_container_engine
1517
from algokit.core.proc import RunResult, run, run_interactive
1618

@@ -219,6 +221,8 @@ def down(self) -> None:
219221
def pull(self) -> None:
220222
logger.info("Fetching any container updates from DockerHub...")
221223
self._run_compose_command("pull --ignore-pull-failures --quiet")
224+
logger.debug("Image version cache reset")
225+
_update_image_version_cache(indexer_outdated=False, algod_outdated=False)
222226

223227
def logs(self, *, follow: bool = False, no_color: bool = False, tail: str | None = None) -> None:
224228
compose_args = ["logs"]
@@ -284,15 +288,28 @@ def is_image_up_to_date(self, image_name: str) -> bool:
284288
latest_version = self._get_latest_image_version(image_name)
285289
return latest_version is None or latest_version in local_versions
286290

287-
def check_docker_compose_for_new_image_versions(self) -> None:
288-
is_indexer_up_to_date = self.is_image_up_to_date(INDEXER_IMAGE)
289-
if is_indexer_up_to_date is False:
291+
def check_docker_compose_for_new_image_versions(self, *, force: bool = False) -> None:
292+
should_check_registry = force or _should_check_image_versions()
293+
294+
if should_check_registry:
295+
# Check Docker registry for new versions
296+
is_indexer_outdated = not self.is_image_up_to_date(INDEXER_IMAGE)
297+
is_algod_outdated = not self.is_image_up_to_date(ALGORAND_IMAGE)
298+
_update_image_version_cache(indexer_outdated=is_indexer_outdated, algod_outdated=is_algod_outdated)
299+
else:
300+
# Use cached state
301+
cached_state = _get_image_version_cache()
302+
if cached_state is None:
303+
return
304+
is_indexer_outdated = cached_state.indexer_outdated
305+
is_algod_outdated = cached_state.algod_outdated
306+
307+
if is_indexer_outdated:
290308
logger.warning(
291309
"indexer has a new version available, run `algokit localnet reset --update` to get the latest version"
292310
)
293311

294-
is_algorand_up_to_date = self.is_image_up_to_date(ALGORAND_IMAGE)
295-
if is_algorand_up_to_date is False:
312+
if is_algod_outdated:
296313
logger.warning(
297314
"algod has a new version available, run `algokit localnet reset --update` to get the latest version"
298315
)
@@ -312,6 +329,65 @@ def check_docker_compose_for_new_image_versions(self) -> None:
312329
INDEXER_IMAGE = "algorand/indexer:latest"
313330
ALGORAND_IMAGE = "algorand/algod:latest"
314331
CONDUIT_IMAGE = "algorand/conduit:latest"
332+
IMAGE_VERSION_CHECK_INTERVAL = timedelta(weeks=1).total_seconds()
333+
334+
335+
@dataclasses.dataclass
336+
class ImageVersionCache:
337+
"""Cache state for image version checks."""
338+
339+
indexer_outdated: bool
340+
algod_outdated: bool
341+
342+
343+
def _get_image_version_cache_path() -> Path:
344+
"""Get the path to the image version check cache file."""
345+
return get_app_state_dir() / "last-localnet-version-check"
346+
347+
348+
def _get_image_version_cache() -> ImageVersionCache | None:
349+
"""Get the cached image version state.
350+
351+
Returns an ImageVersionCache with outdated flags, or None if cache is missing/invalid.
352+
"""
353+
cache_path = _get_image_version_cache_path()
354+
try:
355+
content = cache_path.read_text(encoding="utf-8")
356+
data = json.loads(content)
357+
return ImageVersionCache(
358+
indexer_outdated=data.get("indexer_outdated", False),
359+
algod_outdated=data.get("algod_outdated", False),
360+
)
361+
except (OSError, json.JSONDecodeError):
362+
return None
363+
364+
365+
def _should_check_image_versions() -> bool:
366+
"""Determine if we should check for new image versions based on cache."""
367+
cache_path = _get_image_version_cache_path()
368+
try:
369+
last_checked = cache_path.stat().st_mtime
370+
except OSError:
371+
logger.debug(f"{cache_path} inaccessible, will check for image updates")
372+
return True
373+
374+
elapsed = time.time() - last_checked
375+
if elapsed > IMAGE_VERSION_CHECK_INTERVAL:
376+
logger.debug("Image version cache expired, will check for updates")
377+
return True
378+
379+
logger.debug(f"Skipping image version check, last checked {elapsed / 3600:.1f}h ago")
380+
return False
381+
382+
383+
def _update_image_version_cache(*, indexer_outdated: bool, algod_outdated: bool) -> None:
384+
"""Update the image version check cache with current state."""
385+
cache_path = _get_image_version_cache_path()
386+
try:
387+
cache_data = {"indexer_outdated": indexer_outdated, "algod_outdated": algod_outdated}
388+
cache_path.write_text(json.dumps(cache_data), encoding="utf-8")
389+
except OSError as ex:
390+
logger.debug(f"Failed to update image version cache: {ex}")
315391

316392

317393
def _wait_for_service(

0 commit comments

Comments
 (0)