~ruther/guix-local

054aae7bb201f6ba83390017a4cc644061a6abda — Rodion Goritskov 10 months ago d411aff
services: Add miniflux-service-type.

* doc/guix.texi: Document Miniflux service and configuration.
* gnu/services/web.scm: New service.
* gnu/services/web.scm: Define shepherd service and account roles.
* gnu/tests/web.scm: (%miniflux-create-admin-credentials,
miniflux-base-system, %test-miniflux-admin-string, %test-miniflux-admin-file,
%test-miniflux-socket): Add system tests for Miniflux service.

Change-Id: I4a336e677ec8b46aed632f0ded9cc11c2d38975f
Signed-off-by: Ludovic Courtès <ludo@gnu.org>
3 files changed, 461 insertions(+), 2 deletions(-)

M doc/guix.texi
M gnu/services/web.scm
M gnu/tests/web.scm
M doc/guix.texi => doc/guix.texi +75 -0
@@ 144,6 144,7 @@ Copyright @copyright{} 2024 Evgeny Pisemsky@*
Copyright @copyright{} 2025 jgart@*
Copyright @copyright{} 2025 Artur Wroblewski@*
Copyright @copyright{} 2025 Edouard Klein@*
Copyright @copyright{} 2025 Rodion Goritskov@*

Permission is granted to copy, distribute and/or modify this document
under the terms of the GNU Free Documentation License, Version 1.3 or


@@ 35700,6 35701,80 @@ The file which should store the logging output of Agate.
@end table
@end deftp

@subsubheading Miniflux

