You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1731 lines
52 KiB

  1. #!/bin/bash
  2. . /etc/shlib
  3. include common
  4. include parse
  5. include cmdline
  6. include config
  7. include cache
  8. include fn
  9. include docker
  10. [[ "${BASH_SOURCE[0]}" != "${0}" ]] && SOURCED=true
  11. version=0.1
  12. desc='Install backup'
  13. help=""
  14. docker:running-container-projects() {
  15. :cache: scope=session
  16. docker ps --format '{{.Label "com.docker.compose.project"}}' | sort | uniq
  17. }
  18. decorator._mangle_fn docker:running-container-projects
  19. ssh:mk-private-key() {
  20. local host="$1" service_name="$2"
  21. (
  22. settmpdir VPS_TMPDIR
  23. ssh-keygen -t rsa -N "" -f "$VPS_TMPDIR/rsync_rsa" -C "$service_name@$host" >/dev/null
  24. cat "$VPS_TMPDIR/rsync_rsa"
  25. )
  26. }
  27. mailcow:has-images-running() {
  28. local images
  29. images=$(docker ps --format '{{.Image}}' | sort | uniq)
  30. [[ $'\n'"$images" == *$'\n'"mailcow/"* ]]
  31. }
  32. mailcow:has-container-project-mentionning-mailcow() {
  33. local projects
  34. projects=$(docker:running-container-projects) || return 1
  35. [[ $'\n'"$projects"$'\n' == *mailcow* ]]
  36. }
  37. mailcow:has-running-containers() {
  38. mailcow:has-images-running ||
  39. mailcow:has-container-project-mentionning-mailcow
  40. }
  41. mailcow:get-root() {
  42. :cache: scope=session
  43. local dir
  44. for dir in {/opt{,/apps},/root}/mailcow-dockerized; do
  45. [ -d "$dir" ] || continue
  46. [ -r "$dir/mailcow.conf" ] || continue
  47. echo "$dir"
  48. return 0
  49. done
  50. return 1
  51. }
  52. decorator._mangle_fn mailcow:get-root
  53. compose:get-compose-yml() {
  54. :cache: scope=session
  55. local path
  56. [ -e "/etc/compose/local.conf" ] && . "/etc/compose/local.conf"
  57. path=${DEFAULT_COMPOSE_FILE:-/etc/compose/compose.yml}
  58. [ -e "$path" ] || return 1
  59. echo "$path"
  60. }
  61. decorator._mangle_fn compose:get-compose-yml
  62. export -f compose:get-compose-yml
  63. compose:has-container-project-myc() {
  64. local projects
  65. projects=$(docker:running-container-projects) || return 1
  66. [[ $'\n'"$projects"$'\n' == *$'\n'"myc"$'\n'* ]]
  67. }
  68. compose:file:value-change() {
  69. local key="$1" value="$2"
  70. yaml:file:value-change "$(compose:get-compose-yml)" "$key" "$value" || exit 1
  71. }
  72. export -f compose:file:value-change
  73. yaml:file:value-change() {
  74. local file="$1" key="$2" value="$3" first=1 count=0 diff=""
  75. (
  76. cd "${file%/*}"
  77. while read-0 hunk; do
  78. if [ -n "$first" ]; then
  79. diff+="$hunk"
  80. first=
  81. continue
  82. fi
  83. if [[ "$hunk" =~ $'\n'"+"[[:space:]]+"${key##*.}:" ]]; then
  84. ((count++))
  85. diff+="$hunk" >&2
  86. else
  87. :
  88. # echo "discarding:" >&2
  89. # e "$hunk" | prefix " | " >&2
  90. fi
  91. done < <(
  92. export DEBUG=
  93. settmpdir YQ_TEMP
  94. cp "${file}" "$YQ_TEMP/compose.yml" &&
  95. yq -i ".${key} = \"${value}\"" "$YQ_TEMP/compose.yml" &&
  96. sed -ri 's/^([^# ])/\n\0/g' "$YQ_TEMP/compose.yml" &&
  97. diff -u0 -Z "${file}" "$YQ_TEMP/compose.yml" |
  98. sed -r "s/^(@@.*)$/\x00\1/g;s%^(\+\+\+) [^\t]+%\1 ${file}%g"
  99. printf "\0"
  100. )
  101. if [[ "$count" == 0 ]]; then
  102. err "No change made to '$file'."
  103. return 1
  104. fi
  105. if [[ "$count" != 1 ]]; then
  106. err "compose file change request seems dubious and was refused:"
  107. e "$diff" | prefix " | " >&2
  108. return 1
  109. fi
  110. echo Applying: >&2
  111. e "$diff" | prefix " | " >&2
  112. patch <<<"$diff"
  113. ) || exit 1
  114. }
  115. export -f yaml:file:value-change
  116. type:is-mailcow() {
  117. mailcow:get-root >/dev/null ||
  118. mailcow:has-running-containers
  119. }
  120. type:is-compose() {
  121. compose:get-compose-yml >/dev/null &&
  122. compose:has-container-project-myc
  123. }
  124. vps:get-type() {
  125. :cache: scope=session
  126. local fn
  127. for fn in $(declare -F | cut -f 3 -d " " | egrep "^type:is-"); do
  128. "$fn" && {
  129. echo "${fn#type:is-}"
  130. return 0
  131. }
  132. done
  133. return 1
  134. }
  135. decorator._mangle_fn vps:get-type
  136. mirror-dir:sources() {
  137. :cache: scope=session
  138. if ! shyaml get-values default.sources < /etc/mirror-dir/config.yml; then
  139. err "Couldn't query 'default.sources' in '/etc/mirror-dir/config.yml'."
  140. return 1
  141. fi
  142. }
  143. decorator._mangle_fn mirror-dir:sources
  144. mirror-dir:check-add() {
  145. local elt="$1" sources
  146. sources=$(mirror-dir:sources) || return 1
  147. if [[ $'\n'"$sources"$'\n' == *$'\n'"$elt"$'\n'* ]]; then
  148. info "Volume $elt already in sources"
  149. else
  150. Elt "Adding directory $elt"
  151. sed -i "/sources:/a\ - \"${elt}\"" \
  152. /etc/mirror-dir/config.yml
  153. Feedback || return 1
  154. fi
  155. }
  156. mirror-dir:check-add-vol() {
  157. local elt="$1"
  158. mirror-dir:check-add "/var/lib/docker/volumes/*_${elt}-*/_data"
  159. }
  160. ## The first colon is to prevent auto-export of function from shlib
  161. : ; bash-bug-5() { { cat; } < <(e) >/dev/null; ! cat "$1"; } && bash-bug-5 <(e) 2>/dev/null &&
  162. export BASH_BUG_5=1 && unset -f bash_bug_5
  163. wrap() {
  164. local label="$1" code="$2"
  165. shift 2
  166. export VERBOSE=1
  167. interpreter=/bin/bash
  168. if [ -n "$BASH_BUG_5" ]; then
  169. (
  170. settmpdir tmpdir
  171. fname=${label##*/}
  172. e "$code" > "$tmpdir/$fname" &&
  173. chmod +x "$tmpdir/$fname" &&
  174. Wrap -vsd "$label" -- "$interpreter" "$tmpdir/$fname" "$@"
  175. )
  176. else
  177. Wrap -vsd "$label" -- "$interpreter" <(e "$code") "$@"
  178. fi
  179. }
  180. ping_check() {
  181. #global ignore_ping_check
  182. local host="$1"
  183. ip=$(getent ahosts "$host" | egrep "^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+\s+" |
  184. head -n 1 | cut -f 1 -d " ") || return 1
  185. my_ip=$(curl -s myip.kal.fr)
  186. if [ "$ip" != "$my_ip" ]; then
  187. if [ -n "$ignore_ping_check" ]; then
  188. warn "IP of '$host' ($ip) doesn't match mine ($my_ip). Ignoring due to ``--ignore-ping-check`` option."
  189. else
  190. err "IP of '$host' ($ip) doesn't match mine ($my_ip). Use ``--ignore-ping-check`` to ignore check."
  191. return 1
  192. fi
  193. fi
  194. }
  195. mailcow:install-backup() {
  196. local BACKUP_SERVER="$1" ignore_ping_check="$2" mailcow_root DOMAIN
  197. ## find installation
  198. mailcow_root=$(mailcow:get-root) || {
  199. err "Couldn't find a valid mailcow root directory."
  200. return 1
  201. }
  202. ## check ok
  203. DOMAIN=$(cat "$mailcow_root/.env" | grep ^MAILCOW_HOSTNAME= | cut -f 2 -d =) || {
  204. err "Couldn't find MAILCOW_HOSTNAME in file \"$mailcow_root/.env\"."
  205. return 1
  206. }
  207. ping_check "$DOMAIN" || return 1
  208. MYSQL_ROOT_PASSWORD=$(cat "$mailcow_root/.env" | grep ^DBROOT= | cut -f 2 -d =) || {
  209. err "Couldn't find DBROOT in file \"$mailcow_root/.env\"."
  210. return 1
  211. }
  212. if docker compose >/dev/null 2>&1; then
  213. MYSQL_CONTAINER=${MYSQL_CONTAINER:-mailcowdockerized-mysql-mailcow-1}
  214. else
  215. MYSQL_CONTAINER=${MYSQL_CONTAINER:-mailcowdockerized_mysql-mailcow_1}
  216. fi
  217. container_id=$(docker ps -f name="$MYSQL_CONTAINER" --format "{{.ID}}")
  218. if [ -z "$container_id" ]; then
  219. err "Couldn't find docker container named '$MYSQL_CONTAINER'."
  220. return 1
  221. fi
  222. export KEY_BACKUP_ID="mailcow"
  223. export MYSQL_ROOT_PASSWORD
  224. export MYSQL_CONTAINER
  225. export BACKUP_SERVER
  226. export DOMAIN
  227. wrap "Install rsync-backup on host" "
  228. cd /srv/charm-store/rsync-backup
  229. bash ./hooks/install.d/60-install.sh
  230. " || return 1
  231. wrap "Mysql dump install" "
  232. cd /srv/charm-store/mariadb
  233. bash ./hooks/install.d/60-backup.sh
  234. " || return 1
  235. ## Using https://github.com/mailcow/mailcow-dockerized/blob/master/helper-scripts/backup_and_restore.sh
  236. for elt in "vmail{,-attachments-vol}" crypt redis rspamd postfix; do
  237. mirror-dir:check-add-vol "$elt" || return 1
  238. done
  239. mirror-dir:check-add "$mailcow_root" || return 1
  240. mirror-dir:check-add "/var/backups/mysql" || return 1
  241. mirror-dir:check-add "/etc" || return 1
  242. dest="$BACKUP_SERVER"
  243. dest="${dest%/*}"
  244. ssh_options=()
  245. if [[ "$dest" == *":"* ]]; then
  246. port="${dest##*:}"
  247. dest="${dest%%:*}"
  248. ssh_options=(-p "$port")
  249. else
  250. port=""
  251. dest="${dest%%:*}"
  252. fi
  253. info "You can run this following command from an host having admin access to $dest:"
  254. echo " (Or send it to a backup admin of $dest)" >&2
  255. echo "ssh ${ssh_options[@]} myadmin@$dest ssh-key add '$(cat /var/lib/rsync/.ssh/id_rsa.pub)'"
  256. }
  257. compose:has_domain() {
  258. local compose_file="$1" host="$2" name conf relation relation_value domain server_aliases
  259. while read-0 name conf ; do
  260. name=$(e "$name" | shyaml get-value)
  261. if [[ "$name" =~ ^[a-zA-Z0-9-]+\.[a-zA-Z0-9-]+ ]]; then
  262. [ "$host" == "$name" ] && return 0
  263. fi
  264. rel=$(e "$conf" | shyaml -y get-value relations 2>/dev/null) || continue
  265. for relation in web-proxy publish-dir; do
  266. relation_value=$(e "$rel" | shyaml -y get-value "$relation" 2>/dev/null) || continue
  267. while read-0 label conf_relation; do
  268. domain=$(e "$conf_relation" | shyaml get-value "domain" 2>/dev/null) && {
  269. [ "$host" == "$domain" ] && return 0
  270. }
  271. server_aliases=$(e "$conf_relation" | shyaml get-values "server-aliases" 2>/dev/null) && {
  272. [[ $'\n'"$server_aliases" == *$'\n'"$host"$'\n'* ]] && return 0
  273. }
  274. done < <(e "$relation_value" | shyaml -y key-values-0)
  275. done
  276. done < <(shyaml -y key-values-0 < "$compose_file")
  277. return 1
  278. }
  279. compose:install-backup() {
  280. local BACKUP_SERVER="$1" service_name="$2" compose_file="$3" ignore_ping_check="$4" ignore_domain_check="$5"
  281. ## XXXvlab: far from perfect as it mimics and depends internal
  282. ## logic of current default way to get a domain in compose-core
  283. host=$(hostname)
  284. if ! compose:has_domain "$compose_file" "$host"; then
  285. if [ -n "$ignore_domain_check" ]; then
  286. warn "domain of '$host' not found in compose file '$compose_file'. Ignoring due to ``--ignore-domain-check`` option."
  287. else
  288. err "domain of '$host' not found in compose file '$compose_file'. Use ``--ignore-domain-check`` to ignore check."
  289. return 1
  290. fi
  291. fi
  292. ping_check "$host" || return 1
  293. if [ -e "/root/.ssh/rsync_rsa" ]; then
  294. warn "deleting private key in /root/.ssh/rsync_rsa, has we are not using it anymore."
  295. rm -fv /root/.ssh/rsync_rsa
  296. fi
  297. if [ -e "/root/.ssh/rsync_rsa.pub" ]; then
  298. warn "deleting public key in /root/.ssh/rsync_rsa.pub, has we are not using it anymore."
  299. rm -fv /root/.ssh/rsync_rsa.pub
  300. fi
  301. if service_cfg=$(cat "$compose_file" |
  302. shyaml get-value -y "$service_name" 2>/dev/null); then
  303. info "Entry for service ${DARKYELLOW}$service_name${NORMAL}" \
  304. "is already present in '$compose_file'."
  305. cfg=$(e "$service_cfg" | shyaml get-value -y options) || {
  306. err "No ${WHITE}options${NORMAL} in ${DARKYELLOW}$service_name${NORMAL}'s" \
  307. "entry in '$compose_file'."
  308. return 1
  309. }
  310. private_key=$(e "$cfg" | shyaml get-value private-key) || return 1
  311. target=$(e "$cfg" | shyaml get-value target) || return 1
  312. if [ "$target" != "$BACKUP_SERVER" ]; then
  313. err "Existing backup target '$target' is different" \
  314. "from specified '$BACKUP_SERVER'"
  315. return 1
  316. fi
  317. else
  318. private_key=$(ssh:mk-private-key "$host" "$service_name")
  319. cat <<EOF >> "$compose_file"
  320. $service_name:
  321. options:
  322. ident: $host
  323. target: $BACKUP_SERVER
  324. private-key: |
  325. $(e "$private_key" | sed -r 's/^/ /g')
  326. EOF
  327. fi
  328. dest="$BACKUP_SERVER"
  329. dest="${dest%/*}"
  330. ssh_options=()
  331. if [[ "$dest" == *":"* ]]; then
  332. port="${dest##*:}"
  333. dest="${dest%%:*}"
  334. ssh_options=(-p "$port")
  335. else
  336. port=""
  337. dest="${dest%%:*}"
  338. fi
  339. info "You can run this following command from an host having admin access to $dest:"
  340. echo " (Or send it to a backup admin of $dest)" >&2
  341. ## We remove ending label (label will be added or not in the
  342. ## private key, and thus here, depending on the version of
  343. ## openssh-client)
  344. public_key=$(ssh-keygen -y -f <(e "$private_key"$'\n') | sed -r 's/ [^ ]+@[^ ]+$//')
  345. echo "ssh ${ssh_options[@]} myadmin@$dest ssh-key add '$public_key compose@$host'"
  346. }
  347. backup-action() {
  348. local action="$1"
  349. shift
  350. vps_type=$(vps:get-type) || {
  351. err "Failed to get type of installation."
  352. return 1
  353. }
  354. if ! fn.exists "${vps_type}:${action}"; then
  355. err "type '${vps_type}' has no ${vps_type}:${action} implemented yet."
  356. return 1
  357. fi
  358. "${vps_type}:${action}" "$@"
  359. }
  360. compose:get_default_backup_host_ident() {
  361. local service_name="$1" ## Optional
  362. local compose_file service_cfg cfg target
  363. compose_file=$(compose:get-compose-yml)
  364. service_name="${service_name:-rsync-backup}"
  365. if ! service_cfg=$(cat "$compose_file" |
  366. shyaml get-value -y "$service_name" 2>/dev/null); then
  367. err "No service named '$service_name' found in 'compose.yml'."
  368. return 1
  369. fi
  370. cfg=$(e "$service_cfg" | shyaml get-value -y options) || {
  371. err "No ${WHITE}options${NORMAL} in ${DARKYELLOW}$service_name${NORMAL}'s" \
  372. "entry in '$compose_file'."
  373. return 1
  374. }
  375. if ! target=$(e "$cfg" | shyaml get-value target); then
  376. err "No ${WHITE}options.target${NORMAL} in ${DARKYELLOW}$service_name${NORMAL}'s" \
  377. "entry in '$compose_file'."
  378. fi
  379. if ! target=$(e "$cfg" | shyaml get-value target); then
  380. err "No ${WHITE}options.target${NORMAL} in ${DARKYELLOW}$service_name${NORMAL}'s" \
  381. "entry in '$compose_file'."
  382. fi
  383. if ! ident=$(e "$cfg" | shyaml get-value ident); then
  384. err "No ${WHITE}options.ident${NORMAL} in ${DARKYELLOW}$service_name${NORMAL}'s" \
  385. "entry in '$compose_file'."
  386. fi
  387. echo "$target $ident"
  388. }
  389. mailcow:get_default_backup_host_ident() {
  390. local content cron_line ident found dest cmd_line
  391. if ! [ -e "/etc/cron.d/mirror-dir" ]; then
  392. err "No '/etc/cron.d/mirror-dir' found."
  393. return 1
  394. fi
  395. content=$(cat /etc/cron.d/mirror-dir) || {
  396. err "Can't read '/etc/cron.d/mirror-dir'."
  397. return 1
  398. }
  399. if ! cron_line=$(e "$content" | grep "mirror-dir backup"); then
  400. err "Can't find 'mirror-dir backup' line in '/etc/cron.d/mirror-dir'."
  401. return 1
  402. fi
  403. cron_line=${cron_line%|*}
  404. cmd_line=(${cron_line#*root})
  405. found=
  406. dest=
  407. for arg in "${cmd_line[@]}"; do
  408. [ -n "$found" ] && {
  409. dest="$arg"
  410. break
  411. }
  412. [ "$arg" == "-d" ] && {
  413. found=1
  414. }
  415. done
  416. if ! [[ "$dest" =~ ^[\'\"a-zA-Z0-9:/.-]+$ ]]; then
  417. err "Can't find valid destination in 'mirror-dir backup' arguments from '/etc/cron.d/mirror-dir'."
  418. return 1
  419. fi
  420. if [[ "$dest" == \"*\" ]] || [[ "$dest" == \'*\' ]]; then
  421. ## unquoting, the eval should be safe because of previous check
  422. dest=$(eval e "$dest")
  423. fi
  424. if [ -z "$dest" ]; then
  425. err "Can't find destination in 'mirror-dir backup' arguments from '/etc/cron.d/mirror-dir'."
  426. return 1
  427. fi
  428. ## looking for ident
  429. found=
  430. ident=
  431. for arg in "${cmd_line[@]}"; do
  432. [ -n "$found" ] && {
  433. ident="$arg"
  434. break
  435. }
  436. [ "$arg" == "-h" ] && {
  437. found=1
  438. }
  439. done
  440. if ! [[ "$ident" =~ ^[\'\"a-zA-Z0-9.-]+$ ]]; then
  441. err "Can't find valid identifier in 'mirror-dir backup' arguments from '/etc/cron.d/mirror-dir'."
  442. return 1
  443. fi
  444. if [[ "$ident" == \"*\" ]] || [[ "$ident" == \'*\' ]]; then
  445. ## unquoting, the eval should be safe because of previous check
  446. ident=$(eval e "$ident")
  447. fi
  448. if [ -z "$ident" ]; then
  449. err "Can't find destination in 'mirror-dir backup' arguments from '/etc/cron.d/mirror-dir'."
  450. return 1
  451. fi
  452. echo "$dest $ident"
  453. }
  454. compose:service:containers() {
  455. local project="$1" service="$2"
  456. docker ps \
  457. --filter label="com.docker.compose.project=$project" \
  458. --filter label="compose.master-service=$service" \
  459. --format="{{.ID}}"
  460. }
  461. export -f compose:service:containers
  462. compose:service:container_one() {
  463. local project="$1" service="$2" container_id
  464. {
  465. read-0a container_id || {
  466. err "service ${DARKYELLOW}$service${NORMAL} has no running container."
  467. return 1
  468. }
  469. if read-0a _; then
  470. err "service ${DARKYELLOW}$service${NORMAL} has more than one running container."
  471. return 1
  472. fi
  473. } < <(compose:service:containers "$project" "$service")
  474. echo "$container_id"
  475. }
  476. export -f compose:service:container_one
  477. compose:service:container_first() {
  478. local project="$1" service="$2" container_id
  479. {
  480. read-0a container_id || {
  481. err "service ${DARKYELLOW}$service${NORMAL} has no running container."
  482. return 1
  483. }
  484. if read-0a _; then
  485. warn "service ${DARKYELLOW}$service${NORMAL} has more than one running container."
  486. fi
  487. } < <(compose:service:containers "$project" "$service")
  488. echo "$container_id"
  489. }
  490. export -f compose:service:container_first
  491. compose:project:containers() {
  492. local project="$1" opts
  493. opts+=(--filter label="com.docker.compose.project=$project")
  494. docker ps "${opts[@]}" \
  495. --format="{{.ID}}"
  496. }
  497. export -f compose:project:containers
  498. compose:charm:containers() {
  499. local project="$1" charm="$2"
  500. docker ps \
  501. --filter label="com.docker.compose.project=$project" \
  502. --filter label="compose.charm=$charm" \
  503. --format="{{.ID}}"
  504. }
  505. export -f compose:charm:containers
  506. compose:charm:container_one() {
  507. local project="$1" charm="$2" container_id
  508. {
  509. read-0a container_id || {
  510. err "charm ${DARKPINK}$charm${NORMAL} has no running container in project '$project'."
  511. return 1
  512. }
  513. if read-0a _; then
  514. err "charm ${DARKPINK}$charm${NORMAL} has more than one running container."
  515. return 1
  516. fi
  517. } < <(compose:charm:containers "$project" "$charm")
  518. echo "$container_id"
  519. }
  520. export -f compose:charm:container_one
  521. compose:charm:container_first() {
  522. local project="$1" charm="$2" container_id
  523. {
  524. read-0a container_id || {
  525. warn "charm ${DARKYELLOW}$charm${NORMAL} has no running container in project '$project'."
  526. }
  527. if read-0a _; then
  528. warn "charm ${DARKYELLOW}$charm${NORMAL} has more than one running container."
  529. fi
  530. } < <(compose:charm:containers "$project" "$charm")
  531. echo "$container_id"
  532. }
  533. export -f compose:charm:container_first
  534. compose:get_url() {
  535. local project_name="$1" service="$2" data_file network ip
  536. data_file="/var/lib/compose/relations/${project_name}/${service}-frontend/web-proxy/data"
  537. if [ -e "$data_file" ]; then
  538. (
  539. set -o pipefail
  540. cat "$data_file" | shyaml get-value url
  541. )
  542. else
  543. ## Assume there are no frontend relation here, the url is direct IP
  544. container_id=$(compose:service:container_one "${project_name}" "${service}") || return 1
  545. network_ip=$(docker:container:network_ip_one "${container_id}") || return 1
  546. IFS=":" read -r network ip <<<"$network_ip"
  547. tcp_port=
  548. for port in $(docker:exposed_ports "$container_id"); do
  549. IFS="/" read port type <<<"$port"
  550. [ "$type" == "tcp" ] || continue
  551. tcp_port="$port"
  552. break
  553. done
  554. echo -n "http://$ip"
  555. [ -n "$tcp_port" ] && echo ":$tcp_port"
  556. fi || {
  557. err "Failed querying ${service} to frontend relation to get url."
  558. return 1
  559. }
  560. }
  561. export -f compose:get_url
  562. compose:container:service() {
  563. local container="$1" service
  564. if ! service=$(docker:container:label "$container" "compose.service"); then
  565. err "Failed to get service name from container ${container}."
  566. return 1
  567. fi
  568. if [ -z "$service" ]; then
  569. err "No service found for container ${container}."
  570. return 1
  571. fi
  572. echo "$service"
  573. }
  574. export -f compose:container:service
  575. compose:psql() {
  576. local project_name="$1" dbname="$2" container_id
  577. shift 2
  578. container_id=$(compose:charm:container_one "$project_name" "postgres") || return 1
  579. docker exec -i "${container_id}" psql -U postgres "$dbname" "$@"
  580. }
  581. export -f compose:psql
  582. compose:mongo() {
  583. local project_name="$1" dbname="$2" container_id
  584. container_id=$(compose:charm:container_one "$project_name" "mongo") || return 1
  585. docker exec -i "${container_id}" mongo --quiet "$dbname"
  586. }
  587. export -f compose:mongo
  588. compose:pgm() {
  589. local project_name="$1" container_network_ip container_ip container_network
  590. shift
  591. container_id=$(compose:charm:container_one "$project_name" "postgres") || return 1
  592. service_name=$(compose:container:service "$container_id") || return 1
  593. image_id=$(docker:container:image "$container_id") || return 1
  594. container_network_ip=$(docker:container:network_ip_one "$container_id") || return 1
  595. IFS=":" read -r container_network container_ip <<<"$container_network_ip"
  596. pgpass="/srv/datastore/data/${service_name}/var/lib/postgresql/data/pgpass"
  597. local final_pgm_docker_run_opts+=(
  598. -u 0 -e prefix_pg_local_command=" "
  599. --network "${container_network}"
  600. -e PGHOST="$container_ip"
  601. -e PGUSER=postgres
  602. -v "$pgpass:/root/.pgpass"
  603. "${pgm_docker_run_opts[@]}"
  604. )
  605. cmd=(docker run --rm \
  606. "${final_pgm_docker_run_opts[@]}" \
  607. "${image_id}" pgm "$@"
  608. )
  609. echo "${cmd[@]}"
  610. "${cmd[@]}"
  611. }
  612. export -f compose:pgm
  613. postgres:dump() {
  614. local project_name="$1" src="$2" dst="$3"
  615. (
  616. settmpdir PGM_TMP_LOCATION
  617. pgm_docker_run_opts=('-v' "${PGM_TMP_LOCATION}:/tmp/dump")
  618. compose:pgm "$project_name" cp -f "$src" "/tmp/dump/dump.gz" &&
  619. mv "$PGM_TMP_LOCATION/dump.gz" "$dst"
  620. ) || return 1
  621. }
  622. export -f postgres:dump
  623. postgres:restore() {
  624. local project_name="$1" src="$2" dst="$3"
  625. full_src_path=$(readlink -e "$src") || exit 1
  626. (
  627. pgm_docker_run_opts=('-v' "${full_src_path}:/tmp/dump.gz")
  628. compose:pgm "$project_name" cp -f "/tmp/dump.gz" "$dst"
  629. ) || return 1
  630. }
  631. export -f postgres:restore
  632. odoo:get_public_user_id() {
  633. local project_name="$1" dbname="$2"
  634. echo "select res_id from ir_model_data where model = 'res.users' and name = 'public_user';" |
  635. compose:psql "$project_name" "$dbname" -qAt
  636. }
  637. cyclos:set_root_url() {
  638. local project_name="$1" dbname="$2" url="$3"
  639. echo "UPDATE configurations SET root_url = '$url';" |
  640. compose:psql "$project_name" "$dbname" || {
  641. err "Failed to set cyclos url value in '$dbname' database."
  642. return 1
  643. }
  644. }
  645. export -f cyclos:set_root_url
  646. cyclos:unlock() {
  647. local project_name="$1" dbname="$2"
  648. echo "delete from database_lock;" |
  649. compose:psql "${project_name}" "${dbname}"
  650. }
  651. export -f cyclos:unlock
  652. rocketchat:drop-indexes() {
  653. local project_name="$1" dbname="$2"
  654. echo "db.users.dropIndexes()" |
  655. compose:mongo "${project_name}" "${dbname}"
  656. }
  657. export -f rocketchat:drop-indexes
  658. compose:project_name() {
  659. if [ -z "$PROJECT_NAME" ]; then
  660. PROJECT_NAME=$(compose --get-project-name) || {
  661. err "Couldn't get project name."
  662. return 1
  663. }
  664. if [ -z "$PROJECT_NAME" -o "$PROJECT_NAME" == "orphan" ]; then
  665. err "Couldn't get project name, probably because 'compose.yml' wasn't found."
  666. echo " Please ensure to either configure a global 'compose.yml' or run this command" >&2
  667. echo " in a compose project (with 'compose.yml' on the top level directory)." >&2
  668. return 1
  669. fi
  670. export PROJECT_NAME
  671. fi
  672. echo "$PROJECT_NAME"
  673. }
  674. export -f compose:project_name
  675. compose:get_cron_docker_cmd() {
  676. local cron_line cmd_line docker_cmd
  677. project_name=$(compose:project_name) || return 1
  678. if ! cron_line=$(docker exec "${project_name}"_cron_1 cat /etc/cron.d/rsync-backup | grep "\* \* \*"); then
  679. err "Can't find cron_line in cron container."
  680. echo " Have you forgotten to run 'compose up' ?" >&2
  681. return 1
  682. fi
  683. cron_line=${cron_line%|*}
  684. cron_line=${cron_line%"2>&1"*}
  685. cmd_line="${cron_line#*root}"
  686. eval "args=($cmd_line)"
  687. ## should be last argument
  688. docker_cmd=$(echo ${args[@]: -1})
  689. if ! [[ "$docker_cmd" == "docker run --rm -e "* ]]; then
  690. echo "docker command found should start with 'docker run'." >&2
  691. echo "Here's command:" >&2
  692. echo " $docker_cmd" >&2
  693. return 1
  694. fi
  695. e "$docker_cmd"
  696. }
  697. compose:recover-target() {
  698. local backup_host="$1" ident="$2" src="$3" dst="$4" service_name="${5:-rsync-backup}" project_name
  699. project_name=$(compose:project_name) || return 1
  700. docker_image="${project_name}_${service_name}"
  701. if ! docker_has_image "$docker_image"; then
  702. compose build "${service_name}" || {
  703. err "Couldn't find nor build image for service '$service_name'."
  704. return 1
  705. }
  706. fi
  707. dst="${dst%/}" ## remove final slash
  708. ssh_options=(-o StrictHostKeyChecking=no)
  709. if [[ "$backup_host" == *":"* ]]; then
  710. port="${backup_host##*:}"
  711. backup_host="${backup_host%%:*}"
  712. ssh_options+=(-p "$port")
  713. else
  714. port=""
  715. backup_host="${backup_host%%:*}"
  716. fi
  717. rsync_opts=(
  718. -e "ssh ${ssh_options[*]} -i /var/lib/rsync/.ssh/id_rsa -l rsync"
  719. -azvArH --delete --delete-excluded
  720. --partial --partial-dir .rsync-partial
  721. --numeric-ids
  722. )
  723. if [ "$DRY_RUN" ]; then
  724. rsync_opts+=("-n")
  725. fi
  726. cmd=(
  727. docker run --rm --entrypoint rsync \
  728. -v "/srv/datastore/config/${service_name}/var/lib/rsync":/var/lib/rsync \
  729. -v "${dst%/*}":/mnt/dest \
  730. "$docker_image" \
  731. "${rsync_opts[@]}" "$backup_host":"/var/mirror/$ident/$src" "/mnt/dest/${dst##*/}"
  732. )
  733. echo "${WHITE}Launching: ${NORMAL} ${cmd[@]}"
  734. "${cmd[@]}"
  735. }
  736. mailcow:recover-target() {
  737. local backup_host="$1" ident="$2" src="$3" dst="$4"
  738. dst="${dst%/}" ## remove final slash
  739. ssh_options=(-o StrictHostKeyChecking=no)
  740. if [[ "$backup_host" == *":"* ]]; then
  741. port="${backup_host##*:}"
  742. backup_host="${backup_host%%:*}"
  743. ssh_options+=(-p "$port")
  744. else
  745. port=""
  746. backup_host="${backup_host%%:*}"
  747. fi
  748. rsync_opts=(
  749. -e "ssh ${ssh_options[*]} -i /var/lib/rsync/.ssh/id_rsa -l rsync"
  750. -azvArH --delete --delete-excluded
  751. --partial --partial-dir .rsync-partial
  752. --numeric-ids
  753. )
  754. if [ "$DRY_RUN" ]; then
  755. rsync_opts+=("-n")
  756. fi
  757. cmd=(
  758. rsync "${rsync_opts[@]}" "$backup_host":"/var/mirror/$ident/$src" "${dst}"
  759. )
  760. echo "${WHITE}Launching: ${NORMAL} ${cmd[@]}"
  761. "${cmd[@]}"
  762. }
  763. nextcloud:src:version() {
  764. local version
  765. if ! version=$(cat "/srv/datastore/data/${nextcloud_service}/var/www/html/version.php" 2>/dev/null); then
  766. err "Can't find version.php file to get last version installed."
  767. exit 1
  768. fi
  769. version=$(e "$version" | grep 'VersionString =' | cut -f 3 -d ' ' | cut -f 2 -d "'")
  770. if [ -z "$version" ]; then
  771. err "Can't figure out version from version.php content."
  772. exit 1
  773. fi
  774. echo "$version"
  775. }
  776. container:health:check-fix:container-aliveness() {
  777. local container_id="$1"
  778. timeout 5s docker inspect "$container_id" >/dev/null 2>&1
  779. errlvl=$?
  780. if [ "$errlvl" == 124 ]; then
  781. service_name=$(docker ps --filter id="$container_id" --format '{{.Label "com.docker.compose.service"}}')
  782. container_name=($(docker ps --filter id="$container_id" --format '{{.Names}}'))
  783. pid=$(ps ax -o pid,command -ww | grep docker-containerd-shim |
  784. grep "/$container_id" |
  785. sed -r 's/^ *//g' |
  786. cut -f 1 -d " ")
  787. if [ -z "$pid" ]; then
  788. err "container ${DARKCYAN}${container_name[0]}${NORMAL} for ${DARKYELLOW}$service_name${NORMAL} doesn't answer to 'inspect' command. Can't find its PID neither."
  789. return 1
  790. fi
  791. echo "container ${DARKCYAN}${container_name[0]}${NORMAL} for ${DARKYELLOW}$service_name${NORMAL} doesn't answer to 'inspect' command (pid: $pid)."
  792. Wrap -d "kill pid $pid and restart" <<EOF
  793. kill "$pid"
  794. sleep 2
  795. docker restart "$container_id"
  796. EOF
  797. fi
  798. return $errlvl
  799. }
  800. container:health:check-fix:no-matching-entries() {
  801. local container_id="$1"
  802. out=$(docker exec "$container_id" echo 2>&1)
  803. errlvl=$?
  804. [ "$errlvl" == 0 ] && return 0
  805. service_name=$(docker ps --filter id="$container_id" --format '{{.Label "com.docker.compose.service"}}')
  806. container_name=($(docker ps --filter id="$container_id" --format '{{.Names}}'))
  807. if [ "$errlvl" == 126 ] && [[ "$out" == *"no matching entries in passwd file"* ]]; then
  808. echo "container ${DARKCYAN}${container_name[0]}${NORMAL} for ${DARKYELLOW}$service_name${NORMAL} has ${DARKRED}no-matching-entries${NORMAL} bug." >&2
  809. Wrap -d "restarting container of ${DARKYELLOW}$service_name${NORMAL} twice" <<EOF
  810. docker restart "$container_id"
  811. sleep 2
  812. docker restart "$container_id"
  813. EOF
  814. return $errlvl
  815. fi
  816. warn "Unknown issue with ${DARKYELLOW}$service_name${NORMAL}'s container:"
  817. echo " ${WHITE}cmd:${NORMAL} docker exec -ti $container_id echo" >&2
  818. echo "$out" | prefix " ${DARKGRAY}|${NORMAL} " >&2
  819. echo " ${DARKGRAY}..${NORMAL} leaving this as-is."
  820. return $errlvl
  821. }
  822. [ "$SOURCED" ] && return 0
  823. ##
  824. ## Command line processing
  825. ##
  826. cmdline.spec.gnu
  827. cmdline.spec.reporting
  828. cmdline.spec.gnu install
  829. cmdline.spec::cmd:install:run() {
  830. :
  831. }
  832. cmdline.spec.gnu get-type
  833. cmdline.spec::cmd:get-type:run() {
  834. vps:get-type
  835. }
  836. cmdline.spec:install:cmd:backup:run() {
  837. : :posarg: BACKUP_SERVER 'Target backup server'
  838. : :optfla: --ignore-domain-check \
  839. "Allow to bypass the domain check in
  840. compose file (only used in compose
  841. installation)."
  842. : :optfla: --ignore-ping-check "Allow to bypass the ping check of host."
  843. local vps_type
  844. vps_type=$(vps:get-type) || {
  845. err "Failed to get type of installation."
  846. return 1
  847. }
  848. if ! fn.exists "${vps_type}:install-backup"; then
  849. err "type '${vps_type}' has no backup installation implemented yet."
  850. return 1
  851. fi
  852. opts=()
  853. [ "$opt_ignore_ping_check" ] &&
  854. opts+=("--ignore-ping-check")
  855. if [ "$vps_type" == "compose" ]; then
  856. [ "$opt_ignore_domain_check" ] &&
  857. opts+=("--ignore-domain-check")
  858. fi
  859. "cmdline.spec:install:cmd:${vps_type}-backup:run" "${opts[@]}" "$BACKUP_SERVER"
  860. }
  861. DEFAULT_BACKUP_SERVICE_NAME=rsync-backup
  862. cmdline.spec.gnu compose-backup
  863. cmdline.spec:install:cmd:compose-backup:run() {
  864. : :posarg: BACKUP_SERVER 'Target backup server'
  865. : :optval: --service-name,-s "YAML service name in compose
  866. file to check for existence of key.
  867. Defaults to '$DEFAULT_BACKUP_SERVICE_NAME'"
  868. : :optval: --compose-file,-f "Compose file location. Defaults to
  869. the value of '\$DEFAULT_COMPOSE_FILE'"
  870. : :optfla: --ignore-domain-check \
  871. "Allow to bypass the domain check in
  872. compose file."
  873. : :optfla: --ignore-ping-check "Allow to bypass the ping check of host."
  874. local service_name compose_file
  875. [ -e "/etc/compose/local.conf" ] && source /etc/compose/local.conf
  876. compose_file=${opt_compose_file:-$DEFAULT_COMPOSE_FILE}
  877. service_name=${opt_service_name:-$DEFAULT_BACKUP_SERVICE_NAME}
  878. if ! [ -e "$compose_file" ]; then
  879. err "Compose file not found in '$compose_file'."
  880. return 1
  881. fi
  882. compose:install-backup "$BACKUP_SERVER" "$service_name" "$compose_file" \
  883. "$opt_ignore_ping_check" "$opt_ignore_domain_check"
  884. }
  885. cmdline.spec:install:cmd:mailcow-backup:run() {
  886. : :posarg: BACKUP_SERVER 'Target backup server'
  887. : :optfla: --ignore-ping-check "Allow to bypass the ping check of host."
  888. "mailcow:install-backup" "$BACKUP_SERVER" "$opt_ignore_ping_check"
  889. }
  890. cmdline.spec.gnu backup
  891. cmdline.spec::cmd:backup:run() {
  892. local vps_type
  893. vps_type=$(vps:get-type) || {
  894. err "Failed to get type of installation."
  895. return 1
  896. }
  897. if ! fn.exists "cmdline.spec:backup:cmd:${vps_type}:run"; then
  898. err "type '${vps_type}' has no backup process implemented yet."
  899. return 1
  900. fi
  901. "cmdline.spec:backup:cmd:${vps_type}:run"
  902. }
  903. cmdline.spec:backup:cmd:mailcow:run() {
  904. local cmd_line cron_line cmd
  905. for f in mysql-backup mirror-dir; do
  906. [ -e "/etc/cron.d/$f" ] || {
  907. err "Can't find '/etc/cron.d/$f'."
  908. echo " Have you forgotten to run 'vps install backup BACKUP_HOST' ?" >&2
  909. return 1
  910. }
  911. if ! cron_line=$(cat "/etc/cron.d/$f" |
  912. grep -v "^#" | grep "\* \* \*"); then
  913. err "Can't find cron_line in '/etc/cron.d/$f'." \
  914. "Have you modified it ?"
  915. return 1
  916. fi
  917. cron_line=${cron_line%|*}
  918. cmd_line=(${cron_line#*root})
  919. if [ "$f" == "mirror-dir" ]; then
  920. cmd=()
  921. for arg in "${cmd_line[@]}"; do
  922. [ "$arg" != "-q" ] && cmd+=("$arg")
  923. done
  924. else
  925. cmd=("${cmd_line[@]}")
  926. fi
  927. code="${cmd[*]}"
  928. echo "${WHITE}Launching:${NORMAL} ${code}"
  929. {
  930. {
  931. (
  932. ## Some commands are using colors that are already
  933. ## set by this current program and will trickle
  934. ## down unwantedly
  935. ansi_color no
  936. eval "${code}"
  937. ) | sed -r "s/^/ ${GRAY}|${NORMAL} /g"
  938. set_errlvl "${PIPESTATUS[0]}"
  939. } 3>&1 1>&2 2>&3 | sed -r "s/^/ $DARKRED\!$NORMAL /g"
  940. set_errlvl "${PIPESTATUS[0]}"
  941. } 3>&1 1>&2 2>&3
  942. if [ "$?" != "0" ]; then
  943. err "Failed."
  944. return 1
  945. fi
  946. done
  947. info "Mysql backup and subsequent mirror-dir ${DARKGREEN}succeeded${NORMAL}."
  948. }
  949. set_errlvl() { return "${1:-1}"; }
  950. cmdline.spec:backup:cmd:compose:run() {
  951. local cron_line args
  952. project_name=$(compose:project_name) || return 1
  953. docker_cmd=$(compose:get_cron_docker_cmd) || return 1
  954. echo "${WHITE}Launching:${NORMAL} docker exec -i "${project_name}_cron_1" $docker_cmd"
  955. {
  956. {
  957. eval "docker exec -i \"${project_name}_cron_1\" $docker_cmd" | sed -r "s/^/ ${GRAY}|${NORMAL} /g"
  958. set_errlvl "${PIPESTATUS[0]}"
  959. } 3>&1 1>&2 2>&3 | sed -r "s/^/ $DARKRED\!$NORMAL /g"
  960. set_errlvl "${PIPESTATUS[0]}"
  961. } 3>&1 1>&2 2>&3
  962. if [ "$?" != "0" ]; then
  963. err "Failed."
  964. return 1
  965. fi
  966. info "mirror-dir ${DARKGREEN}succeeded${NORMAL}."
  967. }
  968. cmdline.spec.gnu recover-target
  969. cmdline.spec::cmd:recover-target:run() {
  970. : :posarg: BACKUP_DIR 'Source directory on backup side'
  971. : :posarg: HOST_DIR 'Target directory on host side'
  972. : :optval: --backup-host,-B "The backup host"
  973. : :optfla: --dry-run,-n "Don't do anything, instead tell what it
  974. would do."
  975. ## if no backup host take the one by default
  976. backup_host="$opt_backup_host"
  977. if [ -z "$backup_host" ]; then
  978. backup_host_ident=$(backup-action get_default_backup_host_ident) || return 1
  979. read -r backup_host ident <<<"$backup_host_ident"
  980. fi
  981. if [[ "$BACKUP_DIR" == /* ]]; then
  982. err "BACKUP_DIR must be a relative path from the root of your backup."
  983. return 1
  984. fi
  985. REAL_HOST_DIR=$(realpath "$HOST_DIR") || {
  986. err "Can't find HOST_DIR '$HOST_DIR'."
  987. return 1
  988. }
  989. export DRY_RUN="${opt_dry_run}"
  990. backup-action recover-target "$backup_host" "$ident" "$BACKUP_DIR" "$REAL_HOST_DIR"
  991. }
  992. cmdline.spec.gnu odoo
  993. cmdline.spec::cmd:odoo:run() {
  994. :
  995. }
  996. cmdline.spec.gnu restart
  997. cmdline.spec:odoo:cmd:restart:run() {
  998. : :optval: --service,-s "The service (defaults to 'odoo')"
  999. local out odoo_service
  1000. odoo_service="${opt_service:-odoo}"
  1001. project_name=$(compose:project_name) || return 1
  1002. if ! out=$(docker restart "${project_name}_${odoo_service}_1" 2>&1); then
  1003. if [[ "$out" == *"no matching entries in passwd file" ]]; then
  1004. warn "Catched docker bug. Restarting once more."
  1005. if ! out=$(docker restart "${project_name}_${odoo_service}_1"); then
  1006. err "Can't restart container ${project_name}_${odoo_service}_1 (restarted twice)."
  1007. echo " output:" >&2
  1008. echo "$out" | prefix " ${GRAY}|${NORMAL} " >&2
  1009. exit 1
  1010. fi
  1011. else
  1012. err "Couldn't restart container ${project_name}_${odoo_service}_1 (and no restart bug detected)."
  1013. exit 1
  1014. fi
  1015. fi
  1016. info "Container ${project_name}_${odoo_service}_1 was ${DARKGREEN}successfully${NORMAL} restarted."
  1017. }
  1018. cmdline.spec.gnu restore
  1019. cmdline.spec:odoo:cmd:restore:run() {
  1020. : :posarg: ZIP_DUMP_LOCATION 'Source odoo dump file to restore
  1021. (can be a local file or an url)'
  1022. : :optval: --service,-s "The service (defaults to 'odoo')"
  1023. : :optval: --database,-d 'Target database (default if not specified)'
  1024. local out
  1025. odoo_service="${opt_service:-odoo}"
  1026. if [[ "$ZIP_DUMP_LOCATION" == "http://"* ]] ||
  1027. [[ "$ZIP_DUMP_LOCATION" == "https://"* ]]; then
  1028. settmpdir ZIP_TMP_LOCATION
  1029. tmp_location="$ZIP_TMP_LOCATION/dump.zip"
  1030. curl -k -s -L "$ZIP_DUMP_LOCATION" > "$tmp_location" || {
  1031. err "Couldn't get '$ZIP_DUMP_LOCATION'."
  1032. exit 1
  1033. }
  1034. if [[ "$(dd if="$tmp_location" count=2 bs=1 2>/dev/null)" != "PK" ]]; then
  1035. err "Download doesn't seem to be a zip file."
  1036. dd if="$tmp_location" count=1 bs=256 | hd | prefix " ${GRAY}|${NORMAL} " >&2
  1037. exit 1
  1038. fi
  1039. info "Successfully downloaded '$ZIP_DUMP_LOCATION'"
  1040. echo " in '$tmp_location'." >&2
  1041. ZIP_DUMP_LOCATION="$tmp_location"
  1042. fi
  1043. [ -e "$ZIP_DUMP_LOCATION" ] || {
  1044. err "No file '$ZIP_DUMP_LOCATION' found." >&2
  1045. exit 1
  1046. }
  1047. #cmdline.spec:odoo:cmd:restart:run --service "$odoo_service" || exit 1
  1048. msg_dbname=default
  1049. [ -n "$opt_database" ] && msg_dbname="'$opt_database'"
  1050. compose --no-hooks drop "$odoo_service" $opt_database || {
  1051. err "Error dropping $msg_dbname database of service ${DARKYELLOW}$odoo_service${NORMAL}."
  1052. exit 1
  1053. }
  1054. compose --no-hooks load "$odoo_service" $opt_database < "$ZIP_DUMP_LOCATION" || {
  1055. err "Error restoring service ${DARKYELLOW}$odoo_service${NORMAL} to $msg_dbname database."
  1056. exit 1
  1057. }
  1058. info "Successfully restored ${DARKYELLOW}$odoo_service${NORMAL} to $msg_dbname database."
  1059. ## Restart odoo, ensure there is no bugs lingering on it.
  1060. cmdline.spec:odoo:cmd:restart:run --service "$odoo_service" || exit 1
  1061. }
  1062. cmdline.spec.gnu dump
  1063. cmdline.spec:odoo:cmd:dump:run() {
  1064. : :posarg: DUMP_ZIPFILE 'Target path to store odoo dump zip file.'
  1065. : :optval: --database,-d 'Target database (default if not specified)'
  1066. : :optval: --service,-s "The service (defaults to 'odoo')"
  1067. odoo_service="${opt_service:-odoo}"
  1068. msg_dbname=default
  1069. [ -n "$opt_database" ] && msg_dbname="'$opt_database'"
  1070. compose --no-hooks save "$odoo_service" $opt_database > "$DUMP_ZIPFILE" || {
  1071. err "Error dumping ${DARKYELLOW}$odoo_service${NORMAL}'s $msg_dbname database to '$DUMP_ZIPFILE'."
  1072. exit 1
  1073. }
  1074. info "Successfully dumped ${DARKYELLOW}$odoo_service${NORMAL}'s $msg_dbname database to '$DUMP_ZIPFILE'."
  1075. }
  1076. cmdline.spec.gnu drop
  1077. cmdline.spec:odoo:cmd:drop:run() {
  1078. : :optval: --database,-d 'Target database (default if not specified)'
  1079. : :optval: --service,-s "The service (defaults to 'odoo')"
  1080. odoo_service="${opt_service:-odoo}"
  1081. msg_dbname=default
  1082. [ -n "$opt_database" ] && msg_dbname="'$opt_database'"
  1083. compose --no-hooks drop "$odoo_service" $opt_database || {
  1084. err "Error dropping ${DARKYELLOW}$odoo_service${NORMAL}'s $msg_dbname database."
  1085. exit 1
  1086. }
  1087. info "Successfully dropped ${DARKYELLOW}$odoo_service${NORMAL}'s $msg_dbname database."
  1088. }
  1089. cmdline.spec.gnu set-cyclos-url
  1090. cmdline.spec:odoo:cmd:set-cyclos-url:run() {
  1091. : :optval: --database,-d "Target database ('odoo' if not specified)"
  1092. : :optval: --service,-s "The cyclos service name (defaults to 'cyclos')"
  1093. local URL
  1094. dbname=${opt_database:-odoo}
  1095. cyclos_service="${opt_service:-cyclos}"
  1096. project_name=$(compose:project_name) || exit 1
  1097. URL=$(compose:get_url "${project_name}" "${cyclos_service}") || exit 1
  1098. Wrap -d "set cyclos url to '$URL'" <<EOF || exit 1
  1099. echo "UPDATE res_company SET cyclos_server_url = '$URL/api' WHERE id=1;" |
  1100. compose:psql "$project_name" "$dbname" || {
  1101. err "Failed to set cyclos url value in '$dbname' database."
  1102. exit 1
  1103. }
  1104. EOF
  1105. }
  1106. cmdline.spec.gnu fix-sso
  1107. cmdline.spec:odoo:cmd:fix-sso:run() {
  1108. : :optval: --database,-d "Target database ('odoo' if not specified)"
  1109. local public_user_id project_name dbname
  1110. dbname=${opt_database:-odoo}
  1111. project_name=$(compose:project_name) || exit 1
  1112. public_user_id=$(odoo:get_public_user_id "${project_name}" "${dbname}") || exit 1
  1113. Wrap -d "fix website's object to 'public_user' (id=$public_user_id)" <<EOF || exit 1
  1114. echo "UPDATE website SET user_id = $public_user_id;" |
  1115. compose:psql "$project_name" "$dbname" || {
  1116. err "Failed to set website's object user_id to public user's id ($public_user_id) in '$dbname' database."
  1117. exit 1
  1118. }
  1119. EOF
  1120. }
  1121. cmdline.spec.gnu cyclos
  1122. cmdline.spec::cmd:cyclos:run() {
  1123. :
  1124. }
  1125. cmdline.spec:cyclos:cmd:dump:run() {
  1126. : :posarg: DUMP_GZFILE 'Target path to store odoo dump gz file.'
  1127. : :optval: --database,-d "Target database ('cyclos' if not specified)"
  1128. : :optval: --service,-s "The cyclos service name (defaults to 'cyclos')"
  1129. cyclos_service="${opt_service:-cyclos}"
  1130. cyclos_database="${opt_database:-cyclos}"
  1131. project_name=$(compose:project_name) || exit 1
  1132. container_id=$(compose:service:container_one "$project_name" "${cyclos_service}") || exit 1
  1133. Wrap -d "stop ${DARKYELLOW}${cyclos_service}${NORMAL}'s container" -- \
  1134. docker stop "$container_id" || exit 1
  1135. Wrap -d "Dump postgres database '${cyclos_database}'." -- \
  1136. postgres:dump "${project_name}" "$cyclos_database" "$DUMP_GZFILE" || exit 1
  1137. Wrap -d "start ${DARKYELLOW}${cyclos_service}${NORMAL}'s container" -- \
  1138. docker start "${container_id}" || exit 1
  1139. }
  1140. cmdline.spec.gnu restore
  1141. cmdline.spec:cyclos:cmd:restore:run() {
  1142. : :posarg: GZ_DUMP_LOCATION 'Source cyclos dump file to restore
  1143. (can be a local file or an url)'
  1144. : :optval: --service,-s "The service (defaults to 'cyclos')"
  1145. : :optval: --database,-d 'Target database (default if not specified)'
  1146. local out
  1147. cyclos_service="${opt_service:-cyclos}"
  1148. cyclos_database="${opt_database:-cyclos}"
  1149. project_name=$(compose:project_name) || exit 1
  1150. url=$(compose:get_url "${project_name}" "${cyclos_service}") || return 1
  1151. container_id=$(compose:service:container_one "$project_name" "${cyclos_service}") || exit 1
  1152. if [[ "$GZ_DUMP_LOCATION" == "http://"* ]] ||
  1153. [[ "$GZ_DUMP_LOCATION" == "https://"* ]]; then
  1154. settmpdir GZ_TMP_LOCATION
  1155. tmp_location="$GZ_TMP_LOCATION/dump.gz"
  1156. Wrap -d "get '$GZ_DUMP_LOCATION'" <<EOF || exit 1
  1157. ## Note that curll version before 7.76.0 do not have
  1158. curl -k -s -L "$GZ_DUMP_LOCATION" --fail \\
  1159. > "$tmp_location" || {
  1160. echo "Error fetching ressource. Is url correct ?" >&2
  1161. exit 1
  1162. }
  1163. if [[ "\$(dd if="$tmp_location" count=2 bs=1 2>/dev/null |
  1164. hexdump -v -e "/1 \"%02x\"")" != "1f8b" ]]; then
  1165. err "Download doesn't seem to be a gzip file."
  1166. dd if="$tmp_location" count=1 bs=256 | hd | prefix " ${GRAY}|${NORMAL} " >&2
  1167. exit 1
  1168. fi
  1169. EOF
  1170. GZ_DUMP_LOCATION="$tmp_location"
  1171. fi
  1172. [ -e "$GZ_DUMP_LOCATION" ] || {
  1173. err "No file '$GZ_DUMP_LOCATION' found." >&2
  1174. exit 1
  1175. }
  1176. Wrap -d "stop ${DARKYELLOW}${cyclos_service}${NORMAL}'s container" -- \
  1177. docker stop "$container_id" || exit 1
  1178. ## XXXvlab: making the assumption that the postgres username should
  1179. ## be the same as the cyclos service selected (which is the default,
  1180. ## but not always the case).
  1181. Wrap -d "restore postgres database '${cyclos_database}'." -- \
  1182. postgres:restore "$project_name" "$GZ_DUMP_LOCATION" "${cyclos_service}@${cyclos_database}" || exit 1
  1183. ## ensure that the database is not locked
  1184. Wrap -d "check and remove database lock if any" -- \
  1185. cyclos:unlock "${project_name}" "${cyclos_database}" || exit 1
  1186. Wrap -d "set root url to '$url'" -- \
  1187. cyclos:set_root_url "${project_name}" "${cyclos_database}" "${url}" || exit 1
  1188. Wrap -d "start ${DARKYELLOW}${cyclos_service}${NORMAL}'s container" -- \
  1189. docker start "${container_id}" || exit 1
  1190. }
  1191. cmdline.spec.gnu set-root-url
  1192. cmdline.spec:cyclos:cmd:set-root-url:run() {
  1193. : :optval: --database,-d "Target database ('cyclos' if not specified)"
  1194. : :optval: --service,-s "The cyclos service name (defaults to 'cyclos')"
  1195. local URL
  1196. cyclos_database=${opt_database:-cyclos}
  1197. cyclos_service="${opt_service:-cyclos}"
  1198. project_name=$(compose:project_name) || exit 1
  1199. url=$(compose:get_url "${project_name}" "${cyclos_service}") || exit 1
  1200. container_id=$(compose:service:container_one "${project_name}" "${cyclos_service}") || exit 1
  1201. Wrap -d "stop ${DARKYELLOW}${cyclos_service}${NORMAL}'s container" -- \
  1202. docker stop "$container_id" || exit 1
  1203. Wrap -d "set root url to '$url'" -- \
  1204. cyclos:set_root_url "${project_name}" "${cyclos_database}" "${url}" || exit 1
  1205. Wrap -d "start ${DARKYELLOW}${cyclos_service}${NORMAL}'s container" -- \
  1206. docker start "${container_id}" || exit 1
  1207. }
  1208. cmdline.spec.gnu unlock
  1209. cmdline.spec:cyclos:cmd:unlock:run() {
  1210. : :optval: --database,-d "Target database ('cyclos' if not specified)"
  1211. : :optval: --service,-s "The cyclos service name (defaults to 'cyclos')"
  1212. local URL
  1213. cyclos_database=${opt_database:-cyclos}
  1214. cyclos_service="${opt_service:-cyclos}"
  1215. project_name=$(compose:project_name) || exit 1
  1216. container_id=$(compose:service:container_one "${project_name}" "${cyclos_service}") || exit 1
  1217. Wrap -d "stop ${DARKYELLOW}${cyclos_service}${NORMAL}'s container" -- \
  1218. docker stop "$container_id" || exit 1
  1219. Wrap -d "check and remove database lock if any" -- \
  1220. cyclos:unlock "${project_name}" "${cyclos_database}" || exit 1
  1221. Wrap -d "start ${DARKYELLOW}${cyclos_service}${NORMAL}'s container" -- \
  1222. docker start "${container_id}" || exit 1
  1223. }
  1224. cmdline.spec.gnu rocketchat
  1225. cmdline.spec::cmd:rocketchat:run() {
  1226. :
  1227. }
  1228. cmdline.spec.gnu drop-indexes
  1229. cmdline.spec:rocketchat:cmd:drop-indexes:run() {
  1230. : :optval: --database,-d "Target database ('rocketchat' if not specified)"
  1231. : :optval: --service,-s "The rocketchat service name (defaults to 'rocketchat')"
  1232. local URL
  1233. rocketchat_database=${opt_database:-rocketchat}
  1234. rocketchat_service="${opt_service:-rocketchat}"
  1235. project_name=$(compose:project_name) || exit 1
  1236. container_id=$(compose:service:container_one "${project_name}" "${rocketchat_service}") || exit 1
  1237. Wrap -d "stop ${DARKYELLOW}${rocketchat_service}${NORMAL}'s container" -- \
  1238. docker stop "$container_id" || exit 1
  1239. errlvl=0
  1240. Wrap -d "drop indexes" -- \
  1241. rocketchat:drop-indexes "${project_name}" "${rocketchat_database}" || {
  1242. errlvl=1
  1243. errmsg="Failed to drop indexes"
  1244. }
  1245. Wrap -d "start ${DARKYELLOW}${rocketchat_service}${NORMAL}'s container" -- \
  1246. docker start "${container_id}" || exit 1
  1247. if [ "$errlvl" != 0 ]; then
  1248. err "$errmsg"
  1249. fi
  1250. exit "$errlvl"
  1251. }
  1252. cmdline.spec.gnu nextcloud
  1253. cmdline.spec::cmd:nextcloud:run() {
  1254. :
  1255. }
  1256. cmdline.spec.gnu upgrade
  1257. cmdline.spec:nextcloud:cmd:upgrade:run() {
  1258. : :posarg: [TARGET_VERSION] "Source cyclos dump file to restore"
  1259. : :optval: --service,-s "The nexcloud service name (defaults to 'nextcloud')"
  1260. local URL
  1261. nextcloud_service="${opt_service:-nextcloud}"
  1262. project_name=$(compose:project_name) || exit 1
  1263. containers=$(compose:service:containers "${project_name}" "${nextcloud_service}") || exit 1
  1264. container_stopped=()
  1265. if [ -n "$containers" ]; then
  1266. for container in $containers; do
  1267. Wrap -d "stop ${DARKYELLOW}${nextcloud_service}${NORMAL}'s container" -- \
  1268. docker stop "$container" || {
  1269. err "Failed to stop container '$container'."
  1270. exit 1
  1271. }
  1272. container_stopped+=("$container")
  1273. done
  1274. fi
  1275. before_version=$(nextcloud:src:version) || exit 1
  1276. ## -q to remove the display of ``compose`` related information
  1277. ## like relation resolution.
  1278. ## --no-hint to remove the final hint about modifying your
  1279. ## ``compose.yml``.
  1280. compose -q upgrade "$nextcloud_service" --no-hint
  1281. errlvl="$?"
  1282. after_version=$(nextcloud:src:version)
  1283. if [ "$after_version" != "$before_version" ]; then
  1284. desc="update \`compose.yml\` to set ${DARKYELLOW}$nextcloud_service${NORMAL}'s "
  1285. desc+="docker image to actual code version ${WHITE}${after_version}${NORMAL}"
  1286. Wrap -d "$desc" -- \
  1287. compose:file:value-change \
  1288. "${nextcloud_service}.docker-compose.image" \
  1289. "docker.0k.io/nextcloud:${after_version}-myc" || exit 1
  1290. fi
  1291. if [ "$errlvl" == 0 ]; then
  1292. echo "${WHITE}Launching final compose${NORMAL}"
  1293. compose up || exit 1
  1294. fi
  1295. exit "$errlvl"
  1296. }
  1297. cmdline.spec.gnu check-fix
  1298. cmdline.spec::cmd:check-fix:run() {
  1299. : :posarg: [SERVICES...] "Optional service to check"
  1300. : :optval: --check,-c "Specify a check or a list of checks separated by commas"
  1301. : :optfla: --silent,-s "Don't ouput anything if everything goes well"
  1302. local project_name service_name containers container check
  1303. all_checks=$(declare -F |
  1304. egrep '^declare -fx? container:health:check-fix:[^ ]+$' |
  1305. cut -f 4 -d ":")
  1306. checks=(${opt_check//,/ })
  1307. for check in "${checks[@]}"; do
  1308. fn.exists container:health:check-fix:$check || {
  1309. err "check '$check' not found."
  1310. return 1
  1311. }
  1312. done
  1313. if [ "${#checks[*]}" == 0 ]; then
  1314. checks=($all_checks)
  1315. fi
  1316. ## XXXvlab: could make it parallel
  1317. project_name=$(compose:project_name) || exit 1
  1318. containers=($(compose:project:containers "${project_name}")) || exit 1
  1319. found=
  1320. for container in "${containers[@]}"; do
  1321. service_name=$(docker ps --filter id="$container" --format '{{.Label "com.docker.compose.service"}}')
  1322. if [ "${#SERVICES[@]}" -gt 0 ]; then
  1323. [[ " ${SERVICES[*]} " == *" $service_name "* ]] || continue
  1324. fi
  1325. found=1
  1326. one_bad=
  1327. for check in "${checks[@]}"; do
  1328. if ! container:health:check-fix:"$check" "$container"; then
  1329. one_bad=1
  1330. fi
  1331. done
  1332. if [ -z "$opt_silent" ] && [ -z "$one_bad" ]; then
  1333. Elt "containers have been checked for ${DARKYELLOW}$service_name${NORMAL}"
  1334. Feedback
  1335. fi
  1336. done
  1337. if [ -z "$found" ]; then
  1338. if [ -z "$opt_silent" ]; then
  1339. if [ "${#SERVICES[@]}" -gt 0 ]; then
  1340. warn "No container for given services found in current project '$project_name'."
  1341. else
  1342. warn "No container found for current project '$project_name'."
  1343. fi
  1344. fi
  1345. return 1
  1346. fi
  1347. }
  1348. cmdline::parse "$@"