fix: service credential provisioning and install reliability
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:
2026-06-07 13:41:41 -04:00
parent c696ca9ef6
commit 9bdda6aaf8
3 changed files with 83 additions and 2 deletions
+41
View File
@@ -10,6 +10,7 @@ import subprocess
import logging import logging
from datetime import datetime from datetime import datetime
from typing import Dict, List, Optional, Any from typing import Dict, List, Optional, Any
import bcrypt
from base_service_manager import BaseServiceManager from base_service_manager import BaseServiceManager
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -280,12 +281,51 @@ class CalendarManager(BaseServiceManager):
user_dir = os.path.join(self.calendar_data_dir, 'users', username) user_dir = os.path.join(self.calendar_data_dir, 'users', username)
self.safe_makedirs(user_dir) 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}") logger.info(f"Created calendar user: {username}")
return True return True
except Exception as e: except Exception as e:
logger.error(f"Failed to create calendar user {username}: {e}") logger.error(f"Failed to create calendar user {username}: {e}")
return False 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: def delete_calendar_user(self, username: str) -> bool:
"""Delete a calendar user""" """Delete a calendar user"""
try: try:
@@ -306,6 +346,7 @@ class CalendarManager(BaseServiceManager):
import shutil import shutil
shutil.rmtree(user_dir) shutil.rmtree(user_dir)
self._remove_radicale_htpasswd(username)
logger.info(f"Deleted calendar user: {username}") logger.info(f"Deleted calendar user: {username}")
return True return True
+28
View File
@@ -340,12 +340,39 @@ class EmailManager(BaseServiceManager):
mailbox_dir = os.path.join(self.email_data_dir, 'mailboxes', f'{username}@{domain}') mailbox_dir = os.path.join(self.email_data_dir, 'mailboxes', f'{username}@{domain}')
self.safe_makedirs(mailbox_dir) 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}") logger.info(f"Created email user: {username}@{domain}")
return True return True
except Exception as e: except Exception as e:
logger.error(f"Failed to create email user {username}@{domain}: {e}") logger.error(f"Failed to create email user {username}@{domain}: {e}")
return False 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: def delete_email_user(self, username: str, domain: str) -> bool:
"""Delete an email user""" """Delete an email user"""
try: try:
@@ -366,6 +393,7 @@ class EmailManager(BaseServiceManager):
import shutil import shutil
shutil.rmtree(mailbox_dir) shutil.rmtree(mailbox_dir)
self._dms_del_account(username, domain)
logger.info(f"Deleted email user: {username}@{domain}") logger.info(f"Deleted email user: {username}@{domain}")
return True return True
+14 -2
View File
@@ -253,9 +253,21 @@ class ServiceComposer:
def install(self, service_id: str, manifest: Dict, def install(self, service_id: str, manifest: Dict,
template_content: str) -> 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) 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: def remove(self, service_id: str, purge_data: bool = False) -> Dict:
"""Stop containers, optionally delete compose file, secrets, and service data dir.""" """Stop containers, optionally delete compose file, secrets, and service data dir."""