diff --git a/src/Dockerfile b/src/Dockerfile index a6da46a..1a130a7 100644 --- a/src/Dockerfile +++ b/src/Dockerfile @@ -26,7 +26,7 @@ WORKDIR /var/html-widgets RUN chicken-install 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 \ uri-common http-client medea intarweb \ sql-null openssl postgresql crypto-tools diff --git a/src/nassella.scm b/src/nassella.scm index 2260565..a81b504 100644 --- a/src/nassella.scm +++ b/src/nassella.scm @@ -1,5 +1,5 @@ -;; (load "db") -;; (load "mocks") +;; (load "src/db.scm") +;; (load "src/mocks.scm") (include "db") (include "mocks") @@ -12,10 +12,12 @@ (chicken irregex) (chicken file) (chicken condition) + (chicken sort) (rename srfi-1 (delete srfi1:delete)) srfi-13 srfi-18 + srfi-19 srfi-158 srfi-194 @@ -842,6 +844,64 @@ chmod -R 777 /opt/keys"))) "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789") 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 (lambda () @@ -1292,8 +1352,6 @@ chmod -R 777 /opt/keys"))) (VStack (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? (post "/config/wizard/review-submit/:id" (let* ((instance-id (alist-ref "id" (current-params) equal?)) @@ -1747,14 +1805,18 @@ chmod -R 777 /opt/keys"))) ,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 ("/backups/:id") (let* ((instance-id (alist-ref "id" (current-params) equal?)) - ;; (res (with-db/transaction - ;; (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)))))) -) + (bytes-in-mib 1048576.0)) `(App (Main-Container (VStack @@ -1762,11 +1824,65 @@ chmod -R 777 /opt/keys"))) (a (@ (href "/")) "Create Snapshot") ;; TODO (table (thead - (tr (th "Time") (th "Size") (th "Tag") (th "*"))) + (tr (th "Time") (th "Data Added (MiB)") (th "Total Size (MiB)") (th "Tag") (th "*"))) (tbody - (tr (td "2026-04-22 22:24:41") (td "139.742 MiB") (td "") (td (a (@ (href "/")) "Restore"))) - (tr (td "2026-04-21 12:01:03") (td "139.742 MiB") (td "before upgrade") (td (a (@ (href "/")) "Restore"))) - (tr (td "2026-04-21 22:24:41") (td "129.742 MiB") (td "") (td (a (@ (href "/")) "Restore")))))))))) + ,@(map (lambda (snapshot) + `(tr + (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)