Web app support for restoring restic snapshots.
This commit is contained in:
@@ -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
|
||||||
|
|||||||
142
src/nassella.scm
142
src/nassella.scm
@@ -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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user