;;; systemctl.el --- Emacs interface to Systemd     -*- lexical-binding: t; -*-

;; Copyright (C) 2016  Free Software Foundation, Inc.

;; Author: Mario Lang <mlang@delysid.org>
;; Keywords: 

;; This program is free software; you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation, either version 3 of the License, or
;; (at your option) any later version.

;; This program is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
;; GNU General Public License for more details.

;; You should have received a copy of the GNU General Public License
;; along with this program.  If not, see <http://www.gnu.org/licenses/>.

;;; Commentary:

;; This library provides a front end to Systemd.
;;
;; Use `M-x systemctl-list-units RET' to see a list of all known
;; Systemd units and their status on localhost.  With a prefix
;; argument (`C-u M-x systemctl-list-units RET') you will be prompted
;; for a remote host to connect to.
;;
;; In systemctl-list-units-mode, `RET' will visit all relevant
;; configuration fragments for the unit at point (the equivalent of
;; "systemctl cat some.service").  With a `C-u' prefix argument, it
;; will prompt for a new override.conf file to create (somewhat
;; equivalent to "systemctl edit some.service").  Contrary to the
;; command-line "systemctl" tool, systemctl.el allows viewing and
;; editing of remote unit files thanks to TRAMP.
;;
;; Key bindings `s t a r t' and `s t o p' can be used to start and stop
;; services.  Similarly, `e n a b l e' and `d i s a b l e' can be used to
;; permanently enable and disable unit files.

;;; Granting access to non-root users:

;; Some operations are obviously not allowed when executed from within a
;; non-root Emacs session.  If you want to explicitly grant access to certain
;; users, you can create a polkit localauthority configuration file.
;; Below is an example.  You might want to change the group name, or use
;; "unix-user" instead.
;;
;; /etc/polkit-1/localauthority/50-local.d/10-systemd.pkla:
;;
;; [Normal Staff Permissions]
;; Identity=unix-group:sudo
;; Action=org.freedesktop.systemd1.*
;; ResultAny=no
;; ResultInactive=no
;; ResultActive=yes

;;; Todo:

;; * Have someone with window/frame-fu see if there is a better way to
;;   visit N files in a frame, each in a separate window.  The current approach
;;   feels a bit crude, see `systemctl-edit-unit-files'.
;; * Optionally automatically reload the Systemd daemon when a unit buffer is
;;   saved.
;; * Detect if we are not root, and use the sudo method to edit
;;   system files on localhost.
;; * Add support for local and remote systemd user sessions.
;; * Figure out what's necessary to support local and remote containers.
;; * Menu entries for `systemctl-list-units-mode'.

;;; Code:

