Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Selective restoration of IMAP folders #139

Merged
merged 8 commits into from
Dec 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,7 @@ Migration notes:
its contents by either configuring it as shared, or by creating a
"root" user in the LDAP database with a new, secure password.

1. Quota temporarly unavailable. The new "quota count" Dovecot backend is
1. Quota temporarily unavailable. The new "quota count" Dovecot backend is
used. Large mailboxes need a while to reindex the quota size. During
reindexing, quota information is not available and the following
message is logged:
Expand Down
1 change: 1 addition & 0 deletions build-images.sh
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ buildah add "${container}" ui/dist /ui
# Setup the entrypoint, ask to reserve one TCP port with the label and set a rootless container
buildah config --entrypoint=/ \
--label="org.nethserver.max-per-node=1" \
--label="org.nethserver.min-core=3.3.0-0" \
--label="org.nethserver.images=$(printf "${repobase}/mail-%s:${IMAGETAG:-latest} " \
dovecot \
postfix \
Expand Down
19 changes: 19 additions & 0 deletions dovecot/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,25 @@ preference is stored in the _dict_ database file
`/var/lib/dovecot/dict/uspamret.db`. See the dedicated section below to
modify it.

### `restore-folder`

Import subdirectories of a Maildir++ structure (restored from backup)
under the user's "Restored folder." Command syntax:

restore-folder {USER} {BACKUP_DIR} {FOLDER_PATH} [ROOT_FOLDER]

- **USER**: The destination IMAP user name.
- **BACKUP_DIR**: The source directory where backup contents were
extracted. Any subdirectory beginning with a "." (per the Maildir++
format) is imported.
- **FOLDER_PATH**: The IMAP path for the restored folder. Unused path
ancestors are pruned.
- **ROOT_FOLDER** (Optional): Defaults to "Restored folder" if
unspecified, under which contents are imported.

The command disables the USER's quota if necessary to avoid an over-quota
state.

## Storage

Volumes are:
Expand Down
61 changes: 61 additions & 0 deletions dovecot/usr/local/bin/restore-folder
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
#!/bin/ash
# shellcheck shell=bash

#
# Copyright (C) 2024 Nethesis S.r.l.
# SPDX-License-Identifier: GPL-3.0-or-later
#

set -o pipefail
set -e

user="${1:?missing user argument}"
restoredir="${2:?missing source dir path}"
destfolder="${3:?missing destination folder name}"
rootfolder="${4:-Restored folder}"
basefolder="$(basename "${destfolder}")"
rootprefix=$(doveadm mailbox path -u "${user}" "${rootfolder}")

chown -c vmail:vmail "${restoredir}"
cd "${restoredir}"

read -r curquotalimit curquotamb < <(doveadm -f tab quota get -u "${user}" | awk -F $'\t' 'NR==2 {print $4 "\t" int($4/1024)}')
if [ "${curquotalimit}" != "-" ]; then
# Temporarily disable the user's quota
doveadm dict set -u "${user}" fs:posix:prefix=/var/lib/dovecot/dict/uquota/ shared/"${user}" 0
fi

echo "${rootfolder}" | tr / $'\n' | while read -r rootpart ; do
rootacc="${rootacc}${rootacc+/}${rootpart}"
doveadm mailbox create -u "${user}" "${rootacc}" &>/dev/null || :
doveadm mailbox subscribe -u "${user}" "${rootacc}"
done
doveadm mailbox delete -u "${user}" -r -s "${rootfolder}/${basefolder}" &>/dev/null || :
for folder in .* ; do
[ "${folder}" == "." ] && continue
[ "${folder}" == ".." ] && continue
[ ! -d "${folder}" ] && continue
if [ -e "${rootprefix}${folder}" ]; then
echo "WARNING! Found already existing destination path ${rootprefix}${folder}. Forcing removal." 1>&2
rm -rf "${rootprefix}${folder}"
fi
mv -v "${folder}" "${rootprefix}${folder}"
done
# Prune destfolder ancestors:
if [ "${destfolder}" != "${basefolder}" ]; then
doveadm mailbox rename -u "${user}" -s "${rootfolder}/${destfolder}" "${rootfolder}/${basefolder}" || :
fi
doveadm index -u "${user}" "${rootfolder}/${basefolder}"
doveadm quota recalc -u "${user}"

read -r newquotavalue < <(doveadm -f tab quota get -u "${user}" | awk -F $'\t' 'NR==2 {print $3}')

