Log Startdialog added
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
# AssetManager 0.5.5.91
|
# AssetManager 0.5.5.92
|
||||||
|
|
||||||
AssetManager is a self-hosted web application for managing IT equipment and other organizational assets. It provides asset inventory, software inventory, remote job execution, reporting, privacy/retention documentation, and optional integration with MeshCentral.
|
AssetManager is a self-hosted web application for managing IT equipment and other organizational assets. It provides asset inventory, software inventory, remote job execution, reporting, privacy/retention documentation, and optional integration with MeshCentral.
|
||||||
|
|
||||||
|
|||||||
+85
@@ -23,6 +23,8 @@ import ssl
|
|||||||
import time
|
import time
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
import importlib.metadata
|
||||||
|
import platform
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
@@ -214,6 +216,79 @@ if not ldap_logger.handlers:
|
|||||||
ldap_console_handler.setFormatter(logging.Formatter("LDAP | %(levelname)s | %(message)s"))
|
ldap_console_handler.setFormatter(logging.Formatter("LDAP | %(levelname)s | %(message)s"))
|
||||||
ldap_logger.addHandler(ldap_console_handler)
|
ldap_logger.addHandler(ldap_console_handler)
|
||||||
|
|
||||||
|
def _runtime_package_version(distribution_name: str) -> str:
|
||||||
|
try:
|
||||||
|
return importlib.metadata.version(distribution_name)
|
||||||
|
except importlib.metadata.PackageNotFoundError:
|
||||||
|
return "not installed"
|
||||||
|
except Exception:
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def _database_server_version() -> str:
|
||||||
|
try:
|
||||||
|
with engine.connect() as connection:
|
||||||
|
version = connection.exec_driver_sql("SELECT version()").scalar()
|
||||||
|
return str(version or "unknown").split(",", 1)[0]
|
||||||
|
except Exception as exc:
|
||||||
|
return f"unavailable ({exc.__class__.__name__})"
|
||||||
|
|
||||||
|
|
||||||
|
def _configured_callback_base_url() -> str:
|
||||||
|
try:
|
||||||
|
software = load_config().get("software", {}) or {}
|
||||||
|
configured = str(software.get("callback_base_url", "") or "").strip().rstrip("/")
|
||||||
|
if configured:
|
||||||
|
return configured
|
||||||
|
general = load_config().get("general", {}) or {}
|
||||||
|
return str(general.get("callback_base_url", "") or "").strip().rstrip("/")
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _startup_banner(role: str) -> None:
|
||||||
|
role_name = "Application" if role == "app" else "Callback"
|
||||||
|
callback_base = _configured_callback_base_url()
|
||||||
|
callback_bind = str(os.getenv("CALLBACK_BIND_IP", "127.0.0.1") or "127.0.0.1")
|
||||||
|
callback_port = str(os.getenv("CALLBACK_PORT", "8090") or "8090")
|
||||||
|
internal_callback = str(os.getenv("CALLBACK_INTERNAL_BASE_URL", "http://callback:8001") or "http://callback:8001").rstrip("/")
|
||||||
|
lines = [
|
||||||
|
"=" * 80,
|
||||||
|
f" AssetManager - {role_name} Startup",
|
||||||
|
"=" * 80,
|
||||||
|
f" AssetManager version : {application_version()}",
|
||||||
|
f" Container role : {role}",
|
||||||
|
f" Container version : {application_version()}",
|
||||||
|
f" Image reference : {os.getenv('ASSETMANAGER_IMAGE_REF', 'not provided')}",
|
||||||
|
f" Build version : {APP_VERSION}",
|
||||||
|
f" Build date : {os.getenv('ASSETMANAGER_BUILD_DATE', 'not provided')}",
|
||||||
|
f" Git commit : {os.getenv('ASSETMANAGER_GIT_COMMIT', 'not provided')}",
|
||||||
|
f" Python : {platform.python_version()}",
|
||||||
|
f" FastAPI : {_runtime_package_version('fastapi')}",
|
||||||
|
f" Uvicorn : {_runtime_package_version('uvicorn')}",
|
||||||
|
f" SQLAlchemy : {_runtime_package_version('sqlalchemy')}",
|
||||||
|
f" psycopg : {_runtime_package_version('psycopg')}",
|
||||||
|
f" Database : {_database_server_version()}",
|
||||||
|
f" Data directory : {DATA_ROOT}",
|
||||||
|
f" Startup time UTC : {datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S')}",
|
||||||
|
]
|
||||||
|
if role == "callback":
|
||||||
|
lines.extend([
|
||||||
|
"-" * 80,
|
||||||
|
f" Callback base URL : {callback_base or '<automatic / not fixed>'}",
|
||||||
|
f" Callback API URL : {(callback_base + '/api/software-jobs/<job_id>/callback') if callback_base else '<resolved by application per job>'}",
|
||||||
|
f" Callback health URL : {(callback_base + '/api/software-callback/health') if callback_base else '<resolved from callback base URL>'}",
|
||||||
|
f" Internal callback : {internal_callback}",
|
||||||
|
f" Published listener : {callback_bind}:{callback_port} -> container:8001",
|
||||||
|
])
|
||||||
|
lines.extend([
|
||||||
|
"-" * 80,
|
||||||
|
f" AssetManager {role} ready",
|
||||||
|
"=" * 80,
|
||||||
|
])
|
||||||
|
print("\n".join(lines), flush=True)
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title=os.getenv("APP_TITLE", "AssetManager"))
|
app = FastAPI(title=os.getenv("APP_TITLE", "AssetManager"))
|
||||||
# Dedicated callback-only ASGI application. It deliberately exposes only the
|
# Dedicated callback-only ASGI application. It deliberately exposes only the
|
||||||
# software callback endpoint and its health endpoint so deployments may publish
|
# software callback endpoint and its health endpoint so deployments may publish
|
||||||
@@ -956,6 +1031,16 @@ async def job_definition_script_generator(request: Request):
|
|||||||
return JSONResponse({"script": script, "interpreter": interpreter, "action": action})
|
return JSONResponse({"script": script, "interpreter": interpreter, "action": action})
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
def log_application_startup():
|
||||||
|
_startup_banner("app")
|
||||||
|
|
||||||
|
|
||||||
|
@callback_app.on_event("startup")
|
||||||
|
def log_callback_startup():
|
||||||
|
_startup_banner("callback")
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
def start_backup_scheduler():
|
def start_backup_scheduler():
|
||||||
global BACKUP_THREAD
|
global BACKUP_THREAD
|
||||||
|
|||||||
+151
-111
@@ -21,6 +21,7 @@ from .software_packages import build_deployment_user_script, load_package_manife
|
|||||||
|
|
||||||
_DISPATCH_SLOT_LOCK = threading.Lock()
|
_DISPATCH_SLOT_LOCK = threading.Lock()
|
||||||
_NEXT_DISPATCH_MONOTONIC = 0.0
|
_NEXT_DISPATCH_MONOTONIC = 0.0
|
||||||
|
_SOFTWARE_INVENTORY_MESHCTRL_LOCK = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
def _wait_for_dispatch_slot() -> tuple[float, float]:
|
def _wait_for_dispatch_slot() -> tuple[float, float]:
|
||||||
@@ -744,134 +745,173 @@ def execute_job(job_id: int, token: str, callback_base: str) -> None:
|
|||||||
f'Execution timeout: {execution_timeout_seconds or timeout} seconds',
|
f'Execution timeout: {execution_timeout_seconds or timeout} seconds',
|
||||||
]
|
]
|
||||||
|
|
||||||
if upload_required:
|
inventory_meshctrl_lock_acquired = False
|
||||||
suffix=_script_extension(interpreter)
|
if job.job_type == 'software_inventory':
|
||||||
temp_dir=tempfile.mkdtemp(prefix=f'assetmanager-job-{job.id}-')
|
lock_started = time.monotonic()
|
||||||
temp_path=str(Path(temp_dir)/('run'+suffix))
|
_SOFTWARE_INVENTORY_MESHCTRL_LOCK.acquire()
|
||||||
Path(temp_path).write_text(payload,encoding='utf-8',newline='\n')
|
inventory_meshctrl_lock_acquired = True
|
||||||
diagnostics += [f'Remote directory: {remote_dir}',f'Remote file: {remote_file}',f'Local staging bytes: {os.path.getsize(temp_path)}']
|
diagnostics += [
|
||||||
|
'MeshCtrl isolation: software inventory serialized',
|
||||||
|
f'MeshCtrl isolation wait: {time.monotonic() - lock_started:.3f} seconds',
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
if upload_required:
|
||||||
|
suffix=_script_extension(interpreter)
|
||||||
|
temp_dir=tempfile.mkdtemp(prefix=f'assetmanager-job-{job.id}-')
|
||||||
|
temp_path=str(Path(temp_dir)/('run'+suffix))
|
||||||
|
Path(temp_path).write_text(payload,encoding='utf-8',newline='\n')
|
||||||
|
diagnostics += [f'Remote directory: {remote_dir}',f'Remote file: {remote_file}',f'Local staging bytes: {os.path.getsize(temp_path)}']
|
||||||
|
|
||||||
if remote_cleanup_enabled:
|
if remote_cleanup_enabled:
|
||||||
stale_cleanup=_cleanup_stale_remote_job_directories(
|
stale_cleanup=_cleanup_stale_remote_job_directories(
|
||||||
cfg,asset,password,job.platform,remote_dir,remote_job_retention_hours,min(timeout,120)
|
cfg,asset,password,job.platform,remote_dir,remote_job_retention_hours,min(timeout,120)
|
||||||
)
|
)
|
||||||
diagnostics += [
|
diagnostics += [
|
||||||
'--- Stale remote job cleanup stdout ---',stale_cleanup.stdout or '',
|
'--- Stale remote job cleanup stdout ---',stale_cleanup.stdout or '',
|
||||||
'--- Stale remote job cleanup stderr ---',stale_cleanup.stderr or '',
|
'--- Stale remote job cleanup stderr ---',stale_cleanup.stderr or '',
|
||||||
f'Stale remote job cleanup return code: {stale_cleanup.returncode}',
|
f'Stale remote job cleanup return code: {stale_cleanup.returncode}',
|
||||||
]
|
]
|
||||||
|
|
||||||
prepared=_prepare_remote_directory(cfg,asset,password,job.platform,remote_dir,timeout,remote_file)
|
prepared=_prepare_remote_directory(cfg,asset,password,job.platform,remote_dir,timeout,remote_file)
|
||||||
diagnostics += ['--- Prepare directory stdout ---',prepared.stdout or '','--- Prepare directory stderr ---',prepared.stderr or '',f'Prepare return code: {prepared.returncode}']
|
diagnostics += ['--- Prepare directory stdout ---',prepared.stdout or '','--- Prepare directory stderr ---',prepared.stderr or '',f'Prepare return code: {prepared.returncode}']
|
||||||
if prepared.returncode!=0: raise RuntimeError(f'Remote Jobverzeichnis konnte nicht erstellt werden: {prepared.stderr or prepared.stdout}')
|
if prepared.returncode!=0: raise RuntimeError(f'Remote Jobverzeichnis konnte nicht erstellt werden: {prepared.stderr or prepared.stdout}')
|
||||||
|
|
||||||
upload_results=[]
|
upload_results=[]
|
||||||
uploaded=_upload_script(cfg,asset,password,temp_path,remote_dir,timeout)
|
|
||||||
upload_results.append(uploaded)
|
|
||||||
diagnostics += ['--- Upload attempt 1 stdout ---',uploaded.stdout or '','--- Upload attempt 1 stderr ---',uploaded.stderr or '',f'Upload attempt 1 return code: {uploaded.returncode}']
|
|
||||||
upload_text=(uploaded.stdout or '')+'\n'+(uploaded.stderr or '')
|
|
||||||
upload_ok=uploaded.returncode==0 and 'Upload done' in upload_text and 'Upload error' not in upload_text
|
|
||||||
if not upload_ok:
|
|
||||||
# MeshCtrl can return code 0 together with "Upload error".
|
|
||||||
# Remove only the stale target and retry once. Do not touch ACLs of already uploaded files.
|
|
||||||
repair=_prepare_remote_directory(cfg,asset,password,job.platform,remote_dir,min(timeout,120),remote_file,reset_acl=False)
|
|
||||||
diagnostics += ['--- Upload repair stdout ---',repair.stdout or '','--- Upload repair stderr ---',repair.stderr or '',f'Upload repair return code: {repair.returncode}']
|
|
||||||
uploaded=_upload_script(cfg,asset,password,temp_path,remote_dir,timeout)
|
uploaded=_upload_script(cfg,asset,password,temp_path,remote_dir,timeout)
|
||||||
upload_results.append(uploaded)
|
upload_results.append(uploaded)
|
||||||
diagnostics += ['--- Upload attempt 2 stdout ---',uploaded.stdout or '','--- Upload attempt 2 stderr ---',uploaded.stderr or '',f'Upload attempt 2 return code: {uploaded.returncode}']
|
diagnostics += ['--- Upload attempt 1 stdout ---',uploaded.stdout or '','--- Upload attempt 1 stderr ---',uploaded.stderr or '',f'Upload attempt 1 return code: {uploaded.returncode}']
|
||||||
upload_text=(uploaded.stdout or '')+'\n'+(uploaded.stderr or '')
|
upload_text=(uploaded.stdout or '')+'\n'+(uploaded.stderr or '')
|
||||||
upload_ok=uploaded.returncode==0 and 'Upload done' in upload_text and 'Upload error' not in upload_text
|
upload_ok=uploaded.returncode==0 and 'Upload done' in upload_text and 'Upload error' not in upload_text
|
||||||
if not upload_ok:
|
if not upload_ok:
|
||||||
combined='\n\n'.join(((item.stdout or '')+'\n'+(item.stderr or '')).strip() for item in upload_results if ((item.stdout or '')+(item.stderr or '')).strip())
|
# MeshCtrl can return code 0 together with "Upload error".
|
||||||
raise RuntimeError(f'MeshCentral-Dateiupload fehlgeschlagen nach 2 Versuchen: {combined.strip()}')
|
# Remove only the stale target and retry once. Do not touch ACLs of already uploaded files.
|
||||||
|
repair=_prepare_remote_directory(cfg,asset,password,job.platform,remote_dir,min(timeout,120),remote_file,reset_acl=False)
|
||||||
|
diagnostics += ['--- Upload repair stdout ---',repair.stdout or '','--- Upload repair stderr ---',repair.stderr or '',f'Upload repair return code: {repair.returncode}']
|
||||||
|
uploaded=_upload_script(cfg,asset,password,temp_path,remote_dir,timeout)
|
||||||
|
upload_results.append(uploaded)
|
||||||
|
diagnostics += ['--- Upload attempt 2 stdout ---',uploaded.stdout or '','--- Upload attempt 2 stderr ---',uploaded.stderr or '',f'Upload attempt 2 return code: {uploaded.returncode}']
|
||||||
|
upload_text=(uploaded.stdout or '')+'\n'+(uploaded.stderr or '')
|
||||||
|
upload_ok=uploaded.returncode==0 and 'Upload done' in upload_text and 'Upload error' not in upload_text
|
||||||
|
if not upload_ok:
|
||||||
|
combined='\n\n'.join(((item.stdout or '')+'\n'+(item.stderr or '')).strip() for item in upload_results if ((item.stdout or '')+(item.stderr or '')).strip())
|
||||||
|
raise RuntimeError(f'MeshCentral-Dateiupload fehlgeschlagen nach 2 Versuchen: {combined.strip()}')
|
||||||
|
|
||||||
if job.job_type == 'software_deployment':
|
if job.job_type == 'software_deployment':
|
||||||
payload_files = package_payload_files(job.package_id, job.action)
|
payload_files = package_payload_files(job.package_id, job.action)
|
||||||
diagnostics += [f'Package payload files: {len(payload_files)}']
|
diagnostics += [f'Package payload files: {len(payload_files)}']
|
||||||
for package_file in payload_files:
|
for package_file in payload_files:
|
||||||
remote_package_file = remote_dir + '\\' + package_file.name
|
remote_package_file = remote_dir + '\\' + package_file.name
|
||||||
package_uploads = []
|
package_uploads = []
|
||||||
package_uploaded = _upload_script(cfg, asset, password, str(package_file), remote_dir, timeout)
|
|
||||||
package_uploads.append(package_uploaded)
|
|
||||||
diagnostics += [
|
|
||||||
f'--- Package upload {package_file.name} attempt 1 stdout ---', package_uploaded.stdout or '',
|
|
||||||
f'--- Package upload {package_file.name} attempt 1 stderr ---', package_uploaded.stderr or '',
|
|
||||||
f'Package upload {package_file.name} attempt 1 return code: {package_uploaded.returncode}',
|
|
||||||
]
|
|
||||||
package_text = (package_uploaded.stdout or '') + '\n' + (package_uploaded.stderr or '')
|
|
||||||
package_ok = package_uploaded.returncode == 0 and 'Upload done' in package_text and 'Upload error' not in package_text
|
|
||||||
if not package_ok:
|
|
||||||
package_repair = _prepare_remote_directory(
|
|
||||||
cfg,
|
|
||||||
asset,
|
|
||||||
password,
|
|
||||||
job.platform,
|
|
||||||
remote_dir,
|
|
||||||
min(timeout, 120),
|
|
||||||
remote_package_file,
|
|
||||||
reset_acl=False,
|
|
||||||
)
|
|
||||||
diagnostics += [
|
|
||||||
f'--- Package upload {package_file.name} repair stdout ---', package_repair.stdout or '',
|
|
||||||
f'--- Package upload {package_file.name} repair stderr ---', package_repair.stderr or '',
|
|
||||||
f'Package upload {package_file.name} repair return code: {package_repair.returncode}',
|
|
||||||
]
|
|
||||||
package_uploaded = _upload_script(cfg, asset, password, str(package_file), remote_dir, timeout)
|
package_uploaded = _upload_script(cfg, asset, password, str(package_file), remote_dir, timeout)
|
||||||
package_uploads.append(package_uploaded)
|
package_uploads.append(package_uploaded)
|
||||||
diagnostics += [
|
diagnostics += [
|
||||||
f'--- Package upload {package_file.name} attempt 2 stdout ---', package_uploaded.stdout or '',
|
f'--- Package upload {package_file.name} attempt 1 stdout ---', package_uploaded.stdout or '',
|
||||||
f'--- Package upload {package_file.name} attempt 2 stderr ---', package_uploaded.stderr or '',
|
f'--- Package upload {package_file.name} attempt 1 stderr ---', package_uploaded.stderr or '',
|
||||||
f'Package upload {package_file.name} attempt 2 return code: {package_uploaded.returncode}',
|
f'Package upload {package_file.name} attempt 1 return code: {package_uploaded.returncode}',
|
||||||
]
|
]
|
||||||
package_text = (package_uploaded.stdout or '') + '\n' + (package_uploaded.stderr or '')
|
package_text = (package_uploaded.stdout or '') + '\n' + (package_uploaded.stderr or '')
|
||||||
package_ok = package_uploaded.returncode == 0 and 'Upload done' in package_text and 'Upload error' not in package_text
|
package_ok = package_uploaded.returncode == 0 and 'Upload done' in package_text and 'Upload error' not in package_text
|
||||||
if not package_ok:
|
if not package_ok:
|
||||||
combined = '\n\n'.join(
|
package_repair = _prepare_remote_directory(
|
||||||
((item.stdout or '') + '\n' + (item.stderr or '')).strip()
|
cfg,
|
||||||
for item in package_uploads
|
asset,
|
||||||
if ((item.stdout or '') + (item.stderr or '')).strip()
|
password,
|
||||||
)
|
job.platform,
|
||||||
raise RuntimeError(
|
remote_dir,
|
||||||
f'MeshCentral-Paketdateiupload fehlgeschlagen fuer {package_file.name}: {combined.strip()}'
|
min(timeout, 120),
|
||||||
)
|
remote_package_file,
|
||||||
|
reset_acl=False,
|
||||||
|
)
|
||||||
|
diagnostics += [
|
||||||
|
f'--- Package upload {package_file.name} repair stdout ---', package_repair.stdout or '',
|
||||||
|
f'--- Package upload {package_file.name} repair stderr ---', package_repair.stderr or '',
|
||||||
|
f'Package upload {package_file.name} repair return code: {package_repair.returncode}',
|
||||||
|
]
|
||||||
|
package_uploaded = _upload_script(cfg, asset, password, str(package_file), remote_dir, timeout)
|
||||||
|
package_uploads.append(package_uploaded)
|
||||||
|
diagnostics += [
|
||||||
|
f'--- Package upload {package_file.name} attempt 2 stdout ---', package_uploaded.stdout or '',
|
||||||
|
f'--- Package upload {package_file.name} attempt 2 stderr ---', package_uploaded.stderr or '',
|
||||||
|
f'Package upload {package_file.name} attempt 2 return code: {package_uploaded.returncode}',
|
||||||
|
]
|
||||||
|
package_text = (package_uploaded.stdout or '') + '\n' + (package_uploaded.stderr or '')
|
||||||
|
package_ok = package_uploaded.returncode == 0 and 'Upload done' in package_text and 'Upload error' not in package_text
|
||||||
|
if not package_ok:
|
||||||
|
combined = '\n\n'.join(
|
||||||
|
((item.stdout or '') + '\n' + (item.stderr or '')).strip()
|
||||||
|
for item in package_uploads
|
||||||
|
if ((item.stdout or '') + (item.stderr or '')).strip()
|
||||||
|
)
|
||||||
|
raise RuntimeError(
|
||||||
|
f'MeshCentral-Paketdateiupload fehlgeschlagen fuer {package_file.name}: {combined.strip()}'
|
||||||
|
)
|
||||||
|
|
||||||
preflight=_remote_script_preflight(cfg,asset,password,job.platform,remote_file,min(timeout,120))
|
preflight_ok = False
|
||||||
diagnostics += [
|
preflight_attempts = 3
|
||||||
'--- Remote script preflight stdout ---', preflight.stdout or '',
|
for preflight_attempt in range(1, preflight_attempts + 1):
|
||||||
'--- Remote script preflight stderr ---', preflight.stderr or '',
|
preflight = _remote_script_preflight(
|
||||||
f'Remote script preflight return code: {preflight.returncode}',
|
cfg,
|
||||||
]
|
asset,
|
||||||
preflight_text=(preflight.stdout or '')+'\n'+(preflight.stderr or '')
|
password,
|
||||||
if preflight.returncode!=0 or (job.platform=='windows' and ('File exists: True' not in preflight_text or 'Readable: True' not in preflight_text)):
|
job.platform,
|
||||||
raise RuntimeError('Remote Jobskript ist nach dem Upload nicht lesbar. Siehe Remote script preflight im Dispatcherlog.')
|
remote_file,
|
||||||
|
min(timeout, 120),
|
||||||
|
)
|
||||||
|
diagnostics += [
|
||||||
|
f'--- Remote script preflight attempt {preflight_attempt}/{preflight_attempts} stdout ---',
|
||||||
|
preflight.stdout or '',
|
||||||
|
f'--- Remote script preflight attempt {preflight_attempt}/{preflight_attempts} stderr ---',
|
||||||
|
preflight.stderr or '',
|
||||||
|
f'Remote script preflight attempt {preflight_attempt}/{preflight_attempts} return code: {preflight.returncode}',
|
||||||
|
]
|
||||||
|
preflight_text = (preflight.stdout or '') + '\n' + (preflight.stderr or '')
|
||||||
|
preflight_ok = preflight.returncode == 0 and (
|
||||||
|
job.platform != 'windows'
|
||||||
|
or ('File exists: True' in preflight_text and 'Readable: True' in preflight_text)
|
||||||
|
)
|
||||||
|
if preflight_ok:
|
||||||
|
break
|
||||||
|
if preflight_attempt < preflight_attempts:
|
||||||
|
diagnostics += [
|
||||||
|
f'Remote script preflight attempt {preflight_attempt}/{preflight_attempts}: not readable',
|
||||||
|
'Retrying remote script preflight in 1 second...',
|
||||||
|
]
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
diagnostics += [f'Remote execution shell: {launch_shell}',f'Remote execution command: {launch_command}',f'MeshCtrl PowerShell mode: False']
|
if not preflight_ok:
|
||||||
started=datetime.utcnow()
|
raise RuntimeError('Remote Jobskript ist nach dem Upload nicht lesbar. Siehe Remote script preflight im Dispatcherlog.')
|
||||||
result=_launch_uploaded_script(cfg,asset,password,job.platform,interpreter,remote_file,timeout)
|
|
||||||
finished=datetime.utcnow()
|
|
||||||
diagnostics += [f'Execution started UTC: {started.isoformat()}Z',f'Execution finished UTC: {finished.isoformat()}Z','--- Execution stdout ---',result.stdout or '','--- Execution stderr ---',result.stderr or '',f'Execution return code: {result.returncode}']
|
|
||||||
else:
|
|
||||||
started=datetime.utcnow()
|
|
||||||
action=['RunCommand','--id',asset.mesh_node_id,'--run',payload,'--reply']
|
|
||||||
if job.platform=='windows' and interpreter=='powershell': action += ['--powershell']
|
|
||||||
result=_run_meshctrl(cfg,asset,password,action,timeout)
|
|
||||||
finished=datetime.utcnow()
|
|
||||||
diagnostics += ['--- Direct execution stdout ---',result.stdout or '','--- Direct execution stderr ---',result.stderr or '',f'Execution return code: {result.returncode}']
|
|
||||||
|
|
||||||
now=datetime.utcnow()
|
diagnostics += [f'Remote execution shell: {launch_shell}',f'Remote execution command: {launch_command}',f'MeshCtrl PowerShell mode: False']
|
||||||
# MeshCtrl itself returns code 0 even for some remote PowerShell parser
|
started=datetime.utcnow()
|
||||||
# errors. Only delete the remote files after a completed successful
|
result=_launch_uploaded_script(cfg,asset,password,job.platform,interpreter,remote_file,timeout)
|
||||||
# callback, never merely because the MeshCtrl process exited cleanly.
|
finished=datetime.utcnow()
|
||||||
db.expire_all()
|
diagnostics += [f'Execution started UTC: {started.isoformat()}Z',f'Execution finished UTC: {finished.isoformat()}Z','--- Execution stdout ---',result.stdout or '','--- Execution stderr ---',result.stderr or '',f'Execution return code: {result.returncode}']
|
||||||
callback_state=db.get(SoftwareJob,job.id)
|
else:
|
||||||
cleanup_result=None
|
started=datetime.utcnow()
|
||||||
callback_success=bool(callback_state and callback_state.callback_completed_at is not None and callback_state.status=='success')
|
action=['RunCommand','--id',asset.mesh_node_id,'--run',payload,'--reply']
|
||||||
diagnostics += [f'Callback completed before cleanup: {bool(callback_state and callback_state.callback_completed_at)}',f'Callback status before cleanup: {callback_state.status if callback_state else "unknown"}']
|
if job.platform=='windows' and interpreter=='powershell': action += ['--powershell']
|
||||||
if upload_required and callback_success:
|
result=_run_meshctrl(cfg,asset,password,action,timeout)
|
||||||
cleanup_result=_cleanup_remote_directory(cfg,asset,password,job.platform,remote_dir,min(timeout,120))
|
finished=datetime.utcnow()
|
||||||
diagnostics += ['--- Cleanup stdout ---',cleanup_result.stdout or '','--- Cleanup stderr ---',cleanup_result.stderr or '',f'Cleanup return code: {cleanup_result.returncode}']
|
diagnostics += ['--- Direct execution stdout ---',result.stdout or '','--- Direct execution stderr ---',result.stderr or '',f'Execution return code: {result.returncode}']
|
||||||
elif upload_required:
|
|
||||||
diagnostics += ['Remote cleanup skipped: no successful completed callback; files retained for diagnosis.']
|
now=datetime.utcnow()
|
||||||
|
# MeshCtrl itself returns code 0 even for some remote PowerShell parser
|
||||||
|
# errors. Only delete the remote files after a completed successful
|
||||||
|
# callback, never merely because the MeshCtrl process exited cleanly.
|
||||||
|
db.expire_all()
|
||||||
|
callback_state=db.get(SoftwareJob,job.id)
|
||||||
|
cleanup_result=None
|
||||||
|
callback_success=bool(callback_state and callback_state.callback_completed_at is not None and callback_state.status=='success')
|
||||||
|
diagnostics += [f'Callback completed before cleanup: {bool(callback_state and callback_state.callback_completed_at)}',f'Callback status before cleanup: {callback_state.status if callback_state else "unknown"}']
|
||||||
|
if upload_required and callback_success:
|
||||||
|
cleanup_result=_cleanup_remote_directory(cfg,asset,password,job.platform,remote_dir,min(timeout,120))
|
||||||
|
diagnostics += ['--- Cleanup stdout ---',cleanup_result.stdout or '','--- Cleanup stderr ---',cleanup_result.stderr or '',f'Cleanup return code: {cleanup_result.returncode}']
|
||||||
|
elif upload_required:
|
||||||
|
diagnostics += ['Remote cleanup skipped: no successful completed callback; files retained for diagnosis.']
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if inventory_meshctrl_lock_acquired:
|
||||||
|
_SOFTWARE_INVENTORY_MESHCTRL_LOCK.release()
|
||||||
|
|
||||||
db.query(SoftwareJob).filter(SoftwareJob.id==job.id).update({SoftwareJob.meshctrl_stdout:'\n'.join(diagnostics)[-30000:],SoftwareJob.meshctrl_stderr:(result.stderr or '')[-20000:],SoftwareJob.sent_at:now},synchronize_session=False)
|
db.query(SoftwareJob).filter(SoftwareJob.id==job.id).update({SoftwareJob.meshctrl_stdout:'\n'.join(diagnostics)[-30000:],SoftwareJob.meshctrl_stderr:(result.stderr or '')[-20000:],SoftwareJob.sent_at:now},synchronize_session=False)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -312,6 +312,6 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{% if is_admin %}<dialog id="issue-dialog"><form method="post" id="issue-form"><input type="hidden" name="return_to" id="issue-return-to"><h2>{{ t('assets.issue_asset') }}</h2><label>{{ t('common.name') }}<input name="assigned_to" required></label><label>{{ t('common.date') }}<input type="date" name="assigned_on" value="{{ today }}" required></label><div class="form-actions"><button type="button" class="button button-secondary" onclick="this.closest('dialog').close()">{{ t('common.cancel') }}</button><button type="submit" class="button button-issue">↗ {{ t('assets.issue') }}</button></div></form></dialog>{% endif %}
|
{% if is_admin %}<dialog id="issue-dialog"><form method="post" id="issue-form"><input type="hidden" name="return_to" id="issue-return-to"><h2>{{ t('assets.issue_asset') }}</h2><label>{{ t('common.name') }}<input name="assigned_to" required></label><label>{{ t('common.date') }}<input type="date" name="assigned_on" value="{{ today }}" required></label><div class="form-actions"><button type="button" class="button button-secondary" onclick="this.closest('dialog').close()">{{ t('common.cancel') }}</button><button type="submit" class="button button-issue">↗ {{ t('assets.issue') }}</button></div></form></dialog>{% endif %}
|
||||||
<script src="/static/js/issue-dialog.js"></script>
|
<script src="/static/js/issue-dialog.js?v={{ application_version() }}"></script>
|
||||||
<script src="/static/js/asset-inventory.js"></script>
|
<script src="/static/js/asset-inventory.js?v={{ application_version() }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -625,7 +625,7 @@
|
|||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if is_admin %}<dialog id="issue-dialog"><form method="post" id="issue-form"><input type="hidden" name="return_to" id="issue-return-to"><h2>{{ t('assets.issue_asset') }}</h2><label>{{ t('common.name') }}<input name="assigned_to" required></label><label>{{ t('common.date') }}<input type="date" name="assigned_on" value="{{ now().strftime('%Y-%m-%d') if now is defined else '' }}" required></label><div class="form-actions"><button type="button" onclick="this.closest('dialog').close()">{{ t('common.cancel') }}</button><button type="submit">↗ {{ t('assets.issue') }}</button></div></form></dialog>{% endif %}<script src="/static/js/issue-dialog.js"></script>
|
{% if is_admin %}<dialog id="issue-dialog"><form method="post" id="issue-form"><input type="hidden" name="return_to" id="issue-return-to"><h2>{{ t('assets.issue_asset') }}</h2><label>{{ t('common.name') }}<input name="assigned_to" required></label><label>{{ t('common.date') }}<input type="date" name="assigned_on" value="{{ now().strftime('%Y-%m-%d') if now is defined else '' }}" required></label><div class="form-actions"><button type="button" onclick="this.closest('dialog').close()">{{ t('common.cancel') }}</button><button type="submit">↗ {{ t('assets.issue') }}</button></div></form></dialog>{% endif %}<script src="/static/js/issue-dialog.js?v={{ application_version() }}"></script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(() => {
|
(() => {
|
||||||
|
|||||||
@@ -105,7 +105,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</aside>
|
</aside>
|
||||||
<main class="app-content">{% block content %}{% endblock %}</main>
|
<main class="app-content">{% block content %}{% endblock %}</main>
|
||||||
</div><div id="toast-container" class="toast-container" aria-live="polite"></div><script src="/static/js/local-time.js"></script><script src="/static/js/browser-state.js?v={{ app_version }}"></script><script src="/static/js/table-tools.js?v={{ app_version }}"></script><script src="/static/js/list-tools.js?v={{ app_version }}"></script><script src="/static/js/asset-columns.js?v={{ app_version }}"></script><script src="/static/js/asset-duplicates.js?v={{ app_version }}"></script><script src="/static/js/toasts.js"></script><script src="/static/js/image-editor.js?v={{ app_version }}"></script><script>
|
</div><div id="toast-container" class="toast-container" aria-live="polite"></div><script src="/static/js/local-time.js?v={{ application_version() }}"></script><script src="/static/js/browser-state.js?v={{ application_version() }}"></script><script src="/static/js/table-tools.js?v={{ application_version() }}"></script><script src="/static/js/list-tools.js?v={{ application_version() }}"></script><script src="/static/js/asset-columns.js?v={{ application_version() }}"></script><script src="/static/js/asset-duplicates.js?v={{ application_version() }}"></script><script src="/static/js/toasts.js?v={{ application_version() }}"></script><script src="/static/js/image-editor.js?v={{ application_version() }}"></script><script>
|
||||||
(() => {
|
(() => {
|
||||||
document.addEventListener('keydown', event => {
|
document.addEventListener('keydown', event => {
|
||||||
if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== 's') return;
|
if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== 's') return;
|
||||||
@@ -136,5 +136,5 @@
|
|||||||
document.documentElement.classList.toggle('sidebar-collapsed-preset', collapsed);
|
document.documentElement.classList.toggle('sidebar-collapsed-preset', collapsed);
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
</script><script src="/static/js/asset-page-boot.js"></script><script src="/static/js/presence.js"></script><script src="/static/js/file-picker.js?v={{ app_version }}"></script><script src="/static/js/software-inventory-selection.js?v={{ app_version }}"></script>
|
</script><script src="/static/js/asset-page-boot.js?v={{ application_version() }}"></script><script src="/static/js/presence.js?v={{ application_version() }}"></script><script src="/static/js/file-picker.js?v={{ application_version() }}"></script><script src="/static/js/software-inventory-selection.js?v={{ application_version() }}"></script>
|
||||||
</body></html>
|
</body></html>
|
||||||
|
|||||||
@@ -8,5 +8,5 @@
|
|||||||
<div class="table-scroll sticky-table"><table class="data-table" id="dashboard-asset-table"><thead><tr><th>{{ t('assets.name') }}</th><th>{{ t('assets.category') }}</th>{% for f in fields if f.field_name != 'name' %}<th>{{ t('field.' ~ f.field_name, f.label) }}</th>{% endfor %}<th data-no-sort="1" data-no-filter="1">{{ t('common.actions') }}</th></tr></thead><tbody>
|
<div class="table-scroll sticky-table"><table class="data-table" id="dashboard-asset-table"><thead><tr><th>{{ t('assets.name') }}</th><th>{{ t('assets.category') }}</th>{% for f in fields if f.field_name != 'name' %}<th>{{ t('field.' ~ f.field_name, f.label) }}</th>{% endfor %}<th data-no-sort="1" data-no-filter="1">{{ t('common.actions') }}</th></tr></thead><tbody>
|
||||||
{% for a in assets %}<tr><td><a href="/assets/{{a.id}}">{{a.name}}</a></td><td>{{a.category.name}}</td>{% for f in fields if f.field_name != 'name' %}<td class="multiline">{{a|attr(f.field_name) or ''}}</td>{% endfor %}<td class="row-actions">{% if is_admin %}{% if issue_status_name and a.status == issue_status_name %}<form class="inline-form" method="post" action="/assets/{{a.id}}/return"><input type="hidden" name="return_to" value="dashboard"><button class="icon-button button-return" title="{{ t('assets.return') }}">↩</button></form>{% else %}<button type="button" class="icon-button button-issue" data-issue-action="/assets/{{a.id}}/issue" data-return-to="dashboard" title="{{ t('assets.issue') }}">↗</button>{% endif %}<a class="icon-button button-duplicate" href="/assets/{{a.id}}/duplicate" title="{{ t('assets.duplicate') }}">⧉</a><form class="inline-form" method="post" action="/assets/{{a.id}}/delete" onsubmit="return confirm({{ t('assets.delete_confirm', name=a.name)|tojson }});"><button class="icon-button button-danger" title="{{ t('common.delete') }}">🗑</button></form>{% else %}<span class="muted">{{ t('common.read_only') }}</span>{% endif %}</td></tr>{% else %}<tr><td colspan="40" class="empty-state">{{ t('assets.none') }}</td></tr>{% endfor %}
|
{% for a in assets %}<tr><td><a href="/assets/{{a.id}}">{{a.name}}</a></td><td>{{a.category.name}}</td>{% for f in fields if f.field_name != 'name' %}<td class="multiline">{{a|attr(f.field_name) or ''}}</td>{% endfor %}<td class="row-actions">{% if is_admin %}{% if issue_status_name and a.status == issue_status_name %}<form class="inline-form" method="post" action="/assets/{{a.id}}/return"><input type="hidden" name="return_to" value="dashboard"><button class="icon-button button-return" title="{{ t('assets.return') }}">↩</button></form>{% else %}<button type="button" class="icon-button button-issue" data-issue-action="/assets/{{a.id}}/issue" data-return-to="dashboard" title="{{ t('assets.issue') }}">↗</button>{% endif %}<a class="icon-button button-duplicate" href="/assets/{{a.id}}/duplicate" title="{{ t('assets.duplicate') }}">⧉</a><form class="inline-form" method="post" action="/assets/{{a.id}}/delete" onsubmit="return confirm({{ t('assets.delete_confirm', name=a.name)|tojson }});"><button class="icon-button button-danger" title="{{ t('common.delete') }}">🗑</button></form>{% else %}<span class="muted">{{ t('common.read_only') }}</span>{% endif %}</td></tr>{% else %}<tr><td colspan="40" class="empty-state">{{ t('assets.none') }}</td></tr>{% endfor %}
|
||||||
</tbody></table></div>
|
</tbody></table></div>
|
||||||
{% if is_admin %}<dialog id="issue-dialog"><form method="post" id="issue-form"><input type="hidden" name="return_to" id="issue-return-to"><h2>{{ t('assets.issue_asset') }}</h2><label>{{ t('common.name') }}<input name="assigned_to" required></label><label>{{ t('common.date') }}<input type="date" name="assigned_on" required></label><div class="form-actions"><button type="button" onclick="this.closest('dialog').close()">{{ t('common.cancel') }}</button><button type="submit">↗ {{ t('assets.issue') }}</button></div></form></dialog>{% endif %}<script src="/static/js/issue-dialog.js"></script>
|
{% if is_admin %}<dialog id="issue-dialog"><form method="post" id="issue-form"><input type="hidden" name="return_to" id="issue-return-to"><h2>{{ t('assets.issue_asset') }}</h2><label>{{ t('common.name') }}<input name="assigned_to" required></label><label>{{ t('common.date') }}<input type="date" name="assigned_on" required></label><div class="form-actions"><button type="button" onclick="this.closest('dialog').close()">{{ t('common.cancel') }}</button><button type="submit">↗ {{ t('assets.issue') }}</button></div></form></dialog>{% endif %}<script src="/static/js/issue-dialog.js?v={{ application_version() }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -133,6 +133,6 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<script src="/static/js/software-job-selection.js?v={{ app_version }}"></script>
|
<script src="/static/js/software-job-selection.js?v={{ application_version() }}"></script>
|
||||||
<script src="/static/js/software-job-status.js?v={{ app_version }}"></script>
|
<script src="/static/js/software-job-status.js?v={{ application_version() }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
APP_VERSION = "0.5.5.91"
|
APP_VERSION = "0.5.5.92"
|
||||||
__version__ = APP_VERSION
|
__version__ = APP_VERSION
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-assetmanager}:${POSTGRES_PASSWORD:-change-me}@db:5432/${POSTGRES_DB:-assetmanager}
|
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-assetmanager}:${POSTGRES_PASSWORD:-change-me}@db:5432/${POSTGRES_DB:-assetmanager}
|
||||||
APP_TITLE: AssetManager
|
APP_TITLE: AssetManager
|
||||||
|
ASSETMANAGER_CONTAINER_ROLE: app
|
||||||
|
ASSETMANAGER_IMAGE_REF: ${ASSETMANAGER_IMAGE:-local-build}
|
||||||
|
ASSETMANAGER_BUILD_DATE: ${ASSETMANAGER_BUILD_DATE:-}
|
||||||
|
ASSETMANAGER_GIT_COMMIT: ${ASSETMANAGER_GIT_COMMIT:-}
|
||||||
ASSETMANAGER_DATA_ROOT: /assetmanager-data
|
ASSETMANAGER_DATA_ROOT: /assetmanager-data
|
||||||
APP_CONFIG: /assetmanager-data/config/config.json
|
APP_CONFIG: /assetmanager-data/config/config.json
|
||||||
APPINFO_PATH: /assetmanager-data/config/APPINFO.json
|
APPINFO_PATH: /assetmanager-data/config/APPINFO.json
|
||||||
@@ -74,6 +78,12 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-assetmanager}:${POSTGRES_PASSWORD:-change-me}@db:5432/${POSTGRES_DB:-assetmanager}
|
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-assetmanager}:${POSTGRES_PASSWORD:-change-me}@db:5432/${POSTGRES_DB:-assetmanager}
|
||||||
APP_TITLE: AssetManager
|
APP_TITLE: AssetManager
|
||||||
|
ASSETMANAGER_CONTAINER_ROLE: callback
|
||||||
|
ASSETMANAGER_IMAGE_REF: ${ASSETMANAGER_IMAGE:-local-build}
|
||||||
|
ASSETMANAGER_BUILD_DATE: ${ASSETMANAGER_BUILD_DATE:-}
|
||||||
|
ASSETMANAGER_GIT_COMMIT: ${ASSETMANAGER_GIT_COMMIT:-}
|
||||||
|
CALLBACK_BIND_IP: ${CALLBACK_BIND_IP:-127.0.0.1}
|
||||||
|
CALLBACK_PORT: ${CALLBACK_PORT:-8090}
|
||||||
ASSETMANAGER_DATA_ROOT: /assetmanager-data
|
ASSETMANAGER_DATA_ROOT: /assetmanager-data
|
||||||
APP_CONFIG: /assetmanager-data/config/config.json
|
APP_CONFIG: /assetmanager-data/config/config.json
|
||||||
APPINFO_PATH: /assetmanager-data/config/APPINFO.json
|
APPINFO_PATH: /assetmanager-data/config/APPINFO.json
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
- [0.5.5.92](UPDATE-0.5.5.92.md) - startup diagnostics with runtime, database and callback endpoint information.
|
||||||
- [0.5.5.91](UPDATE-0.5.5.91.md) - smoother table scrolling and consolidated current development changes.
|
- [0.5.5.91](UPDATE-0.5.5.91.md) - smoother table scrolling and consolidated current development changes.
|
||||||
- [0.5.5.90](UPDATE-0.5.5.90.md) - modular analyzer profiles, community repository foundation and portable package import/export.
|
- [0.5.5.90](UPDATE-0.5.5.90.md) - modular analyzer profiles, community repository foundation and portable package import/export.
|
||||||
- [0.5.5.89](UPDATE-0.5.5.89.md) - recursively analyze supported SFX payloads and package selected embedded installers.
|
- [0.5.5.89](UPDATE-0.5.5.89.md) - recursively analyze supported SFX payloads and package selected embedded installers.
|
||||||
@@ -27,7 +28,7 @@
|
|||||||
|
|
||||||
Release notes are stored outside the project root to keep the repository overview compact.
|
Release notes are stored outside the project root to keep the repository overview compact.
|
||||||
|
|
||||||
The current release is **0.5.5.91**.
|
The current release is **0.5.5.92**.
|
||||||
|
|
||||||
Older notes are concise English summaries migrated from the original release documents. Git history remains authoritative for exact implementation details.
|
Older notes are concise English summaries migrated from the original release documents. Git history remains authoritative for exact implementation details.
|
||||||
|
|
||||||
|
|||||||
Executable
+15
@@ -0,0 +1,15 @@
|
|||||||
|
# Update 0.5.5.92
|
||||||
|
|
||||||
|
Version 0.5.5.92 adds structured startup diagnostics for the AssetManager application and callback containers.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Added a clearly delimited startup block to Docker/container logs for the application service.
|
||||||
|
- Added a dedicated startup block to the callback-only service.
|
||||||
|
- Startup diagnostics report the running AssetManager/build version, Python and core runtime package versions.
|
||||||
|
- The actual connected PostgreSQL server version is queried and logged at startup.
|
||||||
|
- Callback startup diagnostics show the configured public callback base/API/health URLs and the internal/published listener information.
|
||||||
|
- Missing build metadata is reported as `not provided`; no credentials, tokens or database passwords are logged.
|
||||||
|
- Version updated to 0.5.5.92.
|
||||||
|
|
||||||
|
Version 0.5.5.91 remains the stabilization baseline for table scrolling, JSON query handling, static cache busting, remote-script preflight retries and serialized software-inventory MeshCtrl execution.
|
||||||
Reference in New Issue
Block a user