Web app support for restoring restic snapshots.

This commit is contained in:
2026-04-29 07:54:54 -07:00
parent ce5d3f0cc6
commit b416fac15d
2 changed files with 130 additions and 14 deletions

View File

@@ -26,7 +26,7 @@ WORKDIR /var/html-widgets
RUN chicken-install RUN chicken-install
WORKDIR /var/ WORKDIR /var/
RUN chicken-install srfi-1 srfi-13 srfi-18 srfi-158 srfi-194 \ RUN chicken-install srfi-1 srfi-13 srfi-18 srfi-19 srfi-158 srfi-194 \
sxml-transforms schematra \ sxml-transforms schematra \
uri-common http-client medea intarweb \ uri-common http-client medea intarweb \
sql-null openssl postgresql crypto-tools sql-null openssl postgresql crypto-tools

View File

@@ -1,5 +1,5 @@
;; (load "db") ;; (load "src/db.scm")
;; (load "mocks") ;; (load "src/mocks.scm")
(include "db") (include "db")
(include "mocks") (include "mocks")
@@ -12,10 +12,12 @@
(chicken irregex) (chicken irregex)
(chicken file) (chicken file)
(chicken condition) (chicken condition)
(chicken sort)
(rename srfi-1 (delete srfi1:delete)) (rename srfi-1 (delete srfi1:delete))
srfi-13 srfi-13
srfi-18 srfi-18
srfi-19
srfi-158 srfi-158
srfi-194 srfi-194
@@ -842,6 +844,64 @@ chmod -R 777 /opt/keys")))
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789") "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789")
40))) 40)))
;; example return value
;; (((time . "2026-04-22T22:24:41.701047574Z") (tree . "42c8556ee6ff87eb2b69a7bc23350026182bc015d7f32ec646d9540f43461754") (paths "/nassella") (hostname . "f042d0fae493") (username . "root") (program_version . "restic 0.18.0") (summary (backup_start . "2026-04-22T22:24:41.701047574Z") (backup_end . "2026-04-22T22:24:51.006181344Z") (files_new . 4069) (files_changed . 0) (files_unmodified . 0) (dirs_new . 108) (dirs_changed . 0) (dirs_unmodified . 0) (data_blobs . 1346) (tree_blobs . 67) (data_added . 69399644) (data_added_packed . 11545191) (total_files_processed . 4069) (total_bytes_processed . 146529878)) (id . "77e70711caca6774dabc255dd63dfcaa788c1bd2c1536fda133442e2b5164473") (short_id . "77e70711")) ((time . "2026-04-23T10:00:05.876773568Z") (tree . "5a1650880a1e688576f941b37892617fcb43022b103a7394d994833b6b05ae75") (paths "/nassella") (hostname . "c4e48b3a29e9") (username . "root") (program_version . "restic 0.18.0") (summary (backup_start . "2026-04-23T10:00:05.876773568Z") (backup_end . "2026-04-23T10:00:15.208834993Z") (files_new . 4069) (files_changed . 0) (files_unmodified . 0) (dirs_new . 108) (dirs_changed . 0) (dirs_unmodified . 0) (data_blobs . 21) (tree_blobs . 46) (data_added . 2903468) (data_added_packed . 470424) (total_files_processed . 4069) (total_bytes_processed . 146503956)) (id . "312e57caf39295ef7be69569631232f2b1b445322636091d63c2082b48b09079") (short_id . "312e57ca")) ((time . "2026-04-24T01:41:40.890895516Z") (tree . "4367a5665a1c6ebb9ea5ddbe40485d58485d3610a7a07a9e22ee0bfea0f044cc") (paths "/nassella") (hostname . "7d01b0ae1b2e") (username . "root") (tags "daily_automatic") (program_version . "restic 0.18.0") (summary (backup_start . "2026-04-24T01:41:40.890895516Z") (backup_end . "2026-04-24T01:41:46.743356138Z") (files_new . 4074) (files_changed . 0) (files_unmodified . 0) (dirs_new . 111) (dirs_changed . 0) (dirs_unmodified . 0) (data_blobs . 29) (tree_blobs . 51) (data_added . 3009785) (data_added_packed . 481008) (total_files_processed . 4074) (total_bytes_processed . 146593600)) (id . "5e7c5f51c46aee69b85e30fade3d6a3b83d07bbe6fa112c75b759966d8848376") (short_id . "5e7c5f51")) ((time . "2026-04-24T10:00:00.956302962Z") (tree . "6edaa49535f5ed860f19c8790d6e4db23b8f79c1b8eb40ac59e73ff524522305") (paths "/nassella") (hostname . "11d8deac4263") (username . "root") (tags "daily_automatic") (program_version . "restic 0.18.0") (summary (backup_start . "2026-04-24T10:00:00.956302962Z") (backup_end . "2026-04-24T10:00:07.649733529Z") (files_new . 4074) (files_changed . 0) (files_unmodified . 0) (dirs_new . 112) (dirs_changed . 0) (dirs_unmodified . 0) (data_blobs . 22) (tree_blobs . 68) (data_added . 4097676) (data_added_packed . 601696) (total_files_processed . 4074) (total_bytes_processed . 146594976)) (id . "50f613854ed4d521b596af4435ba4e3f17587124d0a0de248b19f6052117a689") (short_id . "50f61385")) ((time . "2026-04-25T10:00:01.061830384Z") (tree . "ac797c256a6ee09f51ed504c4234e1ced6cef8e1845c67424618658ea6d55abe") (paths "/nassella") (hostname . "f03ffbd8dfe9") (username . "root") (tags "daily_automatic") (program_version . "restic 0.18.0") (summary (backup_start . "2026-04-25T10:00:01.061830384Z") (backup_end . "2026-04-25T10:00:07.594710184Z") (files_new . 4074) (files_changed . 0) (files_unmodified . 0) (dirs_new . 112) (dirs_changed . 0) (dirs_unmodified . 0) (data_blobs . 21) (tree_blobs . 49) (data_added . 2899995) (data_added_packed . 474478) (total_files_processed . 4074) (total_bytes_processed . 146594977)) (id . "7cb5b3badbcebf8222efe863d61371d8d5017f9df8eeecef42a05125fa33555f") (short_id . "7cb5b3ba")) ((time . "2026-04-26T10:00:01.071960675Z") (tree . "63f60b2b42909fe374a4db97c6dae53b382e69480f17ca1e6b26f218d859d328") (paths "/nassella") (hostname . "6a7def9f0992") (username . "root") (tags "daily_automatic") (program_version . "restic 0.18.0") (summary (backup_start . "2026-04-26T10:00:01.071960675Z") (backup_end . "2026-04-26T10:00:07.762125559Z") (files_new . 4074) (files_changed . 0) (files_unmodified . 0) (dirs_new . 112) (dirs_changed . 0) (dirs_unmodified . 0) (data_blobs . 22) (tree_blobs . 49) (data_added . 2900108) (data_added_packed . 474994) (total_files_processed . 4074) (total_bytes_processed . 146594979)) (id . "f6436aee79cfc8b70d49e81479a46ddce0614e81804607fc1f35a15079e7fe82") (short_id . "f6436aee")))
(define (restic-snapshots user-id instance-id)
(let* ((password-path (conc "restic-password-" user-id "-" instance-id))
(res
(with-db/transaction
(lambda (db)
`((restic-password . ,(get-instance-restic-password db user-id instance-id))
(service-config . ,(get-user-service-config db user-id instance-id))))))
(restic-password (alist-ref 'restic-password res))
(service-config (alist-ref 'service-config res)))
(dynamic-wind
(lambda ()
(with-output-to-file password-path (lambda () (display restic-password))))
(lambda ()
(receive (in-port out-port pid err-port)
(cond-expand
(dev
(process* "docker" `("run" "--rm" "--volume"
,(conc (current-directory) "/" password-path ":/restic-password")
"-e" ,(conc "AWS_ACCESS_KEY_ID="
(alist-ref 'backblaze-key-id service-config))
"-e" ,(conc "AWS_SECRET_ACCESS_KEY="
(alist-ref 'backblaze-application-key service-config))
"-i" "restic/restic:0.18.0" "snapshots"
"--repo" ,(conc "s3:" (alist-ref 'backblaze-bucket-url service-config))
"--password-file" "/restic-password"
"--json")))
(else
(process* "restic"
`("snapshots"
"--repo" ,(conc "s3:" (alist-ref 'backblaze-bucket-url service-config))
"--password-file" password-path
"--json")
`(("AWS_ACCESS_KEY_ID" . ,(alist-ref 'backblaze-key-id service-config))
("AWS_SECRET_ACCESS_KEY" . ,(alist-ref 'backblaze-application-key service-config))))))
(let ((thread
(thread-start!
(lambda ()
(let loop ()
(thread-sleep! 1)
;; We do a non-blocking wait here so that we don't
;; block the entire web process.
(receive (wait-pid exit-normal status) (process-wait pid #t)
(if (= wait-pid 0) ;; wait-pid is 0 until the process has finished
(loop)
(if exit-normal
(let ((res (with-input-from-port in-port read-json)))
;; left here for debugging and to clear ports
(with-input-from-port err-port read-string)
res)
(begin (log-to (debug-log) "restic-snapshots: docker command error")
(error "restic-snapshots docker command had abnormal exit"))))))))))
(thread-join! thread))))
(lambda ()
(delete-file password-path)))))
(with-schematra-app app (with-schematra-app app
(lambda () (lambda ()
@@ -1292,8 +1352,6 @@ chmod -R 777 /opt/keys")))
(VStack (VStack
(Form-Nav (@ (back-to ,(conc "/config/wizard/machine2/" instance-id)) (submit-button "Launch"))))))))) (Form-Nav (@ (back-to ,(conc "/config/wizard/machine2/" instance-id)) (submit-button "Launch")))))))))
;; TODO run restic-init if needed (like the first run or if the backblaze
;; config changes
;; TODO should this perform a backup and then run the systemctl stop app command first? ;; TODO should this perform a backup and then run the systemctl stop app command first?
(post "/config/wizard/review-submit/:id" (post "/config/wizard/review-submit/:id"
(let* ((instance-id (alist-ref "id" (current-params) equal?)) (let* ((instance-id (alist-ref "id" (current-params) equal?))
@@ -1747,14 +1805,18 @@ chmod -R 777 /opt/keys")))
,output))) ,output)))
))))) )))))
(define (roundx n)
(/ (round (* 10 n)) 10.0))
;; The restic date doesn't parse well all the time with srfi-19
;; so we remove the nanoseconds and set it to UTC (which it is)
(define (normalize-restic-date s) (string-append (string-take s (string-index s #\.)) "Z+00:00"))
(define (restic-date-string->date s) (string->date (normalize-restic-date s) "~Y-~m-~dT~H:~M:~S~z"))
(get/widgets (get/widgets
("/backups/:id") ("/backups/:id")
(let* ((instance-id (alist-ref "id" (current-params) equal?)) (let* ((instance-id (alist-ref "id" (current-params) equal?))
;; (res (with-db/transaction (bytes-in-mib 1048576.0))
;; (lambda (db)
;; `((status . ,(get-most-recent-deployment-status db (session-user-id) instance-id))
;; (progress . ,(get-most-recent-deployment-progress db (session-user-id) instance-id))))))
)
`(App `(App
(Main-Container (Main-Container
(VStack (VStack
@@ -1762,11 +1824,65 @@ chmod -R 777 /opt/keys")))
(a (@ (href "/")) "Create Snapshot") ;; TODO (a (@ (href "/")) "Create Snapshot") ;; TODO
(table (table
(thead (thead
(tr (th "Time") (th "Size") (th "Tag") (th "*"))) (tr (th "Time") (th "Data Added (MiB)") (th "Total Size (MiB)") (th "Tag") (th "*")))
(tbody (tbody
(tr (td "2026-04-22 22:24:41") (td "139.742 MiB") (td "") (td (a (@ (href "/")) "Restore"))) ,@(map (lambda (snapshot)
(tr (td "2026-04-21 12:01:03") (td "139.742 MiB") (td "before upgrade") (td (a (@ (href "/")) "Restore"))) `(tr
(tr (td "2026-04-21 22:24:41") (td "129.742 MiB") (td "") (td (a (@ (href "/")) "Restore")))))))))) (td ,(alist-ref 'time snapshot))
(td ,(roundx
(/ (or (alist-ref 'total_bytes_processed (alist-ref 'summary snapshot)) 0) bytes-in-mib)))
(td ,(or (alist-ref 'tags snapshot) ""))
(td (a (@ (href ,(conc "/backups/" instance-id "/"
(alist-ref 'short_id snapshot))))
"Restore"))))
(sort
(restic-snapshots (session-user-id) instance-id)
(lambda (a b) (date>? (restic-date-string->date (alist-ref 'time a))
(restic-date-string->date (alist-ref 'time b)))))))))))))
(get/widgets
("/backups/:instance_id/:restic_id")
(let* ((instance-id (alist-ref "instance_id" (current-params) equal?))
(restic-id (alist-ref "restic_id" (current-params) equal?))
(snapshot-info (find (lambda (snapshot)
(string=? (alist-ref 'short_id snapshot) restic-id))
(restic-snapshots (session-user-id) instance-id)))
(results
(with-db/transaction
(lambda (db)
`((selected-apps . ,(map
car
(filter cdr
(get-user-selected-apps db (session-user-id) instance-id))))
(app-config . ,(get-user-app-config db (session-user-id) instance-id))
(service-config . ,(get-user-service-config db (session-user-id) instance-id))))))
(selected-apps (cons 'log-viewer (alist-ref 'selected-apps results)))
(app-config (alist-ref 'app-config results))
(config (alist-ref 'config app-config))
(root-domain (alist-ref 'root-domain app-config))
(service-config (alist-ref 'service-config results)))
`(App
(Main-Container
(VStack
(h1 "Instance Snapshot Restore")
(ul (li "Snapshot date: " ,(alist-ref 'time snapshot-info))
(li "Snapshot tags: " ,(alist-ref 'tags snapshot-info)))
(h2 "Root Domain")
,root-domain
(h2 "Apps")
(ul ,@(map (lambda (app) `(li ,app " @ "
,(alist-ref 'subdomain (alist-ref app config))
"."
,root-domain))
selected-apps))
(h2 "Machine")
(ul (li "Region: " ,(alist-ref 'digitalocean-region service-config))
(li "Size: " ,(alist-ref 'digitalocean-size service-config)))
(form
(@ (action ,(conc "/config/wizard/review-submit/" instance-id)) (method POST))
(Field (@ (name "restic-snapshot-id") (type "hidden") (value ,restic-id)))
(VStack
(Form-Nav (@ (back-to ,(conc "/backups/" instance-id)) (submit-button "Restore"))))))))))
(schematra-install) (schematra-install)