Files
guix-tribes/tests/tribes-deploy-operations.scm
self 7dec823794 chore: sync supertest dev channel to master
Source: guix-tribes master 2ea4cae872
Base: previous supertest-dev 4fee530b68
Mode: tree sync, preserving dev channel authorization
2026-06-08 08:02:39 +02:00

531 lines
25 KiB
Scheme

(define-module (tests tribes-deploy-operations)
#:use-module (ice-9 ftw)
#:use-module (guix build utils)
#:use-module (json)
#:use-module (srfi srfi-1)
#:use-module (srfi srfi-13)
#:use-module (srfi srfi-64)
#:use-module (tests support)
#:use-module (tribes deploy config)
#:use-module (tribes deploy executor)
#:use-module (tribes deploy guix-helper)
#:use-module (tribes deploy json)
#:use-module (tribes deploy operations)
#:use-module (tribes deploy plan)
#:use-module (tribes deploy state)
#:export (run-tests))
(define *fixture-counter* 0)
(define plan-a
'(("plan_hash" . "plan-a")
("resolved_plugins" . ((("name" . "aether"))))))
(define plan-b
'(("plan_hash" . "plan-b")
("resolved_plugins" . ((("name" . "aether"))))))
(define plan-without-channel-delta
'(("plan_hash" . "plan-without-channel-delta")
("resolved_channels" . #())
("resolved_plugins" . ((("name" . "supertest"))))))
(define plan-with-channel-pin
'(("plan_hash" . "plan-with-channel-pin")
("resolved_channels" . #((("channel_id" . "guix-tribes")
("name" . "tribes")
("url" . "https://git.example.test/guix-tribes.git")
("branch" . "master")
("commit" . "abc123")
("position" . 10))))
("resolved_plugins" . ((("name" . "supertest"))))))
(define plan-with-branch-channel
'(("plan_hash" . "plan-with-branch-channel")
("resolved_channels" . #((("channel_id" . "guix-tribes")
("name" . "tribes")
("url" . "https://git.example.test/guix-tribes.git")
("branch" . "master")
("commit" . #f)
("position" . 10))))
("resolved_plugins" . ((("name" . "supertest"))))))
(define (delete-if-present path)
(when (false-if-exception (lstat path))
(delete-file path)))
(define (delete-directory-if-present path)
(when (false-if-exception (lstat path))
(delete-file-recursively path)))
(define (ensure-directory path) (mkdir-p path))
(define (fresh-root)
(set! *fixture-counter* (+ *fixture-counter* 1))
(let ((root (string-append "/tmp/tribes-operations-test-"
(number->string *fixture-counter*))))
(delete-directory-if-present root)
(ensure-directory root)
root))
(define (helper-success-result payload)
((@@ (tribes deploy guix-helper) helper-success) payload '()))
(define (helper-failure-result code message)
((@@ (tribes deploy guix-helper) helper-failure) code message '() '()))
;; Build a fake helper backend that imitates the broker's view of the world.
;; The build step writes a fake store-path symlink so the broker can
;; canonicalize the GC root link the way the real helper would.
(define* (make-fake-helper fixture #:key (diverge-running? #f))
(let ((build-count 0)
(pull-count 0)
(switch-count 0)
(store-directory (assq-ref fixture 'store-directory))
(profiles-directory (assq-ref fixture 'profiles-directory))
(system-profile-link (assq-ref fixture 'system-profile-link))
(current-system-link (assq-ref fixture 'current-system-link)))
(let ((backend
(make-helper-backend
(lambda (_cfg _on-frame)
(helper-success-result
'(("event" . "done")
("catalog" . (("schemaVersion" . "2") ("plugins" . #()))))))
(lambda (_cfg _on-frame)
(set! pull-count (+ pull-count 1))
(helper-success-result
'(("event" . "done")
("phase" . "pulling")
("channels" . ((("name" . "tribes")
("url" . "https://git.example.test/guix-tribes.git")
("branch" . "master")
("commit" . "pulled456")))))))
(lambda (_cfg root-link _on-frame)
(set! build-count (+ build-count 1))
(let ((store-path
(string-append store-directory "/system-"
(number->string build-count))))
(ensure-directory store-path)
(delete-if-present root-link)
(symlink store-path root-link)
(helper-success-result
`(("event" . "done")
("store_path" . ,store-path)))))
(lambda (_cfg generation-number _on-frame)
(let* ((generation-link
(string-append profiles-directory
"/system-"
(number->string generation-number)
"-link"))
(store-path (false-if-exception
(canonicalize-path generation-link))))
(cond
((and store-path (file-exists? store-path))
(set! switch-count (+ switch-count 1))
(delete-if-present system-profile-link)
(symlink generation-link system-profile-link)
(unless diverge-running?
(delete-if-present current-system-link)
(symlink generation-link current-system-link))
(helper-success-result
`(("event" . "done")
("generation_number" . ,generation-number))))
(else
(helper-failure-result
"switch_failed"
"fake helper: missing store path"))))))))
(values backend
(lambda () build-count)
(lambda () pull-count)
(lambda () switch-count)))))
(define (write-json-file path payload)
(call-with-output-file path
(lambda (port)
(scm->json (json-ready payload) port))))
(define (read-text-file path)
(call-with-input-file path
(lambda (port)
(let loop ((chars '()))
(let ((char (read-char port)))
(if (eof-object? char)
(list->string (reverse chars))
(loop (cons char chars))))))))
(define (make-fixture)
(let* ((root (fresh-root))
(deploy-directory (string-append root "/deploy"))
(etc-directory (string-append root "/etc/tribes"))
(profiles-directory (string-append root "/profiles"))
(store-directory (string-append root "/store"))
(current-system-directory (string-append root "/current-system"))
(host-config-file (string-append etc-directory "/host-config.json"))
(channels-file (string-append etc-directory "/channels.scm"))
(current-config-file (string-append current-system-directory
"/configuration.scm"))
(current-system-link (string-append root "/run-current-system"))
(system-profile-link (string-append profiles-directory "/system")))
(ensure-directory deploy-directory)
(ensure-directory etc-directory)
(ensure-directory profiles-directory)
(ensure-directory store-directory)
(ensure-directory current-system-directory)
(write-json-file host-config-file
'(("schemaVersion" . "1")
("tribes" . (("host" . "example.test")
("plugins" . ())))))
(call-with-output-file channels-file
(lambda (port) (display "()\n" port)))
(call-with-output-file current-config-file
(lambda (port) (display ";; test config\n" port)))
`((root . ,root)
(deploy-directory . ,deploy-directory)
(host-config-file . ,host-config-file)
(channels-file . ,channels-file)
(current-config-file . ,current-config-file)
(current-system-link . ,current-system-link)
(system-profile-link . ,system-profile-link)
(system-profile-directory . ,profiles-directory)
(profiles-directory . ,profiles-directory)
(store-directory . ,store-directory))))
(define (fixture->config fixture)
(deploy-config
(deploy-directory (assq-ref fixture 'deploy-directory))
(host-config-file (assq-ref fixture 'host-config-file))
(channels-file (assq-ref fixture 'channels-file))
(current-config-file (assq-ref fixture 'current-config-file))
(current-system-link (assq-ref fixture 'current-system-link))
(system-profile-link (assq-ref fixture 'system-profile-link))
(system-profile-directory (assq-ref fixture 'system-profile-directory))
(helper-binary "fake-helper-not-used")))
(define (no-frame _) #t)
(define rollback-herd-command
(@@ (tribes deploy operations) rollback-herd-command))
(define write-plan-channels!
(@@ (tribes deploy operations) write-plan-channels!))
(define (write-executable path content)
(call-with-output-file path
(lambda (port) (display content port)))
(chmod path #o755))
(define (with-fake-rollback-herd fixture thunk)
(let* ((herd (string-append (assq-ref fixture 'root) "/fake-herd"))
(log-file (string-append (assq-ref fixture 'root) "/herd.log")))
(write-executable
herd
(string-append "#!/bin/sh\n"
"printf '%s\\n' \"$*\" >> " log-file "\n"
"exit 0\n"))
(parameterize ((rollback-herd-command herd))
(thunk))))
(define (find-generation-by-plan-hash generations plan-hash)
(find (lambda (generation)
(equal? (json-ref generation "plan_hash") plan-hash))
generations))
(define (seed-profile-generation! fixture generation-number store-name)
(let* ((store-path (string-append (assq-ref fixture 'store-directory)
"/" store-name))
(generation-link (string-append (assq-ref fixture 'profiles-directory)
"/system-"
(number->string generation-number)
"-link")))
(ensure-directory store-path)
(delete-if-present generation-link)
(symlink store-path generation-link)
store-path))
(define (run-tests)
(test-begin "tribes-deploy-operations")
(let* ((fixture (make-fixture))
(config (fixture->config fixture))
(channels-file (assq-ref fixture 'channels-file))
(plan `(("plan_hash" . "plan-with-plugin-channel")
("resolved_channels" . ,(vector '(("channel_id" . "guix-tribes")
("name" . "tribes")
("url" . "https://git.example.test/guix-tribes.git")
("branch" . "supertest-dev")
("commit" . "plugin-commit")
("position" . 10)))))))
(call-with-output-file channels-file
(lambda (port)
(display "(list\n" port)
(display " (channel (name 'guix) (url \"https://git.example.test/guix.git\") (branch \"master\") (commit \"guix-commit\"))\n" port)
(display ")\n" port)))
(write-plan-channels! config plan)
(let ((channels (read-text-file channels-file)))
(test-assert "plan channel writer preserves current guix channel"
(string-contains channels "(name (quote guix))"))
(test-assert "plan channel writer includes plugin channel"
(string-contains channels "supertest-dev"))))
(let* ((fixture (make-fixture))
(state (make-state-store (fixture->config fixture))))
(call-with-values (lambda () (make-fake-helper fixture))
(lambda (helper get-builds get-pulls get-switches)
(let* ((prepare-1 (prepare-plugins! state helper
(plan-plugins plan-a)
(plan-hash plan-a)
no-frame))
(prepare-2 (prepare-plugins! state helper
(plan-plugins plan-a)
(plan-hash plan-a)
no-frame)))
(test-equal "prepare returns ready"
"ready"
(json-ref prepare-1 "status"))
(test-equal "prepare is idempotent on plan_hash"
1 (get-builds))
(test-equal "idempotent prepare reuses generation number"
(json-ref prepare-1 "generation_number")
(json-ref prepare-2 "generation_number"))
(test-equal "prepare does not switch generations"
0 (get-switches))
(let* ((commit (commit-plan! state helper (plan-hash plan-a)
no-frame))
(generations (state-store-read-generations state))
(generation-a (find-generation-by-plan-hash
generations (plan-hash plan-a))))
(test-equal "commit switches after prepare"
"healthy" (json-ref commit "status"))
(test-equal "commit triggers one switch"
1 (get-switches))
(test-equal "active generation matches prepared store path"
(json-ref prepare-1 "store_path")
(state-store-running-system-path state))
(test-equal "generation is marked active after commit"
"active" (json-ref generation-a "status")))))))
(let* ((fixture (make-fixture))
(state (make-state-store (fixture->config fixture))))
(call-with-values (lambda () (make-fake-helper fixture))
(lambda (helper _get-builds _get-pulls _get-switches)
(let* ((prepared (prepare-plugins! state helper
(plan-plugins plan-with-channel-pin)
(plan-hash plan-with-channel-pin)
no-frame
#:plan plan-with-channel-pin))
(committed (commit-plan! state helper
(plan-hash plan-with-channel-pin)
no-frame))
(generation (find-generation-by-plan-hash
(state-store-read-generations state)
(plan-hash plan-with-channel-pin)))
(channels (json-list-ref generation "channels"))
(channel (car channels)))
(test-equal "prepared generation records channel pins"
"ready"
(json-ref prepared "status"))
(test-equal "active generation keeps channel pins"
"healthy"
(json-ref committed "status"))
(test-equal "generation channel pin records pulled commit"
"pulled456"
(json-ref channel "commit"))))))
(let* ((fixture (make-fixture))
(state (make-state-store (fixture->config fixture))))
(call-with-values (lambda () (make-fake-helper fixture))
(lambda (helper _get-builds _get-pulls _get-switches)
(prepare-plugins! state helper
(plan-plugins plan-with-branch-channel)
(plan-hash plan-with-branch-channel)
no-frame
#:plan plan-with-branch-channel)
(let* ((generation (find-generation-by-plan-hash
(state-store-read-generations state)
(plan-hash plan-with-branch-channel)))
(channels (json-list-ref generation "channels"))
(channel (car channels)))
(test-equal "branch channel records exact pulled commit"
"pulled456"
(json-ref channel "commit"))))))
(let* ((fixture (make-fixture))
(state (make-state-store (fixture->config fixture))))
(call-with-values (lambda () (make-fake-helper fixture))
(lambda (helper get-builds get-pulls get-switches)
(let ((prepared (prepare-plugins! state helper
(plan-plugins plan-without-channel-delta)
(plan-hash plan-without-channel-delta)
no-frame
#:pull-required?
(plan-requires-pull?
plan-without-channel-delta))))
(test-equal "prepare skips pull for plans with no channel delta"
"ready"
(json-ref prepared "status"))
(test-equal "no-channel-delta prepare does not pull"
0 (get-pulls))
(test-equal "no-channel-delta prepare still builds"
1 (get-builds))
(test-equal "no-channel-delta prepare does not switch"
0 (get-switches))))))
(let* ((fixture (make-fixture))
(state (make-state-store (fixture->config fixture))))
(with-fake-rollback-herd
fixture
(lambda ()
(call-with-values (lambda () (make-fake-helper fixture))
(lambda (helper get-builds get-pulls get-switches)
(let* ((prepared-a (prepare-plugins! state helper (plan-plugins plan-a)
(plan-hash plan-a) no-frame))
(_ca (commit-plan! state helper (plan-hash plan-a) no-frame))
(_pb (prepare-plugins! state helper (plan-plugins plan-b)
(plan-hash plan-b) no-frame))
(_cb (commit-plan! state helper (plan-hash plan-b) no-frame))
(rollback (rollback-store-path! state helper
(json-ref prepared-a "store_path")
#f no-frame)))
(test-equal "direct rollback succeeds for retained generation"
"healthy" (json-ref rollback "status"))
(test-equal "direct rollback does not rebuild"
2 (get-builds))
(test-equal "direct rollback performs a third switch"
3 (get-switches))
(test-equal "rolled back system points at prior store path"
(json-ref prepared-a "store_path")
(state-store-running-system-path state))))))))
(let* ((fixture (make-fixture))
(state (make-state-store (fixture->config fixture)))
(baseline-store-path (seed-profile-generation! fixture 1 "baseline-system"))
(system-profile-link (assq-ref fixture 'system-profile-link))
(current-system-link (assq-ref fixture 'current-system-link)))
(delete-if-present system-profile-link)
(symlink (string-append (assq-ref fixture 'profiles-directory)
"/system-1-link")
system-profile-link)
(delete-if-present current-system-link)
(symlink (string-append (assq-ref fixture 'profiles-directory)
"/system-1-link")
current-system-link)
(with-fake-rollback-herd
fixture
(lambda ()
(call-with-values (lambda () (make-fake-helper fixture))
(lambda (helper get-builds get-pulls get-switches)
(let* ((_prepared (prepare-plugins! state helper (plan-plugins plan-a)
(plan-hash plan-a) no-frame))
(_commit (commit-plan! state helper (plan-hash plan-a) no-frame))
(rollback (rollback-store-path! state helper baseline-store-path
#f no-frame)))
(test-equal "rollback can switch to an unrecorded Guix profile generation"
"healthy" (json-ref rollback "status"))
(test-equal "profile-generation rollback does not rebuild"
1 (get-builds))
(test-equal "profile-generation rollback performs the second switch"
2 (get-switches))
(test-equal "profile-generation rollback returns to the baseline store path"
baseline-store-path
(state-store-running-system-path state))))))))
(let* ((fixture (make-fixture))
(state (make-state-store (fixture->config fixture))))
(with-fake-rollback-herd
fixture
(lambda ()
(call-with-values (lambda () (make-fake-helper fixture))
(lambda (helper get-builds get-pulls get-switches)
(let* ((prepared-a (prepare-plugins! state helper (plan-plugins plan-a)
(plan-hash plan-a) no-frame))
(_ca (commit-plan! state helper (plan-hash plan-a) no-frame))
(_pb (prepare-plugins! state helper (plan-plugins plan-b)
(plan-hash plan-b) no-frame))
(_cb (commit-plan! state helper (plan-hash plan-b) no-frame))
(_drop (delete-directory-if-present
(json-ref prepared-a "store_path")))
(rollback (rollback-store-path! state helper
(json-ref prepared-a "store_path")
plan-a no-frame))
(generation-a (find-generation-by-plan-hash
(state-store-read-generations state)
(plan-hash plan-a))))
(test-equal "rollback rebuilds from plan when store path is gone"
"healthy" (json-ref rollback "status"))
(test-equal "rebuild fallback performs a third build"
3 (get-builds))
(test-equal "rebuild fallback performs a third switch"
3 (get-switches))
(test-assert "rebuild fallback records a new active generation"
(> (json-ref generation-a "generation_number") 1))
(test-assert "rebuild fallback activates a new store path"
(not (string=? (state-store-running-system-path state)
(json-ref prepared-a "store_path"))))))))))
(let* ((fixture (make-fixture))
(state (make-state-store (fixture->config fixture))))
(with-fake-rollback-herd
fixture
(lambda ()
(call-with-values (lambda () (make-fake-helper fixture))
(lambda (helper get-builds get-pulls get-switches)
(let* ((prepared-a (prepare-plugins! state helper (plan-plugins plan-a)
(plan-hash plan-a) no-frame))
(_ca (commit-plan! state helper (plan-hash plan-a) no-frame))
(_drop (delete-directory-if-present
(json-ref prepared-a "store_path")))
(rollback (rollback-store-path! state helper
(json-ref prepared-a "store_path")
#f no-frame)))
(test-equal "rollback without a retained store path or plan is infeasible"
"rollback_infeasible" (json-ref rollback "code"))))))))
(let* ((fixture (make-fixture))
(state (make-state-store (fixture->config fixture)))
(current-system-link (assq-ref fixture 'current-system-link))
(old-running (string-append (assq-ref fixture 'store-directory)
"/running-before-commit")))
(ensure-directory old-running)
(delete-if-present current-system-link)
(symlink old-running current-system-link)
(call-with-values (lambda () (make-fake-helper fixture #:diverge-running? #t))
(lambda (helper get-builds get-pulls get-switches)
(let* ((prepared (prepare-plugins! state helper (plan-plugins plan-a)
(plan-hash plan-a) no-frame))
(commit (commit-plan! state helper (plan-hash plan-a) no-frame)))
(test-equal "commit reports selected store path even if running link lags"
(json-ref prepared "store_path")
(json-ref commit "store_path"))
(test-equal "commit exposes selected system separately"
(json-ref prepared "store_path")
(json-ref commit "selectedSystem"))
(test-equal "commit preserves divergent running system for diagnostics"
old-running
(json-ref commit "runningSystem"))))))
(call-with-values (lambda () (resolve-deployment '()))
(lambda (status payload)
(test-equal "resolve-deployment wraps successful plans with 200"
200 status)
(test-assert "resolve-deployment success response is ok"
(equal? (json-ref payload "ok") #t))
(test-assert "resolve-deployment success response contains a plan"
(json-object? (json-ref payload "plan")))
(test-assert "resolve-deployment success plan has a hash"
(string? (json-ref (json-ref payload "plan") "plan_hash")))))
(call-with-values
(lambda ()
(resolve-deployment
'(("plugins" . ((("plugin_name" . "aether") ("enabled" . #t))
(("plugin_name" . "aether") ("enabled" . #t)))))))
(lambda (status payload)
(test-equal "resolve-deployment returns 409 for explicit resolver errors"
409 status)
(test-assert "resolve-deployment conflict payload is an explicit resolver error"
(and (equal? (json-ref payload "ok") #f)
(json-object? (json-ref payload "error"))))))
(test-end "tribes-deploy-operations"))
(run-tests-when-script "tests/tribes-deploy-operations.scm" run-tests)