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
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
+28
View File
@@ -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
View File
@@ -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."""