fix(cli): keep stdout clean by sending installer/auth logs to stderr
- route all installer and auth debug output to stderr - ensure CLI stdout contains only the generated token - fix E2E test failure caused by mixed log/token output https://chatgpt.com/share/694a7b30-3dd4-800f-ba48-ae7083cfa4d8
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
import urllib.error
|
import urllib.error
|
||||||
|
|
||||||
from .errors import TokenCreationError
|
from .errors import TokenCreationError
|
||||||
@@ -18,20 +19,21 @@ def _try_json(body: str) -> object:
|
|||||||
raise TokenCreationError(f"Invalid JSON from Matomo API: {body[:400]}") from exc
|
raise TokenCreationError(f"Invalid JSON from Matomo API: {body[:400]}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _dbg(msg: str, enabled: bool) -> None:
|
||||||
|
if enabled:
|
||||||
|
# IMPORTANT: keep stdout clean (tests expect only token on stdout)
|
||||||
|
print(msg, file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
def _login_via_logme(client: HttpClient, admin_user: str, admin_password: str, debug: bool) -> None:
|
def _login_via_logme(client: HttpClient, admin_user: str, admin_password: str, debug: bool) -> None:
|
||||||
"""
|
"""
|
||||||
Create an authenticated Matomo session (cookie jar) using the classic Login controller.
|
Create an authenticated Matomo session (cookie jar) using the classic Login controller.
|
||||||
|
|
||||||
Matomo accepts the md5 hashed password in the `password` parameter for action=logme.
|
Matomo accepts the md5 hashed password in the `password` parameter for action=logme.
|
||||||
We rely on urllib's opener to follow redirects and store cookies.
|
We rely on urllib's opener to follow redirects and store cookies.
|
||||||
|
|
||||||
If this ever stops working in a future Matomo version, the next step would be:
|
|
||||||
- GET the login page, extract CSRF/nonce, then POST the login form.
|
|
||||||
"""
|
"""
|
||||||
md5_password = _md5(admin_password)
|
md5_password = _md5(admin_password)
|
||||||
|
|
||||||
# Hit the login endpoint; cookies should be set in the client's CookieJar.
|
|
||||||
# We treat any HTTP response as "we reached the login controller" – later API call will tell us if session is valid.
|
|
||||||
try:
|
try:
|
||||||
status, body = client.get(
|
status, body = client.get(
|
||||||
"/index.php",
|
"/index.php",
|
||||||
@@ -42,16 +44,14 @@ def _login_via_logme(client: HttpClient, admin_user: str, admin_password: str, d
|
|||||||
"password": md5_password,
|
"password": md5_password,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if debug:
|
_dbg(f"[auth] login via logme returned HTTP {status} (body preview: {body[:120]!r})", debug)
|
||||||
print(f"[auth] login via logme returned HTTP {status} (body preview: {body[:120]!r})")
|
|
||||||
except urllib.error.HTTPError as exc:
|
except urllib.error.HTTPError as exc:
|
||||||
# Even 4xx/5xx can still set cookies; continue and let the API call validate.
|
# Even 4xx/5xx can still set cookies; continue and let the API call validate.
|
||||||
if debug:
|
|
||||||
try:
|
try:
|
||||||
err_body = exc.read().decode("utf-8", errors="replace")
|
err_body = exc.read().decode("utf-8", errors="replace")
|
||||||
except Exception:
|
except Exception:
|
||||||
err_body = ""
|
err_body = ""
|
||||||
print(f"[auth] login via logme raised HTTPError {exc.code} (body preview: {err_body[:120]!r})")
|
_dbg(f"[auth] login via logme raised HTTPError {exc.code} (body preview: {err_body[:120]!r})", debug)
|
||||||
|
|
||||||
|
|
||||||
def create_app_token_via_session(
|
def create_app_token_via_session(
|
||||||
@@ -70,8 +70,7 @@ def create_app_token_via_session(
|
|||||||
"""
|
"""
|
||||||
env_token = os.environ.get("MATOMO_BOOTSTRAP_TOKEN_AUTH")
|
env_token = os.environ.get("MATOMO_BOOTSTRAP_TOKEN_AUTH")
|
||||||
if env_token:
|
if env_token:
|
||||||
if debug:
|
_dbg("[auth] Using MATOMO_BOOTSTRAP_TOKEN_AUTH from environment.", debug)
|
||||||
print("[auth] Using MATOMO_BOOTSTRAP_TOKEN_AUTH from environment.")
|
|
||||||
return env_token
|
return env_token
|
||||||
|
|
||||||
# 1) Establish logged-in session
|
# 1) Establish logged-in session
|
||||||
@@ -90,8 +89,7 @@ def create_app_token_via_session(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
if debug:
|
_dbg(f"[auth] createAppSpecificTokenAuth HTTP {status} body[:200]={body[:200]!r}", debug)
|
||||||
print(f"[auth] createAppSpecificTokenAuth HTTP {status} body[:200]={body[:200]!r}")
|
|
||||||
|
|
||||||
if status != 200:
|
if status != 200:
|
||||||
raise TokenCreationError(f"HTTP {status} during token creation: {body[:400]}")
|
raise TokenCreationError(f"HTTP {status} during token creation: {body[:400]}")
|
||||||
@@ -100,7 +98,6 @@ def create_app_token_via_session(
|
|||||||
|
|
||||||
token = data.get("value") if isinstance(data, dict) else None
|
token = data.get("value") if isinstance(data, dict) else None
|
||||||
if not token:
|
if not token:
|
||||||
# Matomo may return {"result":"error","message":"..."}.
|
|
||||||
raise TokenCreationError(f"Unexpected response from token creation: {data}")
|
raise TokenCreationError(f"Unexpected response from token creation: {data}")
|
||||||
|
|
||||||
return str(token)
|
return str(token)
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
import time
|
import time
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
|
|
||||||
# Optional knobs (mostly for debugging / CI stability)
|
# Optional knobs (mostly for debugging / CI stability)
|
||||||
PLAYWRIGHT_HEADLESS = os.environ.get("MATOMO_PLAYWRIGHT_HEADLESS", "1").strip() not in ("0", "false", "False")
|
PLAYWRIGHT_HEADLESS = (
|
||||||
|
os.environ.get("MATOMO_PLAYWRIGHT_HEADLESS", "1").strip() not in ("0", "false", "False")
|
||||||
|
)
|
||||||
PLAYWRIGHT_SLOWMO_MS = int(os.environ.get("MATOMO_PLAYWRIGHT_SLOWMO_MS", "0"))
|
PLAYWRIGHT_SLOWMO_MS = int(os.environ.get("MATOMO_PLAYWRIGHT_SLOWMO_MS", "0"))
|
||||||
PLAYWRIGHT_NAV_TIMEOUT_MS = int(os.environ.get("MATOMO_PLAYWRIGHT_NAV_TIMEOUT_MS", "60000"))
|
PLAYWRIGHT_NAV_TIMEOUT_MS = int(os.environ.get("MATOMO_PLAYWRIGHT_NAV_TIMEOUT_MS", "60000"))
|
||||||
|
|
||||||
@@ -16,27 +18,33 @@ DEFAULT_TIMEZONE = os.environ.get("MATOMO_TIMEZONE", "Germany - Berlin")
|
|||||||
DEFAULT_ECOMMERCE = os.environ.get("MATOMO_ECOMMERCE", "Ecommerce enabled")
|
DEFAULT_ECOMMERCE = os.environ.get("MATOMO_ECOMMERCE", "Ecommerce enabled")
|
||||||
|
|
||||||
|
|
||||||
|
def _log(msg: str) -> None:
|
||||||
|
# IMPORTANT: logs must not pollute stdout (tests expect only token on stdout)
|
||||||
|
print(msg, file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
def wait_http(url: str, timeout: int = 180) -> None:
|
def wait_http(url: str, timeout: int = 180) -> None:
|
||||||
"""
|
"""
|
||||||
Consider Matomo 'reachable' as soon as the HTTP server answers - even with 500.
|
Consider Matomo 'reachable' as soon as the HTTP server answers - even with 500.
|
||||||
urllib raises HTTPError for 4xx/5xx, so we must treat that as reachability too.
|
urllib raises HTTPError for 4xx/5xx, so we must treat that as reachability too.
|
||||||
"""
|
"""
|
||||||
print(f"[install] Waiting for Matomo HTTP at {url} ...")
|
_log(f"[install] Waiting for Matomo HTTP at {url} ...")
|
||||||
last_err: Exception | None = None
|
last_err: Exception | None = None
|
||||||
|
|
||||||
for i in range(timeout):
|
for i in range(timeout):
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(url, timeout=2) as resp:
|
with urllib.request.urlopen(url, timeout=2) as resp:
|
||||||
_ = resp.read(128)
|
_ = resp.read(128)
|
||||||
print("[install] Matomo HTTP reachable (2xx/3xx).")
|
_log("[install] Matomo HTTP reachable (2xx/3xx).")
|
||||||
return
|
return
|
||||||
except urllib.error.HTTPError as exc:
|
except urllib.error.HTTPError as exc:
|
||||||
print(f"[install] Matomo HTTP reachable (HTTP {exc.code}).")
|
# 4xx/5xx means the server answered -> reachable
|
||||||
|
_log(f"[install] Matomo HTTP reachable (HTTP {exc.code}).")
|
||||||
return
|
return
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
last_err = exc
|
last_err = exc
|
||||||
if i % 5 == 0:
|
if i % 5 == 0:
|
||||||
print(f"[install] still waiting ({i}/{timeout}) … ({type(exc).__name__})")
|
_log(f"[install] still waiting ({i}/{timeout}) … ({type(exc).__name__})")
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
|
|
||||||
raise RuntimeError(f"Matomo did not become reachable after {timeout}s: {url} ({last_err})")
|
raise RuntimeError(f"Matomo did not become reachable after {timeout}s: {url} ({last_err})")
|
||||||
@@ -79,12 +87,12 @@ def ensure_installed(
|
|||||||
|
|
||||||
if is_installed(base_url):
|
if is_installed(base_url):
|
||||||
if debug:
|
if debug:
|
||||||
print("[install] Matomo already looks installed. Skipping installer.")
|
_log("[install] Matomo already looks installed. Skipping installer.")
|
||||||
return
|
return
|
||||||
|
|
||||||
from playwright.sync_api import sync_playwright
|
from playwright.sync_api import sync_playwright
|
||||||
|
|
||||||
print("[install] Running Matomo web installer via Playwright (recorded flow)...")
|
_log("[install] Running Matomo web installer via Playwright (recorded flow)...")
|
||||||
|
|
||||||
with sync_playwright() as p:
|
with sync_playwright() as p:
|
||||||
browser = p.chromium.launch(
|
browser = p.chromium.launch(
|
||||||
@@ -98,7 +106,7 @@ def ensure_installed(
|
|||||||
|
|
||||||
def _dbg(msg: str) -> None:
|
def _dbg(msg: str) -> None:
|
||||||
if debug:
|
if debug:
|
||||||
print(f"[install] {msg}")
|
_log(f"[install] {msg}")
|
||||||
|
|
||||||
def click_next() -> None:
|
def click_next() -> None:
|
||||||
"""
|
"""
|
||||||
@@ -141,11 +149,6 @@ def ensure_installed(
|
|||||||
# --- Recorded-ish flow, but made variable-based + more stable ---
|
# --- Recorded-ish flow, but made variable-based + more stable ---
|
||||||
page.goto(base_url, wait_until="domcontentloaded")
|
page.goto(base_url, wait_until="domcontentloaded")
|
||||||
|
|
||||||
# The first few screens can vary slightly (welcome/system check/db etc.).
|
|
||||||
# In your recording, you clicked through multiple Next pages without DB input (env already set in container).
|
|
||||||
# We mimic that: keep clicking "Next" until we see the superuser fields.
|
|
||||||
#
|
|
||||||
# Stop condition: superuser login field appears.
|
|
||||||
def superuser_form_visible() -> bool:
|
def superuser_form_visible() -> bool:
|
||||||
# In your recording, the superuser "Login" field was "#login-0".
|
# In your recording, the superuser "Login" field was "#login-0".
|
||||||
return page.locator("#login-0").count() > 0
|
return page.locator("#login-0").count() > 0
|
||||||
@@ -176,7 +179,10 @@ def ensure_installed(
|
|||||||
page.locator("#email-0").fill(admin_email)
|
page.locator("#email-0").fill(admin_email)
|
||||||
|
|
||||||
# Next
|
# Next
|
||||||
|
if page.get_by_role("button", name="Next »").count() > 0:
|
||||||
page.get_by_role("button", name="Next »").click()
|
page.get_by_role("button", name="Next »").click()
|
||||||
|
else:
|
||||||
|
click_next()
|
||||||
|
|
||||||
# First website
|
# First website
|
||||||
if page.locator("#siteName-0").count() > 0:
|
if page.locator("#siteName-0").count() > 0:
|
||||||
@@ -189,7 +195,6 @@ def ensure_installed(
|
|||||||
|
|
||||||
# Timezone dropdown (best-effort)
|
# Timezone dropdown (best-effort)
|
||||||
try:
|
try:
|
||||||
# recording: page.get_by_role("combobox").first.click() then listbox text
|
|
||||||
page.get_by_role("combobox").first.click()
|
page.get_by_role("combobox").first.click()
|
||||||
page.get_by_role("listbox").get_by_text(DEFAULT_TIMEZONE).click()
|
page.get_by_role("listbox").get_by_text(DEFAULT_TIMEZONE).click()
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -197,7 +202,6 @@ def ensure_installed(
|
|||||||
|
|
||||||
# Ecommerce dropdown (best-effort)
|
# Ecommerce dropdown (best-effort)
|
||||||
try:
|
try:
|
||||||
# recording: combobox nth(2)
|
|
||||||
page.get_by_role("combobox").nth(2).click()
|
page.get_by_role("combobox").nth(2).click()
|
||||||
page.get_by_role("listbox").get_by_text(DEFAULT_ECOMMERCE).click()
|
page.get_by_role("listbox").get_by_text(DEFAULT_ECOMMERCE).click()
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -207,27 +211,12 @@ def ensure_installed(
|
|||||||
click_next()
|
click_next()
|
||||||
page.wait_for_load_state("domcontentloaded")
|
page.wait_for_load_state("domcontentloaded")
|
||||||
|
|
||||||
# In recording: Next link, then Continue to Matomo button
|
|
||||||
if page.get_by_role("link", name="Next »").count() > 0:
|
if page.get_by_role("link", name="Next »").count() > 0:
|
||||||
page.get_by_role("link", name="Next »").click()
|
page.get_by_role("link", name="Next »").click()
|
||||||
|
|
||||||
if page.get_by_role("button", name="Continue to Matomo »").count() > 0:
|
if page.get_by_role("button", name="Continue to Matomo »").count() > 0:
|
||||||
page.get_by_role("button", name="Continue to Matomo »").click()
|
page.get_by_role("button", name="Continue to Matomo »").click()
|
||||||
|
|
||||||
# Optional: login once (not strictly required for token flow, but harmless and matches your recording).
|
|
||||||
# Some UIs have fancy-icon labels; we follow your recorded selectors best-effort.
|
|
||||||
try:
|
|
||||||
user_box = page.get_by_role("textbox", name=" Username or e-mail")
|
|
||||||
pass_box = page.get_by_role("textbox", name=" Password")
|
|
||||||
if user_box.count() > 0 and pass_box.count() > 0:
|
|
||||||
user_box.click()
|
|
||||||
user_box.fill(admin_user)
|
|
||||||
pass_box.fill(admin_password)
|
|
||||||
if page.get_by_role("button", name="Sign in").count() > 0:
|
|
||||||
page.get_by_role("button", name="Sign in").click()
|
|
||||||
except Exception:
|
|
||||||
_dbg("Post-install login skipped (UI differs).")
|
|
||||||
|
|
||||||
context.close()
|
context.close()
|
||||||
browser.close()
|
browser.close()
|
||||||
|
|
||||||
@@ -235,4 +224,4 @@ def ensure_installed(
|
|||||||
if not is_installed(base_url):
|
if not is_installed(base_url):
|
||||||
raise RuntimeError("[install] Installer did not reach installed state.")
|
raise RuntimeError("[install] Installer did not reach installed state.")
|
||||||
|
|
||||||
print("[install] Installation finished.")
|
_log("[install] Installation finished.")
|
||||||
|
|||||||
Reference in New Issue
Block a user