if [ "${curquotalimit}" == "-" ]; then
echo "QUOTA_UNCHANGED. Quota usage ${newquotavalue} KB. Quota is not limited, nothing to do."
elif [ "${newquotavalue}" -lt "${curquotalimit}" ]; then
# Restore previous user's quota
doveadm dict set -u "${user}" fs:posix:prefix=/var/lib/dovecot/dict/uquota/ shared/"${user}" "${curquotamb}"
echo "QUOTA_UNCHANGED. Quota restored to ${curquotamb}"
else
echo "QUOTA_DISABLED_BY_RESTORE. Original quota was insufficient for the restore of ${destfolder}. User quota is disabled now."
fi
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ try:
rquotas = mail.doveadm_query("quotaGet", {"allUsers":True})
except mail.DoveadmError as ex:
if ex.code == 75:
print(agent.SD_WARNING + "Quota information is temporarly unavailable. Try again later.", ex, file=sys.stderr)
print(agent.SD_WARNING + "Quota information is temporarily unavailable. Try again later.", ex, file=sys.stderr)
else:
print(agent.SD_ERROR + "quotaGet error", ex, file=sys.stderr)
except Exception:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ try:
{'alocal':mbxn, 'domain':"*"})

# Destroy the mailbox and its contents
mail.doveadm_query("mailboxDelete", {"mailbox": [mbxn], "user": "vmail"})
mail.doveadm_query("mailboxDelete", {"mailbox": [mbxn], "user": "vmail", "recursive": True})

except mail.DoveadmError as dex:
if dex.code != 68:
Expand Down
83 changes: 83 additions & 0 deletions imageroot/actions/restore-backup-content/50restore_backup_content
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
#!/usr/bin/env python3

#
# Copyright (C) 2024 Nethesis S.r.l.
# SPDX-License-Identifier: GPL-3.0-or-later
#

import json
import sys
import os
import subprocess
import agent
import atexit

request = json.load(sys.stdin)
if request['user'] == 'vmail':
# Public folders belong to user Vmail. When one of them is restored, the
# destination folder is created under the Public folder itself.
destroot = request.get("destroot", f"{request['folder']}/Restored folder")
else:
destroot = request.get("destroot", "Restored folder")
# Temporary path for restore. It is relative to Dovecot's vmail dir, the
# default working directory for the dovecot container:
tmp_restore_path = ".restore-backup-content." + os.environ["AGENT_TASK_ID"]
def cleanup_tmp():
global tmp_restore_path
if tmp_restore_path:
agent.run_helper("podman", "exec", "dovecot", "rm", "-rf", tmp_restore_path)
atexit.register(cleanup_tmp)
# Transform the folder path into a filesystem directory name:
_, maildir_path = subprocess.check_output([
'podman', 'exec', 'dovecot',
'doveadm', 'mailbox', 'path', '-u', request['user'], request['folder'],
], text=True).strip().rsplit('/', 1)
maildir_path_goescaped = maildir_path.replace("\\", "\\\\")
restic_args = [
"restore",
"--json",
f"{request['snapshot']}:volumes/dovecot-data/{request['user']}/Maildir",
f"--target=/srv/volumes/dovecot-data/{tmp_restore_path}",
f"--include={maildir_path_goescaped}*",
]
podman_args = ["--workdir=/srv"] + agent.agent.get_state_volume_args()

# Prepare progress callback function that captures non-progress messages too:
last_restic_message = {}
def build_restore_progress_callback():
restore_progress = agent.get_progress_callback(5, 95)
def fprog(omessage):
global last_restic_message
last_restic_message = omessage
if omessage['message_type'] == 'status':
fpercent = float(omessage['percent_done'])
restore_progress(int(fpercent * 100))
return fprog
# Run the restic restore command capturing the progress status:
prestore = agent.run_restic(agent.redis_connect(), request["destination"], request["repopath"], podman_args, restic_args, progress_callback=build_restore_progress_callback())
if prestore.returncode != 0:
print(agent.SD_ERR + f"Restic restore command failed with exit code {prestore.returncode}.", file=sys.stderr)
sys.exit(1)

# 1. Disable the user quota temporarily.
# 2. Move the restored content under "Restored content" IMAP folder.
# Remove the destination if already exists.
# 3. Reindex the destination folder.
# 4. Restore the previous quota setting, if possible
quota_disabled = False

proc_import = subprocess.run(["podman", "exec", "-i", "dovecot",
"restore-folder", request["user"], f"/var/lib/vmail/{tmp_restore_path}", request['folder'], destroot],
stdout=subprocess.PIPE, text=True)
if proc_import.returncode != 0:
print(agent.SD_ERR + f"Import script failed with exit code {proc_import.returncode}.", file=sys.stderr)
sys.exit(1)

if 'QUOTA_DISABLED_BY_RESTORE' in proc_import.stdout:
quota_disabled = True

