feat(assets): probe-first resolver + SPOT for IMAGE_NAME/PORT + README screenshot

Probe-first asset resolution (regression fix)
---------------------------------------------

cache_manager.cache_file() returned either a relative cache path
(success) or None (failure). The previous app.py fallback
asset['cache'] = cached or asset['source'] mixed both types into
one field, which the template wrapped in url_for('static', ...)
regardless — producing broken
/static/https://file.infinito.nexus/.../logo.png URLs whenever the
source couldn't be downloaded.

- New app/utils/asset_resolver.py: HEAD-probes the URL (3 s
  timeout, image/* content type). On hit, embed directly via a
  new external_url field — no download required. On miss, fall
  back to cache_manager.cache_file. If that also fails, expose
  the source URL via external_url so the browser shows the alt
  text instead of an empty src.
- app.py exposes an asset_src(asset) context processor that
  picks external_url first, then url_for('static', cache),
  so the template never wraps an absolute URL in a static prefix.
- Templates (base, navigation, card) switch to asset_src(...) and
  gate the card image branch on cache or external_url.
- 16 unit tests cover every probe/cache/fallback branch; one live
  integration test exercises the canonical
  https://file.infinito.nexus/assets/img/logo.png to prove the
  probe-first path works end-to-end (cache dir stays empty).
- config.sample.yaml: new Infinito.Nexus card driven by the same
  canonical asset URL.

Single source of truth for IMAGE_NAME and PORT
----------------------------------------------

- env.example is now the only place the literal values live.
- Makefile and docker-compose.yml reference \$(IMAGE_NAME) /
  \${IMAGE_NAME:?…} (same for PORT); no defaults, no silent
  fallbacks.
- New make env / make config bootstrap .env / app/config.yaml
  from their checked-in templates. Idempotent.
- All container-using targets depend on the two bootstrap targets
  so a fresh checkout runs in a single invocation.
- Recipes source .env at recipe-execution time so they pick up a
  freshly bootstrapped .env in the same make invocation.

README
------

- Screenshot added under the title.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-18 12:19:15 +02:00
parent 39a41e561c
commit 3f30621630
14 changed files with 480 additions and 56 deletions

View File

@@ -5,24 +5,61 @@ ifneq (,$(wildcard .env))
export $(shell sed 's/=.*//' .env)
endif
# Default port (can be overridden with PORT env var)
PORT ?= 5000
PYTHON ?= python3
ACT ?= act
# Default port (can be overridden with PORT env var)
# Bootstrap the local .env from the checked-in env.example template.
# Idempotent: leaves an existing .env untouched.
.PHONY: env
env:
@if [ -f .env ]; then \
echo ".env already exists — leaving it alone."; \
else \
cp env.example .env; \
echo "Created .env from env.example — review and adjust."; \
fi
# Bootstrap app/config.yaml from the checked-in app/config.sample.yaml
# template. Idempotent: leaves an existing config.yaml untouched. The
# Dockerfile COPYs the whole app/ directory at build time, so this file
# must exist before `make build` / `make up`.
.PHONY: config
config:
@if [ -f app/config.yaml ]; then \
echo "app/config.yaml already exists — leaving it alone."; \
else \
cp app/config.sample.yaml app/config.yaml; \
echo "Created app/config.yaml from app/config.sample.yaml — review and adjust."; \
fi
# Build/run recipes source .env at recipe-execution time (not at make
# parse time) so they work in the same invocation that bootstrapped
# .env via the `env` prereq. Without this, the inner $$IMAGE_NAME /
# $$PORT would be empty on the very first `make build` after a fresh
# checkout — the parse-time `include .env` happens before `env` runs.
define _require_env
if [ ! -f .env ]; then echo "ERROR: .env missing"; exit 1; fi; \
. ./.env; \
for v in $(1); do \
eval "val=\$$$$v"; \
[ -n "$$val" ] || { echo "ERROR: $$v is empty in .env (see env.example)"; exit 1; }; \
done
endef
.PHONY: build
build:
build: env config
# Build the Docker image.
docker build -t application-portfolio .
@$(call _require_env,IMAGE_NAME); \
docker build -t "$$IMAGE_NAME" .
.PHONY: build-no-cache
build-no-cache:
build-no-cache: env config
# Build the Docker image without cache.
docker build --no-cache -t application-portfolio .
@$(call _require_env,IMAGE_NAME); \
docker build --no-cache -t "$$IMAGE_NAME" .
.PHONY: up
up:
up: env config
# Start the application using docker-compose with build.
docker-compose up -d --build --force-recreate
@@ -34,23 +71,25 @@ down:
- docker-compose down
.PHONY: run-dev
run-dev:
run-dev: env config config
# Run the container in development mode (hot-reload).
docker run -d \
-p $(PORT):$(PORT) \
--name portfolio \
-v $(PWD)/app/:/app \
-e FLASK_APP=app.py \
-e FLASK_ENV=development \
application-portfolio
@$(call _require_env,IMAGE_NAME PORT); \
docker run -d \
-p "$$PORT:$$PORT" \
--name portfolio \
-v "$(PWD)/app/:/app" \
-e FLASK_APP=app.py \
-e FLASK_ENV=development \
"$$IMAGE_NAME"
.PHONY: run-prod
run-prod:
run-prod: env config config
# Run the container in production mode.
docker run -d \
-p $(PORT):$(PORT) \
--name portfolio \
application-portfolio
@$(call _require_env,IMAGE_NAME PORT); \
docker run -d \
-p "$$PORT:$$PORT" \
--name portfolio \
"$$IMAGE_NAME"
.PHONY: logs
logs:
@@ -58,12 +97,12 @@ logs:
docker logs -f portfolio
.PHONY: dev
dev:
dev: env config
# Start the application in development mode using docker-compose.
FLASK_ENV=development docker-compose up -d
.PHONY: prod
prod:
prod: env config
# Start the application in production mode using docker-compose (with build).
docker-compose up -d --build
@@ -78,9 +117,10 @@ delete:
- docker rm -f portfolio
.PHONY: browse
browse:
# Open the application in the browser at http://localhost:$(PORT)
chromium http://localhost:$(PORT)
browse: env
# Open the application in the browser at http://localhost:$$PORT
@$(call _require_env,PORT); \
chromium "http://localhost:$$PORT"
.PHONY: install
install: