667 lines
21 KiB
Python
667 lines
21 KiB
Python
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timezone
|
|
from typing import Any
|
|
import textwrap
|
|
|
|
import httpx
|
|
|
|
from ..k8s.exec import ExecError, PodExecutor
|
|
from ..k8s.pods import PodSelectionError
|
|
from ..settings import settings
|
|
from ..utils.logging import get_logger
|
|
from ..utils.passwords import random_password
|
|
from .keycloak_admin import keycloak_admin
|
|
from .mailu import mailu
|
|
|
|
|
|
HTTP_OK = 200
|
|
EXIT_PASSWORD_MATCH = 0
|
|
EXIT_PASSWORD_MISMATCH = 1
|
|
EXIT_USER_MISSING = 3
|
|
FIREFLY_PASSWORD_ATTR = "firefly_password"
|
|
FIREFLY_PASSWORD_UPDATED_ATTR = "firefly_password_updated_at"
|
|
FIREFLY_PASSWORD_ROTATED_ATTR = "firefly_password_rotated_at"
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
|
|
_FIREFLY_SYNC_SCRIPT = textwrap.dedent(
|
|
"""
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use FireflyIII\\Console\\Commands\\Correction\\CreatesGroupMemberships;
|
|
use FireflyIII\\Models\\Role;
|
|
use FireflyIII\\Repositories\\User\\UserRepositoryInterface;
|
|
use FireflyIII\\Support\\Facades\\FireflyConfig;
|
|
use FireflyIII\\User;
|
|
use Illuminate\\Contracts\\Console\\Kernel as ConsoleKernel;
|
|
|
|
function log_line(string $message): void
|
|
{
|
|
fwrite(STDOUT, $message . PHP_EOL);
|
|
}
|
|
|
|
function error_line(string $message): void
|
|
{
|
|
fwrite(STDERR, $message . PHP_EOL);
|
|
}
|
|
|
|
function find_app_root(): string
|
|
{
|
|
$candidates = [];
|
|
$env_root = getenv('FIREFLY_APP_DIR') ?: '';
|
|
if ($env_root !== '') {
|
|
$candidates[] = $env_root;
|
|
}
|
|
$candidates[] = '/var/www/html';
|
|
$candidates[] = '/var/www/firefly-iii';
|
|
$candidates[] = '/app';
|
|
|
|
foreach ($candidates as $candidate) {
|
|
if (!is_dir($candidate)) {
|
|
continue;
|
|
}
|
|
if (file_exists($candidate . '/vendor/autoload.php')) {
|
|
return $candidate;
|
|
}
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
$email = trim((string) getenv('FIREFLY_USER_EMAIL'));
|
|
$password = (string) getenv('FIREFLY_USER_PASSWORD');
|
|
|
|
if ($email === '' || $password === '') {
|
|
error_line('missing FIREFLY_USER_EMAIL or FIREFLY_USER_PASSWORD');
|
|
exit(1);
|
|
}
|
|
|
|
$root = find_app_root();
|
|
if ($root === '') {
|
|
error_line('firefly app root not found');
|
|
exit(1);
|
|
}
|
|
|
|
$autoload = $root . '/vendor/autoload.php';
|
|
$app_bootstrap = $root . '/bootstrap/app.php';
|
|
|
|
if (!file_exists($autoload) || !file_exists($app_bootstrap)) {
|
|
error_line('firefly bootstrap files missing');
|
|
exit(1);
|
|
}
|
|
|
|
require $autoload;
|
|
$app = require $app_bootstrap;
|
|
|
|
$kernel = $app->make(ConsoleKernel::class);
|
|
$kernel->bootstrap();
|
|
|
|
try {
|
|
FireflyConfig::set('single_user_mode', true);
|
|
} catch (Throwable $exc) {
|
|
error_line('failed to enforce single_user_mode: ' . $exc->getMessage());
|
|
}
|
|
|
|
$repository = $app->make(UserRepositoryInterface::class);
|
|
|
|
$existing_user = User::where('email', $email)->first();
|
|
$first_user = User::count() == 0;
|
|
|
|
if (!$existing_user) {
|
|
$existing_user = User::create(
|
|
[
|
|
'email' => $email,
|
|
'password' => bcrypt($password),
|
|
'blocked' => false,
|
|
'blocked_code' => null,
|
|
]
|
|
);
|
|
|
|
if ($first_user) {
|
|
$role = Role::where('name', 'owner')->first();
|
|
if ($role) {
|
|
$existing_user->roles()->attach($role);
|
|
}
|
|
}
|
|
|
|
log_line(sprintf('created firefly user %s', $email));
|
|
} else {
|
|
log_line(sprintf('updating firefly user %s', $email));
|
|
}
|
|
|
|
$existing_user->blocked = false;
|
|
$existing_user->blocked_code = null;
|
|
$existing_user->save();
|
|
|
|
$repository->changePassword($existing_user, $password);
|
|
CreatesGroupMemberships::createGroupMembership($existing_user);
|
|
|
|
log_line('firefly user sync complete');
|
|
"""
|
|
).strip()
|
|
|
|
_FIREFLY_PASSWORD_CHECK_SCRIPT = textwrap.dedent(
|
|
"""
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use FireflyIII\\Support\\Facades\\FireflyConfig;
|
|
use FireflyIII\\User;
|
|
use Illuminate\\Contracts\\Console\\Kernel as ConsoleKernel;
|
|
use Illuminate\\Support\\Facades\\Hash;
|
|
|
|
function log_line(string $message): void
|
|
{
|
|
fwrite(STDOUT, $message . PHP_EOL);
|
|
}
|
|
|
|
function error_line(string $message): void
|
|
{
|
|
fwrite(STDERR, $message . PHP_EOL);
|
|
}
|
|
|
|
function find_app_root(): string
|
|
{
|
|
$candidates = [];
|
|
$env_root = getenv('FIREFLY_APP_DIR') ?: '';
|
|
if ($env_root !== '') {
|
|
$candidates[] = $env_root;
|
|
}
|
|
$candidates[] = '/var/www/html';
|
|
$candidates[] = '/var/www/firefly-iii';
|
|
$candidates[] = '/app';
|
|
|
|
foreach ($candidates as $candidate) {
|
|
if (!is_dir($candidate)) {
|
|
continue;
|
|
}
|
|
if (file_exists($candidate . '/vendor/autoload.php')) {
|
|
return $candidate;
|
|
}
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
$email = trim((string) getenv('FIREFLY_USER_EMAIL'));
|
|
$username = trim((string) getenv('FIREFLY_USER_USERNAME'));
|
|
$password = (string) getenv('FIREFLY_USER_PASSWORD');
|
|
|
|
if (($email === '' && $username === '') || $password === '') {
|
|
error_line('missing FIREFLY_USER_EMAIL or FIREFLY_USER_USERNAME or FIREFLY_USER_PASSWORD');
|
|
exit(2);
|
|
}
|
|
|
|
$root = find_app_root();
|
|
if ($root === '') {
|
|
error_line('firefly app root not found');
|
|
exit(2);
|
|
}
|
|
|
|
$autoload = $root . '/vendor/autoload.php';
|
|
$app_bootstrap = $root . '/bootstrap/app.php';
|
|
|
|
if (!file_exists($autoload) || !file_exists($app_bootstrap)) {
|
|
error_line('firefly bootstrap files missing');
|
|
exit(2);
|
|
}
|
|
|
|
require $autoload;
|
|
$app = require $app_bootstrap;
|
|
|
|
$kernel = $app->make(ConsoleKernel::class);
|
|
$kernel->bootstrap();
|
|
|
|
try {
|
|
FireflyConfig::set('single_user_mode', true);
|
|
} catch (Throwable $exc) {
|
|
error_line('failed to enforce single_user_mode: ' . $exc->getMessage());
|
|
}
|
|
|
|
if ($email !== '') {
|
|
$query = User::where('email', $email);
|
|
} else {
|
|
$query = User::where('username', $username);
|
|
}
|
|
|
|
if ($email !== '' && $username !== '') {
|
|
$query = $query->orWhere('username', $username);
|
|
}
|
|
|
|
$existing_user = $query->first();
|
|
if (!$existing_user) {
|
|
error_line('firefly user missing');
|
|
exit(3);
|
|
}
|
|
|
|
if (Hash::check($password, $existing_user->password)) {
|
|
log_line('password match');
|
|
exit(0);
|
|
}
|
|
|
|
log_line('password mismatch');
|
|
exit(1);
|
|
"""
|
|
).strip()
|
|
|
|
|
|
def _firefly_exec_command() -> str:
|
|
return f"php <<'PHP'\n{_FIREFLY_SYNC_SCRIPT}\nPHP"
|
|
|
|
|
|
def _firefly_check_command() -> str:
|
|
return f"php <<'PHP'\n{_FIREFLY_PASSWORD_CHECK_SCRIPT}\nPHP"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class FireflySyncSummary:
|
|
processed: int
|
|
synced: int
|
|
skipped: int
|
|
failures: int
|
|
detail: str = ""
|
|
|
|
|
|
@dataclass
|
|
class FireflySyncCounters:
|
|
processed: int = 0
|
|
synced: int = 0
|
|
skipped: int = 0
|
|
failures: int = 0
|
|
|
|
def status(self) -> str:
|
|
return "ok" if self.failures == 0 else "error"
|
|
|
|
def summary(self, detail: str = "") -> FireflySyncSummary:
|
|
return FireflySyncSummary(
|
|
processed=self.processed,
|
|
synced=self.synced,
|
|
skipped=self.skipped,
|
|
failures=self.failures,
|
|
detail=detail,
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class UserSyncOutcome:
|
|
status: str
|
|
detail: str = ""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class UserIdentity:
|
|
username: str
|
|
user_id: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class FireflySyncInput:
|
|
username: str
|
|
mailu_email: str
|
|
password: str
|
|
password_generated: bool
|
|
updated_at: str
|
|
rotated_at: str
|
|
|
|
|
|
def _extract_attr(attrs: Any, key: str) -> str:
|
|
if not isinstance(attrs, dict):
|
|
return ""
|
|
raw = attrs.get(key)
|
|
if isinstance(raw, list):
|
|
for item in raw:
|
|
if isinstance(item, str) and item.strip():
|
|
return item.strip()
|
|
return ""
|
|
if isinstance(raw, str) and raw.strip():
|
|
return raw.strip()
|
|
return ""
|
|
|
|
|
|
def _should_skip_user(user: dict[str, Any], username: str) -> bool:
|
|
if not username or user.get("enabled") is False:
|
|
return True
|
|
if user.get("serviceAccountClientId") or username.startswith("service-account-"):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _load_attrs(user_id: str, user: dict[str, Any]) -> dict[str, Any] | None:
|
|
attrs = user.get("attributes")
|
|
if isinstance(attrs, dict):
|
|
return attrs
|
|
try:
|
|
full = keycloak_admin.get_user(user_id)
|
|
except Exception:
|
|
return None
|
|
attrs = full.get("attributes") if isinstance(full, dict) else {}
|
|
return attrs if isinstance(attrs, dict) else {}
|
|
|
|
|
|
def _ensure_mailu_email(username: str, attrs: dict[str, Any], fallback_email: str) -> str | None:
|
|
mailu_email = mailu.resolve_mailu_email(username, attrs, fallback_email)
|
|
if not mailu_email:
|
|
return None
|
|
if _extract_attr(attrs, "mailu_email"):
|
|
return mailu_email
|
|
try:
|
|
keycloak_admin.set_user_attribute(username, "mailu_email", mailu_email)
|
|
except Exception:
|
|
return None
|
|
return mailu_email
|
|
|
|
|
|
def _ensure_firefly_password(username: str, attrs: dict[str, Any]) -> tuple[str | None, bool]:
|
|
password = _extract_attr(attrs, FIREFLY_PASSWORD_ATTR)
|
|
if password:
|
|
return password, False
|
|
password = random_password(24)
|
|
try:
|
|
keycloak_admin.set_user_attribute(username, FIREFLY_PASSWORD_ATTR, password)
|
|
except Exception:
|
|
return None, False
|
|
return password, True
|
|
|
|
|
|
def _set_firefly_updated_at(username: str) -> bool:
|
|
now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
try:
|
|
keycloak_admin.set_user_attribute(username, FIREFLY_PASSWORD_UPDATED_ATTR, now_iso)
|
|
except Exception:
|
|
return False
|
|
return True
|
|
|
|
|
|
def _set_firefly_rotated_at(username: str) -> bool:
|
|
now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
try:
|
|
keycloak_admin.set_user_attribute(username, FIREFLY_PASSWORD_ROTATED_ATTR, now_iso)
|
|
except Exception:
|
|
return False
|
|
return True
|
|
|
|
|
|
def _normalize_user(user: dict[str, Any]) -> tuple[UserSyncOutcome | None, UserIdentity | None]:
|
|
username = user.get("username") if isinstance(user.get("username"), str) else ""
|
|
if _should_skip_user(user, username):
|
|
return UserSyncOutcome("skipped"), None
|
|
user_id = user.get("id") if isinstance(user.get("id"), str) else ""
|
|
if not user_id:
|
|
return UserSyncOutcome("failed", "missing user id"), None
|
|
return None, UserIdentity(username, user_id)
|
|
|
|
|
|
def _load_attrs_or_outcome(
|
|
identity: UserIdentity,
|
|
user: dict[str, Any],
|
|
) -> tuple[dict[str, Any] | None, UserSyncOutcome | None]:
|
|
attrs = _load_attrs(identity.user_id, user)
|
|
if attrs is None:
|
|
return None, UserSyncOutcome("failed", "missing attributes")
|
|
return attrs, None
|
|
|
|
|
|
def _mailu_email_or_outcome(
|
|
identity: UserIdentity,
|
|
attrs: dict[str, Any],
|
|
user: dict[str, Any],
|
|
) -> tuple[str | None, UserSyncOutcome | None]:
|
|
mailu_email = _ensure_mailu_email(
|
|
identity.username,
|
|
attrs,
|
|
user.get("email") if isinstance(user.get("email"), str) else "",
|
|
)
|
|
if not mailu_email:
|
|
return None, UserSyncOutcome("failed", "missing mailu email")
|
|
return mailu_email, None
|
|
|
|
|
|
def _firefly_password_or_outcome(
|
|
identity: UserIdentity,
|
|
attrs: dict[str, Any],
|
|
) -> tuple[str | None, bool, UserSyncOutcome | None]:
|
|
password, generated = _ensure_firefly_password(identity.username, attrs)
|
|
if not password:
|
|
return None, generated, UserSyncOutcome("failed", "missing firefly password")
|
|
return password, generated, None
|
|
|
|
|
|
def _should_skip_sync(password_generated: bool, updated_at: str) -> bool:
|
|
return not password_generated and bool(updated_at)
|
|
|
|
|
|
def _build_sync_input(user: dict[str, Any]) -> FireflySyncInput | UserSyncOutcome:
|
|
outcome, identity = _normalize_user(user)
|
|
attrs = None
|
|
mailu_email = None
|
|
password = None
|
|
password_generated = False
|
|
|
|
if outcome is None and identity is not None:
|
|
attrs, outcome = _load_attrs_or_outcome(identity, user)
|
|
if outcome is None and attrs is not None:
|
|
mailu_email, outcome = _mailu_email_or_outcome(identity, attrs, user)
|
|
if outcome is None and mailu_email:
|
|
password, password_generated, outcome = _firefly_password_or_outcome(identity, attrs)
|
|
|
|
if outcome:
|
|
return outcome
|
|
if identity is None:
|
|
return UserSyncOutcome("failed", "missing identity")
|
|
if attrs is None:
|
|
return UserSyncOutcome("failed", "missing attributes")
|
|
if not mailu_email:
|
|
return UserSyncOutcome("failed", "missing mailu email")
|
|
if not password:
|
|
return UserSyncOutcome("failed", "missing firefly password")
|
|
|
|
updated_at = _extract_attr(attrs, FIREFLY_PASSWORD_UPDATED_ATTR)
|
|
rotated_at = _extract_attr(attrs, FIREFLY_PASSWORD_ROTATED_ATTR)
|
|
return FireflySyncInput(
|
|
username=identity.username,
|
|
mailu_email=mailu_email,
|
|
password=password,
|
|
password_generated=password_generated,
|
|
updated_at=updated_at,
|
|
rotated_at=rotated_at,
|
|
)
|
|
|
|
|
|
def _rotation_result(status: str, detail: str = "", rotated: bool | None = None) -> dict[str, Any]:
|
|
result = {"status": status}
|
|
if status == "ok":
|
|
result["rotated"] = bool(rotated)
|
|
elif detail:
|
|
result["detail"] = detail
|
|
return result
|
|
|
|
|
|
def _rotation_check_input(username: str) -> tuple[FireflySyncInput | UserSyncOutcome | None, str]:
|
|
if not username:
|
|
return None, "missing username"
|
|
if not keycloak_admin.ready():
|
|
return None, "keycloak admin not configured"
|
|
user = keycloak_admin.find_user(username)
|
|
if not isinstance(user, dict):
|
|
return None, "user not found"
|
|
user_id = user.get("id") if isinstance(user.get("id"), str) else ""
|
|
if not user_id:
|
|
return None, "missing user id"
|
|
full = keycloak_admin.get_user(user_id)
|
|
return _build_sync_input(full), ""
|
|
|
|
|
|
class FireflyService:
|
|
def __init__(self) -> None:
|
|
self._executor = PodExecutor(
|
|
settings.firefly_namespace,
|
|
settings.firefly_pod_label,
|
|
settings.firefly_container,
|
|
)
|
|
|
|
def check_rotation_for_user(self, username: str) -> dict[str, Any]:
|
|
cleaned = (username or "").strip()
|
|
prepared, error = _rotation_check_input(cleaned)
|
|
if error:
|
|
return _rotation_result("error", error)
|
|
if isinstance(prepared, UserSyncOutcome):
|
|
if prepared.status == "skipped":
|
|
return _rotation_result("ok", rotated=False)
|
|
return _rotation_result("error", prepared.detail or "")
|
|
outcome = self._rotation_outcome(prepared)
|
|
if outcome.status == "synced":
|
|
return _rotation_result("ok", rotated=True)
|
|
if outcome.status == "skipped":
|
|
return _rotation_result("ok", rotated=False)
|
|
return _rotation_result("error", outcome.detail or "rotation check failed")
|
|
|
|
def sync_user(self, email: str, password: str, wait: bool = True) -> dict[str, Any]:
|
|
email = (email or "").strip()
|
|
if not email:
|
|
raise RuntimeError("missing email")
|
|
if not password:
|
|
raise RuntimeError("missing password")
|
|
if not settings.firefly_namespace:
|
|
raise RuntimeError("firefly sync not configured")
|
|
|
|
env = {
|
|
"FIREFLY_USER_EMAIL": email,
|
|
"FIREFLY_USER_PASSWORD": password,
|
|
}
|
|
|
|
try:
|
|
result = self._executor.exec(
|
|
_firefly_exec_command(),
|
|
env=env,
|
|
timeout_sec=settings.firefly_user_sync_wait_timeout_sec,
|
|
check=True,
|
|
)
|
|
except (ExecError, PodSelectionError, TimeoutError) as exc:
|
|
return {"status": "error", "detail": str(exc)}
|
|
|
|
output = (result.stdout or result.stderr).strip()
|
|
return {"status": "ok", "detail": output}
|
|
|
|
def check_password(self, email: str, password: str, username: str = "") -> dict[str, Any]:
|
|
email = (email or "").strip()
|
|
username = (username or "").strip()
|
|
if not email:
|
|
if not username:
|
|
raise RuntimeError("missing email")
|
|
if not password:
|
|
raise RuntimeError("missing password")
|
|
if not settings.firefly_namespace:
|
|
raise RuntimeError("firefly sync not configured")
|
|
|
|
env = {
|
|
"FIREFLY_USER_EMAIL": email,
|
|
"FIREFLY_USER_USERNAME": username,
|
|
"FIREFLY_USER_PASSWORD": password,
|
|
}
|
|
|
|
try:
|
|
result = self._executor.exec(
|
|
_firefly_check_command(),
|
|
env=env,
|
|
timeout_sec=settings.firefly_user_sync_wait_timeout_sec,
|
|
check=False,
|
|
)
|
|
except (ExecError, PodSelectionError, TimeoutError) as exc:
|
|
return {"status": "error", "detail": str(exc)}
|
|
|
|
detail = (result.stdout or result.stderr).strip()
|
|
if result.exit_code == EXIT_PASSWORD_MATCH:
|
|
return {"status": "match", "detail": detail}
|
|
if result.exit_code == EXIT_PASSWORD_MISMATCH:
|
|
return {"status": "mismatch", "detail": detail}
|
|
if result.exit_code == EXIT_USER_MISSING:
|
|
return {"status": "missing", "detail": detail or "user missing"}
|
|
return {"status": "error", "detail": detail or f"exit_code={result.exit_code}"}
|
|
|
|
def _rotation_outcome(self, prepared: FireflySyncInput) -> UserSyncOutcome:
|
|
if prepared.rotated_at:
|
|
return UserSyncOutcome("skipped")
|
|
check = self.check_password(prepared.mailu_email, prepared.password, prepared.username)
|
|
status = check.get("status") if isinstance(check, dict) else "error"
|
|
if status == "match":
|
|
return UserSyncOutcome("skipped")
|
|
if status == "mismatch":
|
|
if not _set_firefly_rotated_at(prepared.username):
|
|
return UserSyncOutcome("failed", "failed to set rotated_at")
|
|
return UserSyncOutcome("synced")
|
|
detail = check.get("detail") if isinstance(check, dict) else ""
|
|
detail = detail or "password check failed"
|
|
return UserSyncOutcome("failed", detail)
|
|
|
|
def run_cron(self) -> dict[str, Any]:
|
|
if not settings.firefly_cron_token:
|
|
raise RuntimeError("firefly cron token missing")
|
|
url = f"{settings.firefly_cron_base_url.rstrip('/')}/{settings.firefly_cron_token}"
|
|
try:
|
|
with httpx.Client(timeout=settings.firefly_cron_timeout_sec) as client:
|
|
resp = client.get(url)
|
|
if resp.status_code != HTTP_OK:
|
|
return {"status": "error", "detail": f"status={resp.status_code}"}
|
|
except Exception as exc:
|
|
return {"status": "error", "detail": str(exc)}
|
|
return {"status": "ok", "detail": "cron triggered"}
|
|
|
|
def _sync_user_entry(self, user: dict[str, Any]) -> UserSyncOutcome:
|
|
prepared = _build_sync_input(user)
|
|
if isinstance(prepared, UserSyncOutcome):
|
|
return prepared
|
|
outcome = None
|
|
if _should_skip_sync(prepared.password_generated, prepared.updated_at):
|
|
outcome = self._rotation_outcome(prepared)
|
|
if outcome is None:
|
|
result = self.sync_user(prepared.mailu_email, prepared.password, wait=True)
|
|
result_status = result.get("status") if isinstance(result, dict) else "error"
|
|
if result_status != "ok":
|
|
outcome = UserSyncOutcome("failed", f"sync {result_status}")
|
|
elif not _set_firefly_updated_at(prepared.username):
|
|
outcome = UserSyncOutcome("failed", "failed to set updated_at")
|
|
else:
|
|
outcome = UserSyncOutcome("synced")
|
|
return outcome
|
|
|
|
def sync_users(self) -> dict[str, Any]:
|
|
if not keycloak_admin.ready():
|
|
return {"status": "error", "detail": "keycloak admin not configured"}
|
|
if not settings.firefly_namespace:
|
|
raise RuntimeError("firefly sync not configured")
|
|
|
|
counters = FireflySyncCounters()
|
|
users = keycloak_admin.iter_users(page_size=200, brief=False)
|
|
for user in users:
|
|
outcome = self._sync_user_entry(user)
|
|
if outcome.status == "synced":
|
|
counters.processed += 1
|
|
counters.synced += 1
|
|
elif outcome.status == "skipped":
|
|
counters.skipped += 1
|
|
else:
|
|
counters.failures += 1
|
|
|
|
summary = counters.summary()
|
|
logger.info(
|
|
"firefly user sync finished",
|
|
extra={
|
|
"event": "firefly_user_sync",
|
|
"status": counters.status(),
|
|
"processed": summary.processed,
|
|
"synced": summary.synced,
|
|
"skipped": summary.skipped,
|
|
"failures": summary.failures,
|
|
},
|
|
)
|
|
return {"status": counters.status(), "summary": summary}
|
|
|
|
|
|
firefly = FireflyService()
|