json.dump({
"request": request,
"quota_disabled": quota_disabled,
"last_restic_message": last_restic_message,
}, fp=sys.stdout)
52 changes: 52 additions & 0 deletions imageroot/actions/restore-backup-content/validate-input.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "restore-backup-content input",
"$id": "http://schema.nethserver.org/mail/restore-backup-content-input.json",
"description": "Restore the backup of a user's mail folder.",
"examples": [
{
"folder": "INBOX/Liste/Samba.org",
"user": "first.user",
"destination": "14030a59-a4e6-54cc-b8ea-cd5f97fe44c8",
"repopath": "mail/4372a5d5-0886-45d3-82e7-68d913716a4c",
"snapshot": "aee022f50fa0b494ee9b172d1395375d7d471e3b647b6238750008dba92d29f7"
},
{
"folder": "INBOX/Liste/Samba.org",
"user": "first.user",
"destination": "14030a59-a4e6-54cc-b8ea-cd5f97fe44c8",
"repopath": "mail/4372a5d5-0886-45d3-82e7-68d913716a4c",
"snapshot": "latest"
}
],
"type": "object",
"required": [
"folder",
"destination",
"repopath",
"snapshot",
"user"
],
"properties": {
"folder": {
"type": "string",
"description": "Absolute IMAP folder path to restore. Its subfolders will be restored as well."
},
"destination": {
"type": "string",
"description": "The UUID of the backup destination where the Restic repository resides."
},
"repopath": {
"type": "string",
"description": "Restic repository path, under the backup destination"
},
"snapshot": {
"type": "string",
"description": "Restic snapshot ID to restore"
},
"user": {
"type": "string",
"description": "Restore the content of this Dovecot IMAP user"
}
}
}
37 changes: 37 additions & 0 deletions imageroot/actions/restore-backup-content/validate-output.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "restore-backup-content output",
"$id": "http://schema.nethserver.org/mail/restore-backup-content-output.json",
"description": "Restore the backup of a user's mail folder.",
"examples": [
{
"request": {
"destination": "14030a59-a4e6-54cc-b8ea-cd5f97fe44c8",
"folder": "INBOX/Liste/Samba.org",
"repopath": "mail/4372a5d5-0886-45d3-82e7-68d913716a4c",
"snapshot": "latest",
"user": "first.user"
},
"quota_disabled": false
}
],
"type": "object",
"required": [
"quota_disabled"
],
"properties": {
"quota_disabled": {
"title": "Quota disabled",
"type": "boolean",
"description": "If the restore has disabled the user's quota or not"
},
"last_restic_message": {
"type": "object",
"description": "Last JSON message from Restic restore"
},
"request": {
"type": "object",
"title": "Original request object"
}
}
}
41 changes: 41 additions & 0 deletions imageroot/actions/seek-snapshot-contents/50seek_snapshot_contents
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
#!/usr/bin/env python3

#
# Copyright (C) 2024 Nethesis S.r.l.
# SPDX-License-Identifier: GPL-3.0-or-later
#

import json
import sys
import os
import subprocess
import agent

request = json.load(sys.stdin)
rdb = agent.redis_connect()
podman_args = ["--workdir=/srv"] + agent.agent.get_state_volume_args()
restic_args = [
"dump",
request["snapshot"],
"/state/maildir-dump.lst",
]

restic_cmd, restic_env = agent.prepare_restic_command(rdb, request["destination"], request["repopath"], podman_args, restic_args)
with subprocess.Popen(restic_cmd, stdout=subprocess.PIPE, stderr=sys.stderr, text=True, env=restic_env) as vproc:
folders = []
while True:
line = vproc.stdout.readline()
if not line:
break
fields = line.rstrip().split("\t")
if fields[0] == request["user"]:
folders.append(fields[1])
if vproc.returncode != 0:
print(agent.SD_ERR + f"Restic dump command failed with exit code {vproc.returncode}.", file=sys.stderr)
sys.exit(1)

folders.sort()
json.dump({
"request": request,
"folders": folders,
}, fp=sys.stdout)
45 changes: 45 additions & 0 deletions imageroot/actions/seek-snapshot-contents/validate-input.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "seek-snapshot-contents input",
"$id": "http://schema.nethserver.org/mail/seek-snapshot-contents-input.json",
"description": "Extract the list of IMAP folders from a backup snapshot",
"examples": [
{
"destination": "14030a59-a4e6-54cc-b8ea-cd5f97fe44c8",
"user": "first.user",
"repopath": "mail/4372a5d5-0886-45d3-82e7-68d913716a4c",
"snapshot": "aee022f50fa0b494ee9b172d1395375d7d471e3b647b6238750008dba92d29f7"
},
{
"destination": "14030a59-a4e6-54cc-b8ea-cd5f97fe44c8",
"user": "first.user",
"repopath": "mail/4372a5d5-0886-45d3-82e7-68d913716a4c",
"snapshot": "latest"
}
],
"type": "object",
"required": [
"destination",
"repopath",
"snapshot",
"user"
],
"properties": {
"destination": {
"type": "string",
"description": "The UUID of the backup destination where the Restic repository resides."
},
"repopath": {
"type": "string",
"description": "Restic repository path, under the backup destination"
},
"snapshot": {
"type": "string",
"description": "Restic snapshot ID to restore"
},
"user": {
"type": "string",
"description": "Restore the content of this Dovecot IMAP user"
}
}
}
Loading
Loading