(require 'systemd)
(require 'tabulated-list)
(require 'tramp)

(defgroup systemctl nil
  "Interface to Systemd.")

(defcustom systemctl-default-override-file-name "override.conf"
  "Default file name for new override.conf files."
  :group 'systemctl
  :type 'string)

(defcustom systemctl-list-units-format
  (vector (list "Unit" 22 t)
          (list "Loaded" 9 t)
          (list "Active" 8 t)
          (list "State" 8 t)
          (list "Description" 50 nil))
  "Column format specification for the `systemctl-list-units' command."
  :group 'systemctl
  :type '(vector (list :tag "Unit"
                       (string :tag "Title")
                       (number :tag "Width")
                       (boolean :tag "Sortable"))
                 (list :tag "Loaded"
                       (string :tag "Title")
                       (number :tag "Width")
                       (boolean :tag "Sortable"))
                 (list :tag "Active"
                       (string :tag "Title")
                       (number :tag "Width")
                       (boolean :tag "Sortable"))
                 (list :tag "State"
                       (string :tag "Title")
                       (number :tag "Width")
                       (boolean :tag "Sortable"))
                 (list :tag "Description"
                       (string :tag "Title")
                       (number :tag "Width")
                       (boolean :tag "Sortable"))))

(defcustom systemctl-tramp-method "scpx"
  "The TRAMP method to use when remotely accessing Systemd Unit files."
  :group 'systemctl
  :type (cons 'choice
	      (mapcar (lambda (method)
			(list 'const (car method)))
		      tramp-methods)))

(defvar-local systemctl-bus :system
  "Default D-Bus bus to use when accessing Systemd.
You should use the function `systemctl-bus' to retrieve the value of this
variable to make sure the bus is properly initialized in case it is pointing
to a remote machine.")

(defvar systemctl-list-units-mode-map
  (let ((map (make-sparse-keymap)))
    (define-key map "\C-m" #'systemctl-edit-unit-files)
    (define-key map "f" #'systemctl-find-fragment)
    (define-key map "start" #'systemctl-start)
    (define-key map "stop"  #'systemctl-stop)
    (define-key map "enable" #'systemctl-enable)
    (define-key map "disable" #'systemctl-disable)
    map)
  "Keymap for `systemctl-list-units-mode'.")

(defun systemctl-bus ()
  (when (stringp systemctl-bus)
    (dbus-init-bus systemctl-bus))
  systemctl-bus)
      
(defun systemctl-list-units-entries ()
  "Retrieve a list of units known to Systemd.
See `systemctl-list-units-format' and `tabulated-list-entries'."
  (mapcar (lambda (desc)
            (list (nth 6 desc)
                  (vector (nth 0 desc)
                          (nth 2 desc)
                          (nth 3 desc)
                          (nth 4 desc)
                          (nth 1 desc))))
          (systemd-ListUnits (systemctl-bus))))

(defun systemctl-unescape-unit-name (string)
  (while (string-match "\\\\x\\([0-9a-f]\\{2\\}\\)" string)
    (setq string
          (replace-match (string (string-to-number (match-string 1 string) 16))
                         t t string)))
  string)

(defun systemctl-list-units-print-entry (id cols)
  "Insert a Systemd Units List entry at point.
See `tabulated-list-printer'."
  (let ((beg (point))
        (x (max tabulated-list-padding 0))
        (inhibit-read-only t))
    (when (> x 0) (insert (make-string x ?\s)))
    (dotimes (n (length tabulated-list-format))
      (let ((desc (aref cols n)))
        (when (= n 0)
          (setq desc (systemctl-unescape-unit-name desc)))
        (setq x (tabulated-list-print-col n desc x))))
    (insert ?\n)
    (put-text-property beg (point) 'tabulated-list-id id)
    (put-text-property beg (point) 'tabulated-list-entry cols)))

(define-derived-mode systemctl-list-units-mode tabulated-list-mode
  "Systemd-Units"
  "Major mode for displaying a list of Systemd Units."
  (setq tabulated-list-entries #'systemctl-list-units-entries
        tabulated-list-format    systemctl-list-units-format
        tabulated-list-printer #'systemctl-list-units-print-entry)
  (tabulated-list-init-header))
  
;;;###autoload
(defun systemctl-list-units (&optional host)
  "Display a list of all Systemd Units."
  (interactive
   (list (when (equal current-prefix-arg '(4))
           (read-string "Remote host: "))))
  
  (with-current-buffer (let ((buffer-name (if host
					      (format "*Systemd Units (%s)*"
						      host)
					    "*Systemd Units*")))
			 (get-buffer-create buffer-name))
    (systemctl-list-units-mode)
    (when host
      (setq systemctl-bus (systemd-remote-bus host)
	    default-directory (systemctl-file-name "/etc/systemd/")))
    (tabulated-list-print)
    (pop-to-buffer (current-buffer))))

(defun systemctl-list-units-get-unit ()
  (when (eq major-mode 'systemctl-list-units-mode)
    (let ((entry (tabulated-list-get-entry)))
      (when entry
	(aref entry 0)))))

(defun systemctl-start (unit)
  "Start Systemd UNIT."
  (interactive (list (or (systemctl-list-units-get-unit)
                         (read-string "Unit: "))))
  (systemd-StartUnit (systemctl-bus) unit "replace")
  (when (eq major-mode 'systemctl-list-units-mode)
    (tabulated-list-revert)))

(defun systemctl-stop (unit)
  (interactive (list (or (systemctl-list-units-get-unit)
                         (read-string "Unit: "))))
  (systemd-StopUnit (systemctl-bus) unit "replace")
  (when (eq major-mode 'systemctl-list-units-mode)
    (tabulated-list-revert)))

(defun systemctl-enable (unit)
  "Enable Systemd UNIT."
  (interactive (list (or (systemctl-list-units-get-unit)
                         (read-string "Unit: "))))
  (pcase (systemd-EnableUnitFiles (systemctl-bus) (list unit) nil nil)
    (`(,carries-install-info ,changes)
     (if changes
	 (pcase-dolist (`(,type ,from ,to) changes)
	   (message "%s %s -> %s" type from to))
       (message "No changes")))))

(defun systemctl-disable (unit)
  "Disable Systemd UNIT."
  (interactive (list (or (systemctl-list-units-get-unit)
                         (read-string "Unit: "))))
  (let ((changes (systemd-DisableUnitFiles (systemctl-bus) (list unit) nil)))
    (if changes
	(pcase-dolist (`(,type ,from ,to) changes)
	  (message "%s %s -> %s" type from to))
      (message "No changes"))))

(defun systemctl-reload ()
  "Reload all unit files."
  (interactive)
  (systemd-Reload (systemctl-bus)))

(defun systemctl-file-name (file-name)
  (if (and (stringp systemctl-bus)
	   (string-match "unixexec:path=ssh,.*argv2=\\([^,]*\\),"
			 systemctl-bus))
      (let ((host (systemd-unescape-dbus-address
		   (match-string 1 systemctl-bus))))
	(concat "/" systemctl-tramp-method ":" host ":" file-name))
    file-name))

(defun systemctl-find-fragment (unit)
  (interactive
   (list (or (and (eq major-mode 'systemctl-list-units-mode)
		  (tabulated-list-get-id))
	     (systemd-GetUnit (systemctl-bus) (read-string "Unit: ")))))
  (let ((fragment-path (systemd-unit-FragmentPath (systemctl-bus) unit)))
    (when fragment-path
      (find-file (systemctl-file-name fragment-path)))))

(defun systemctl-edit-unit-files (unit &optional override-file)
  "Visit all configuration files related to UNIT simultaneously.
If optional OVERRIDE-FILE is specified, or if a prefix argument has been
given interactively, open a (new) override file."
  (interactive
   (let* ((unit (if (tabulated-list-get-entry)
		    (systemctl-unescape-unit-name (aref (tabulated-list-get-entry) 0))
		  (read-string "Unit: ")))
	  (unit-path (or (tabulated-list-get-id)
			 (systemd-GetUnit (systemctl-bus) unit)))
	  (override-file
	   (when (equal current-prefix-arg '(4))
	     (read-file-name "Override file: "
			     (systemctl-file-name
			      (concat "/etc/systemd/system/" unit ".d/"))
			     nil nil
			     systemctl-default-override-file-name))))
     (list unit-path override-file)))
  (let ((files (mapcar #'systemctl-file-name
		       (systemd-unit-DropInPaths (systemctl-bus) unit))))
    (when override-file
      (push override-file files))
    (let ((path (systemd-unit-FragmentPath (systemctl-bus) unit)))
      (when (not (string= path ""))
	(setq files (nconc files
			   (list (systemctl-file-name path))))))
    (let ((path (systemd-unit-SourcePath (systemctl-bus) unit)))
      (when (not (string= path ""))
	(setq files (nconc files
			   (list (systemctl-file-name path))))))
    (if files
	(let ((buffers (mapcar #'find-file-noselect files)))
	  (pop-to-buffer (pop buffers))
	  (when buffers
	    (delete-other-windows)
	    (dolist (buffer buffers)
	      (let ((window (split-window (car (last (window-list))))))
		(shrink-window-if-larger-than-buffer)
		(set-window-buffer window buffer)))
	    (dolist (window (window-list))
	      (shrink-window-if-larger-than-buffer window))))
      (when (called-interactively-p 'interactive)
	(message "No configuration files associated with `%s'." unit)))))

(provide 'systemctl)
;;; systemctl.el ends here
