fix: service credential provisioning and install reliability
Unit Tests / test (push) Successful in 7m21s
Unit Tests / test (push) Successful in 7m21s
- calendar: create_calendar_user() now writes bcrypt htpasswd entry to data/services/calendar/config/users (the path Radicale reads at /etc/radicale/users); delete_calendar_user() removes the entry - email: create_email_user() calls `docker exec cell-mail setup email add` to register the account in docker-mailserver's Dovecot/Postfix store; delete_email_user() calls the matching `setup email del` — both are non-fatal if the container isn't running - service_composer.install(): pull image separately before up so slow registry pulls don't race with container startup; retry up once on failure so a transient registry hiccup on first install doesn't require the user to manually retry Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@ import subprocess
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional, Any
|
||||
import bcrypt
|
||||
from base_service_manager import BaseServiceManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -280,12 +281,51 @@ class CalendarManager(BaseServiceManager):
|
||||
user_dir = os.path.join(self.calendar_data_dir, 'users', username)
|
||||
self.safe_makedirs(user_dir)
|
||||
|
||||
# Write bcrypt entry to Radicale htpasswd (non-fatal if service not installed)
|
||||
self._write_radicale_htpasswd(username, password)
|
||||
|
||||
logger.info(f"Created calendar user: {username}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create calendar user {username}: {e}")
|
||||
return False
|
||||
|
||||
def _radicale_htpasswd_path(self) -> str:
|
||||
return os.path.join(self.data_dir, 'services', 'calendar', 'config', 'users')
|
||||
|
||||
def _write_radicale_htpasswd(self, username: str, password: str) -> None:
|
||||
htpasswd = self._radicale_htpasswd_path()
|
||||
config_dir = os.path.dirname(htpasswd)
|
||||
if not os.path.isdir(config_dir):
|
||||
return
|
||||
try:
|
||||
raw = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
|
||||
if raw.startswith('$2b$'):
|
||||
raw = '$2y$' + raw[4:]
|
||||
lines = []
|
||||
if os.path.exists(htpasswd):
|
||||
with open(htpasswd) as f:
|
||||
lines = f.readlines()
|
||||
lines = [l for l in lines if not l.startswith(f'{username}:')]
|
||||
lines.append(f'{username}:{raw}\n')
|
||||
with open(htpasswd, 'w') as f:
|
||||
f.writelines(lines)
|
||||
except Exception as e:
|
||||
logger.warning('Failed to write Radicale htpasswd for %s: %s', username, e)
|
||||
|
||||
def _remove_radicale_htpasswd(self, username: str) -> None:
|
||||
htpasswd = self._radicale_htpasswd_path()
|
||||
if not os.path.exists(htpasswd):
|
||||
return
|
||||
try:
|
||||
with open(htpasswd) as f:
|
||||
lines = f.readlines()
|
||||
lines = [l for l in lines if not l.startswith(f'{username}:')]
|
||||
with open(htpasswd, 'w') as f:
|
||||
f.writelines(lines)
|
||||
except Exception as e:
|
||||
logger.warning('Failed to remove Radicale htpasswd for %s: %s', username, e)
|
||||
|
||||
def delete_calendar_user(self, username: str) -> bool:
|
||||
"""Delete a calendar user"""
|
||||
try:
|
||||
@@ -306,6 +346,7 @@ class CalendarManager(BaseServiceManager):
|
||||
import shutil
|
||||
shutil.rmtree(user_dir)
|
||||
|
||||
self._remove_radicale_htpasswd(username)
|
||||
logger.info(f"Deleted calendar user: {username}")
|
||||
return True
|
||||
|
||||
|
||||
@@ -340,12 +340,39 @@ class EmailManager(BaseServiceManager):
|
||||
mailbox_dir = os.path.join(self.email_data_dir, 'mailboxes', f'{username}@{domain}')
|
||||
self.safe_makedirs(mailbox_dir)
|
||||
|
||||
# Provision account in docker-mailserver (non-fatal if container not running)
|
||||
self._dms_add_account(username, domain, password)
|
||||
|
||||
logger.info(f"Created email user: {username}@{domain}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create email user {username}@{domain}: {e}")
|
||||
return False
|
||||
|
||||
def _dms_add_account(self, username: str, domain: str, password: str) -> None:
|
||||
try:
|
||||
r = subprocess.run(
|
||||
['docker', 'exec', 'cell-mail', 'setup', 'email', 'add',
|
||||
f'{username}@{domain}', password],
|
||||
capture_output=True, text=True, timeout=30, check=False,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
logger.warning('dms add account %s@%s: %s', username, domain, r.stderr.strip())
|
||||
except Exception as e:
|
||||
logger.warning('dms add account %s@%s failed (non-fatal): %s', username, domain, e)
|
||||
|
||||
def _dms_del_account(self, username: str, domain: str) -> None:
|
||||
try:
|
||||
r = subprocess.run(
|
||||
['docker', 'exec', 'cell-mail', 'setup', 'email', 'del',
|
||||
f'{username}@{domain}'],
|
||||
capture_output=True, text=True, timeout=30, check=False,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
logger.warning('dms del account %s@%s: %s', username, domain, r.stderr.strip())
|
||||
except Exception as e:
|
||||
logger.warning('dms del account %s@%s failed (non-fatal): %s', username, domain, e)
|
||||
|
||||
def delete_email_user(self, username: str, domain: str) -> bool:
|
||||
"""Delete an email user"""
|
||||
try:
|
||||
@@ -366,6 +393,7 @@ class EmailManager(BaseServiceManager):
|
||||
import shutil
|
||||
shutil.rmtree(mailbox_dir)
|
||||
|
||||
self._dms_del_account(username, domain)
|
||||
logger.info(f"Deleted email user: {username}@{domain}")
|
||||
return True
|
||||
|
||||
|
||||
+14
-2
@@ -253,9 +253,21 @@ class ServiceComposer:
|
||||
|
||||
def install(self, service_id: str, manifest: Dict,
|
||||
template_content: str) -> Dict:
|
||||
"""Write compose file and start containers."""
|
||||
"""Write compose file, pull image, then start containers.
|
||||
|
||||
pull is run first so the up step doesn't time out on slow connections.
|
||||
A single retry handles transient registry hiccups on first install.
|
||||
"""
|
||||
self.write_compose(service_id, manifest, template_content)
|
||||
return self.up(service_id)
|
||||
pull = self._store_cmd(service_id, 'pull', timeout=600)
|
||||
if not pull.get('ok'):
|
||||
logger.warning('service_composer: image pull for %s failed, proceeding anyway: %s',
|
||||
service_id, pull.get('stderr', '')[:200])
|
||||
result = self.up(service_id)
|
||||
if not result.get('ok'):
|
||||
logger.info('service_composer: retrying up for %s after initial failure', service_id)
|
||||
result = self.up(service_id)
|
||||
return result
|
||||
|
||||
def remove(self, service_id: str, purge_data: bool = False) -> Dict:
|
||||
"""Stop containers, optionally delete compose file, secrets, and service data dir."""
|
||||
|
||||
Reference in New Issue
Block a user