@cindex miniflux
The @uref{https://miniflux.app/, Miniflux} is a minimalist RSS feed reader
with a web interface.

Depending on the configuration, an initial administrator user can be pre-created
on startup. To enable this, @code{create-admin?} should be set to @code{#t}, and
both @code{admin-username-file} and @code{admin-password-file} should point to
files containing the username and password, respectively. However, it is
recommended to manually change the password to a secure one via the web
UI after the initial service startup.

@defvar miniflux-service-type
This is the type of the Miniflux service. Its value
must be a @code{miniflux-configuration} record as in this example:

@lisp
(service miniflux-service-type
         (miniflux-configuration
          (listen-address "0.0.0.0:8080")
          (base-url "http://my-news-source.test")
          (create-administrator-account? #t)
          (administrator-account-name "/var/miniflux/initial-admin-username")
          (administrator-account-password "/var/miniflux/initial-admin-password")))
@end lisp

The details of the @code{miniflux-configuration} record type are given below.

@end defvar

@deftp {Data Type} miniflux-configuration
Available @code{miniflux-configuration} fields are:

@table @asis
@item @code{listen-address} (default: @code{"127.0.0.1:8080"}) (type: string)
Address to listen on.
Use absolute path like @code{"/var/run/miniflux/miniflux.sock"} for a Unix socket.

@item @code{base-url} (default: @code{"http://127.0.0.1/"}) (type: string)
Base URL to generate HTML links and base path for cookies.

@item @code{create-administrator-account?} (default: @code{#f}) (type: boolean)
Create an initial administrator account.

@item @code{administrator-account-name} (type: maybe-string-or-file-path)
Initial administrator account name as a string or an absolute path
to a file with an account name inside.

@item @code{administrator-account-password} (type: maybe-string-or-file-path)
Initial administrator account password as a string or an absolute path
to a file with a password inside.

@item @code{run-migrations?} (default: @code{#t}) (type: boolean)
Run database migrations during application startup.

@item @code{database-url} (default: @code{"host=/var/run/postgresql"}) (type: string)
PostgreSQL connection string.

@item @code{user} (default: @code{"miniflux"}) (type: string)
User name for Postgresql and system account.

@item @code{group} (default: @code{"miniflux"}) (type: string)
Group for the system account.

@item @code{log-file} (default: @code{"/var/log/miniflux.log"}) (type: string)
Path to the log file.

@item @code{extra-settings} (type: maybe-list)
Extra configuration parameters as a list of strings.

@end table
@end deftp

@node High Availability Services
@subsection High Availability Services


M gnu/services/web.scm => gnu/services/web.scm +193 -1
@@ 19,6 19,7 @@
;;; Copyright © 2023 Miguel Ángel Moreno <mail@migalmoreno.com>
;;; Copyright © 2024 Leo Nikkilä <hello@lnikki.la>
;;; Copyright © 2025 Maxim Cournoyer <maxim.cournoyer@gmail.com>
;;; Copyright © 2025 Rodion Goritskov <rodion@goritskov.com>
;;;
;;; This file is part of GNU Guix.
;;;


@@ 40,8 41,10 @@
  #:use-module (gnu services shepherd)
  #:use-module (gnu services admin)
  #:use-module (gnu services configuration)
  #:use-module (gnu services databases)
  #:use-module (gnu services getmail)
  #:use-module (gnu services mail)
  #:use-module (gnu system file-systems)
  #:use-module (gnu system pam)
  #:use-module (gnu system shadow)
  #:use-module (gnu packages admin)


@@ 59,7 62,9 @@
  #:use-module (gnu packages mail)
  #:use-module (gnu packages rust-apps)
  #:autoload   (guix i18n) (G_)
  #:autoload   (gnu build linux-container) (%namespaces)
  #:use-module (guix diagnostics)
  #:use-module (guix least-authority)
  #:use-module (guix packages)
  #:use-module (guix records)
  #:use-module (guix modules)


@@ 74,6 79,7 @@
  #:use-module (srfi srfi-34)
  #:use-module (ice-9 match)
  #:use-module (ice-9 format)
  #:use-module (ice-9 regex)
  #:export (httpd-configuration
            httpd-configuration?
            httpd-configuration-package


@@ 328,7 334,23 @@
            agate-configuration-group
            agate-configuration-log-file

            agate-service-type))
            agate-service-type

            miniflux-configuration
            miniflux-configuration?
            miniflux-configuration-listen-address
            miniflux-configuration-base-url
            miniflux-configuration-create-administrator-account?
            miniflux-configuration-administrator-account-name
            miniflux-configuration-administrator-account-password
            miniflux-configuration-run-migrations?
            miniflux-configuration-database-url
            miniflux-configuration-user
            miniflux-configuration-group
            miniflux-configuration-log-file
            miniflux-configuration-extra-settings

            miniflux-service-type))

;;; Commentary:
;;;


@@ 2279,3 2301,173 @@ root=/srv/gemini
   (default-value (agate-configuration))
   (description "Run Agate, a simple Gemini protocol server written in
Rust.")))

(define (serialize-string field-name val)
  (format #f "~a=~a\n" field-name val))

(define (string-or-file-path? val)
  (string? val))
(define (serialize-string-or-file-path field-name val)
  (serialize-string (if (absolute-file-name? val)
                        (format #f "~a_FILE" field-name) field-name) val))
(define-maybe string-or-file-path)

(define (serialize-list field-name val)
  (string-append (string-join val "\n") "\n"))
(define-maybe list)

(define (serialize-boolean field-name val)
  (if val (serialize-string field-name "1") (serialize-string field-name "0")))

(define-configuration/no-serialization miniflux-configuration
  (listen-address
   (string "127.0.0.1:8080")
   "Address to listen on.
Use absolute path like @code{\"/var/run/miniflux/miniflux.sock\"} for a Unix socket.")
  (base-url
   (string "http://127.0.0.1/")
   "Base URL to generate HTML links and base path for cookies.")
  (create-administrator-account?
   (boolean #f)
   "Create an initial administrator account.")
  (administrator-account-name
   maybe-string-or-file-path
   "Initial administrator account name as a string or an absolute path to a file with a account name inside.")
  (administrator-account-password
   maybe-string-or-file-path
   "Initial administrator account password as a string or an absolute path to a file with a password inside.")
  (run-migrations?
   (boolean #t)
   "Run database migrations during application startup.")
  (database-url
   (string "host=/var/run/postgresql")
   "PostgreSQL connection string.")
  (user
   (string "miniflux")
   "User name for Postgresql and system account.")
  (group
   (string "miniflux")
   "Group for the system account.")
  (log-file
   (string "/var/log/miniflux.log")
   "Path to the log file.")
  (extra-settings
   maybe-list
   "Extra configuration parameters as a list of strings."))

(define (miniflux-serialize-configuration config)
  (match-record config <miniflux-configuration>
                (listen-address base-url create-administrator-account?
                                administrator-account-name administrator-account-password
                                run-migrations? database-url extra-settings)
    (string-append (serialize-string "LISTEN_ADDR" listen-address)
                   (serialize-string "BASE_URL" base-url)
                   (serialize-boolean "CREATE_ADMIN" create-administrator-account?)
                   (serialize-maybe-string-or-file-path "ADMIN_USERNAME" administrator-account-name)
                   (serialize-maybe-string-or-file-path "ADMIN_PASSWORD" administrator-account-password)
                   (serialize-boolean "RUN_MIGRATIONS" run-migrations?)
                   (serialize-string "DATABASE_URL" database-url)
                   (serialize-maybe-list #f extra-settings))))

(define (miniflux-configuration-file config)
  (mixed-text-file "miniflux.conf" (miniflux-serialize-configuration config)))

(define (pair->file-system-mapping pair previous)
  (if (pair? pair)
      (let ((path (car pair))
            (writable (cdr pair)))
        (if (or (and (string? path)
                     (absolute-file-name? path))
                (computed-file? path))
            (append previous (list (file-system-mapping
                                     (source path)
                                     (target source)
                                     (writable? writable))))
            previous))
      previous))

(define (miniflux-shepherd-service config)
  (match-record config <miniflux-configuration>
                (user group log-file database-url listen-address
                      administrator-account-name administrator-account-password)
    (let ((config-file (miniflux-configuration-file config)))
      (list (shepherd-service
	      (documentation "Run Miniflux server")
	      (provision '(miniflux))
	      (requirement '(postgres networking))
	      (start #~(make-forkexec-constructor
		        (list #$(least-authority-wrapper
                                 (file-append miniflux "/bin/miniflux")
                                 #:name "miniflux"
                                 #:user user
                                 #:group group
                                 #:preserved-environment-variables
                                 (append %default-preserved-environment-variables
                                         '("SSL_CERT_FILE"))
                                 #:mappings
                                 (fold pair->file-system-mapping
                                       '()
                                       `((,log-file . #t)
                                         (,config-file . #f)
                                         ("/etc/ssl/certs/ca-certificates.crt" . #f)
                                         (,administrator-account-name . #f)
                                         (,administrator-account-password . #f)
                                         (,(dirname listen-address)  . #t)
                                         ,(let* ((db-socket-match (string-match ".*host=(/[^ ]*).*" database-url))
                                                 (db-socket (if db-socket-match (match:substring db-socket-match 1) #f)))
                                            (if db-socket
                                                `(,db-socket . #t)))))
                                 #:namespaces
                                 (fold delq %namespaces '(net user)))
                              "-config-file"
                              #$config-file)
		        #:log-file #$log-file))
              (stop #~(make-kill-destructor)))))))

(define (miniflux-accounts config)
  (match-record config <miniflux-configuration>
                (user group)
    `(,(user-group
         (name group)
         (system? #t))
      ,(user-account
         (name user)
         (group group)
         (system? #t)
         (comment "miniflux server user")
         (home-directory "/var/empty")
         (shell (file-append shadow "/sbin/nologin"))))))

(define (miniflux-postgresql-role config)
  (list (postgresql-role
          (name (miniflux-configuration-user config))
          (create-database? #t))))

(define (miniflux-log-files config)
  (list (miniflux-configuration-log-file config)))

(define (miniflux-activation-service-type config)
  (match-record config <miniflux-configuration>
                (user listen-address)
    #~(begin
        (use-modules (gnu build activation))
        (let ((user (getpwnam #$user)))
          (if (absolute-file-name? #$listen-address)
              (mkdir-p/perms (dirname #$listen-address) user #o755))))))

(define miniflux-service-type
  (service-type
    (name 'miniflux)
    (default-value (miniflux-configuration))
    (extensions
     (list (service-extension account-service-type
			      miniflux-accounts)
	   (service-extension postgresql-role-service-type
			      miniflux-postgresql-role)
	   (service-extension shepherd-root-service-type
			      miniflux-shepherd-service)
           (service-extension log-rotation-service-type
                              miniflux-log-files)
           (service-extension activation-service-type
                              miniflux-activation-service-type)))
    (description "Run Miniflux, minimalist feed reader")))

M gnu/tests/web.scm => gnu/tests/web.scm +193 -1
@@ 5,6 5,7 @@
;;; Copyright © 2018 Pierre-Antoine Rouby <pierre-antoine.rouby@inria.fr>
;;; Copyright © 2018 Marius Bakke <mbakke@fastmail.com>
;;; Copyright © 2024 Maxim Cournoyer <maxim@guixotic.coop>
;;; Copyright © 2025 Rodion Goritskov <rodion@goritskov.com>
;;;
;;; This file is part of GNU Guix.
;;;


@@ 37,6 38,7 @@
  #:use-module (gnu packages base)
  #:use-module (gnu packages databases)
  #:use-module (gnu packages guile-xyz)
  #:use-module (gnu packages gnupg)
  #:use-module (gnu packages patchutils)
  #:use-module (gnu packages python)
  #:use-module (gnu packages tls)


@@ 56,7 58,10 @@
            %test-hpcguix-web
            %test-anonip
            %test-patchwork
            %test-agate))
            %test-agate
            %test-miniflux-admin-string
            %test-miniflux-admin-file
            %test-miniflux-socket))

(define %index.html-contents
  ;; Contents of the /index.html file.


@@ 848,3 853,190 @@ HTTP-PORT."
   (name "agate")
   (description "Connect to a running Agate service.")
   (value (run-agate-test name %agate-os %index.gmi-contents))))


;;;
;;; Miniflux
;;;

(define %miniflux-create-admin-credentials
  #~(begin
      (mkdir "/var/miniflux")
      (call-with-output-file "/var/miniflux/admin-username"
        (lambda (port)
          (display "test" port)))
      (call-with-output-file "/var/miniflux/admin-password"
        (lambda (port)
          (display "testpassword" port)))))

(define miniflux-base-system
  (lambda (miniflux-config)
    (simple-operating-system
     (simple-service 'create-admin-credentials
                     activation-service-type
                     %miniflux-create-admin-credentials)
     (service dhcpcd-service-type)
     (service postgresql-service-type
	      (postgresql-configuration
	        (postgresql postgresql-13)))
     (service miniflux-service-type
              miniflux-config))))

(define %miniflux-with-admin-as-string
  (miniflux-base-system
   (miniflux-configuration
    (listen-address "0.0.0.0:8080")
    (create-administrator-account? #t)
    (administrator-account-name "test")
    (administrator-account-password "testpassword"))))

(define %miniflux-with-admin-as-file
  (miniflux-base-system
   (miniflux-configuration
    (listen-address "0.0.0.0:8080")
    (create-administrator-account? #t)
    (administrator-account-name "/var/miniflux/admin-username")
    (administrator-account-password "/var/miniflux/admin-password"))))

(define %miniflux-with-socket
  (miniflux-base-system
   (miniflux-configuration
    (listen-address "/var/run/miniflux/miniflux.sock"))))

(define* (run-miniflux-test name test-os)
  (define os
    (marionette-operating-system
     test-os
     #:imported-modules '((gnu services herd)
                          (guix combinators))))

  (define forwarded-port 8080)

  (define vm
    (virtual-machine
      (operating-system os)
      (memory-size 512)
      (port-forwardings `((8080 . ,forwarded-port)))))

  (define test
    (with-extensions (list guile-gcrypt)
      (with-imported-modules '((gnu build marionette))
        #~(begin
            (use-modules (srfi srfi-64)
		         (srfi srfi-11)
                         (gnu build marionette)
		         (web client)
		         (web uri)
                         (web response)
		         (ice-9 match)
                         (ice-9 iconv)
                         (gcrypt base64))

            (define marionette
              (make-marionette (list #$vm)))

            (test-runner-current (system-test-runner #$output))
            (test-begin #$name)

	    (test-assert "Check Miniflux service is running"
	      (begin
	        (#$retry-on-error
	         (lambda ()
		   (marionette-eval
		    '(begin
		       (use-modules (gnu services herd))
		       (match (start-service '#$(string->symbol "miniflux"))
		         (#f #f)
		         (('service response-parts ...)
			  (match (assq-ref response-parts 'running)
			    (#f #f)
			    ((running) #t)))))
		    marionette))
	         #:delay 1
	         #:times 10)))

            (test-assert "Miniflux TCP port ready, IPv4"
              (wait-for-tcp-port #$forwarded-port marionette))

	    (test-assert "Miniflux login page is opened"
              (begin
                (wait-for-tcp-port #$forwarded-port marionette)
                (#$retry-on-error
                 (lambda ()
                   (let-values (((_ text)
                                 (http-get
				  #$(format #f "http://localhost:~A/" forwarded-port)
				  #:decode-body? #t)))
                     (string-contains text "<title>Sign In - Miniflux</title>")))
                 #:times 10
                 #:delay 2)))

            (define authorization-header
              (let ((encoded (base64-encode (string->bytevector "test:testpassword" "utf-8"))))
                `(authorization . (basic . ,encoded))))

            (test-equal "Miniflux initial admin API call is successful"
              200
              (begin
                (wait-for-tcp-port #$forwarded-port marionette)
                (#$retry-on-error
                 (lambda ()
                   (let-values (((response _)
                                 (http-get #$(format #f "http://localhost:~A/v1/me" forwarded-port)
                                           #:headers (list authorization-header)
                                           #:decode-body? #t)))

                     (response-code response)))
                 #:times 10
                 #:delay 2)))

            (test-end)))))
  (gexp->derivation (string-append name "-test") test))

(define* (run-miniflux-socket-test name test-os)
  (define os
    (marionette-operating-system
     test-os
     #:imported-modules '((gnu services herd)
                          (guix combinators))))

  (define vm
    (virtual-machine
      (operating-system os)
      (memory-size 512)))

  (define test
    (with-imported-modules '((gnu build marionette))
      #~(begin
          (use-modules (srfi srfi-64)
                       (gnu build marionette))

          (define marionette
            (make-marionette (list #$vm)))

          (test-runner-current (system-test-runner #$output))
          (test-begin #$name)

          (test-assert "Check socket file is created"
            (wait-for-unix-socket "/var/run/miniflux/miniflux.sock" marionette))

          (test-end))))
  (gexp->derivation (string-append name "-test") test))

(define %test-miniflux-admin-string
  (system-test
   (name "miniflux-admin-string")
   (description "Run Miniflux with initial admin credentials as string.")
   (value (run-miniflux-test name %miniflux-with-admin-as-string))))

(define %test-miniflux-admin-file
  (system-test
   (name "miniflux-admin-file")
   (description "Run Miniflux with initial admin credentials as file.")
   (value (run-miniflux-test name %miniflux-with-admin-as-file))))

(define %test-miniflux-socket
  (system-test
   (name "miniflux-socket")
   (description "Run Miniflux on unix socket.")
   (value (run-miniflux-socket-test name %miniflux-with-socket))))