Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

init Modular Portable Service Layer, proof of concept #267111

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions example/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@

# Module Based Service Abstraction Layer usage example

This directory demonstrates
- `configuration.nix`: How to use an abstract service module on NixOS
- `generic-python-http-server.nix`: A simple abstract service
- `nixos-test.nix`: Make it runnable
- `nix build example/nixos-test.nix`

Mess around with the repl

nix repl -f example/nixos-test.nix
nix-repl> nodes.machine.services.abstract.<TAB>
args
daemon
...

Enjoy!
23 changes: 23 additions & 0 deletions example/configuration.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# A NixOS configuration
{ pkgs, ... }: {

environment.systemPackages = [ pkgs.hello ]; # Truly NixOS

services.abstract."serve-nix-manual" = {
imports = [ ./generic-python-http-server.nix ];
# You could change the port, but the default is sweet
# service.port = 8080;
service.directory = pkgs.nix.doc;
};

# A service that's distributed with NixOS may look like this.
# Doesn't exists yet. Add to specialArgs in a submoduleWith.
#
# services.abstract."serve-nixpkgs-manual" = { serviceModules, ... }: {
# imports = [ serviceModules.nginx ];
# service.virtualHosts."/" = {
# root = pkgs.nix.doc;
# };
# };

}
36 changes: 36 additions & 0 deletions example/generic-python-http-server.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{ lib, config, options, pkgs, ... }:
let
inherit (lib) mkOption types;
cfg = config.service;
in
{
# I think `service` could be the place where the service itself is configured.
# Alternatively, this could have been `pythonHttpServer`, but then over time
# we'd endup with a service module namespace where we don't really know which
# names will conflict, whenever we introduce a new generic top level option in
# the future.
options.service = {
port = lib.mkOption {
type = types.int;
default = 8080;
description = ''
Port number where the web server should listen.
'';
};
directory = lib.mkOption {
type = types.path;
description = ''
A path to serve over HTTP.
'';
};
};

config = {
# Obligatory relaying of: Warning http.server is not recommended for production. It only implements basic security checks.
process = "${pkgs.python3}/bin/python3";
args = [
"-m" "http.server" (toString cfg.port)
"--directory" cfg.directory
];
};
}
22 changes: 22 additions & 0 deletions example/nixos-test.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
let
pkgs = import ../. { };
inherit (pkgs.testers) runNixOSTest;
in

runNixOSTest {
name = "abstract-service";

nodes.machine = { lib, options, ... }: {
imports = [
./configuration.nix
];

# Ignore this. It makes nodes.machine.options appear in
# nix repl -f example/nixos-test.nix
options.options = lib.mkOption { default = options; };
};

testScript = ''
machine.wait_for_open_port(8080);
'';
}
2 changes: 2 additions & 0 deletions nixos/modules/module-list.nix
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,8 @@
./security/systemd-confinement.nix
./security/tpm2.nix
./security/wrappers/default.nix
./services/abstract/generic.nix
./services/abstract/systemd.nix
./services/admin/meshcentral.nix
./services/admin/oxidized.nix
./services/admin/pgadmin.nix
Expand Down
23 changes: 23 additions & 0 deletions nixos/modules/services/abstract/generic.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# This module is valid for all implementations of the service abstraction layer,
# and might even be vendored from another repo or moved outside of nixpkgs/nixos/
# into nixpkgs/something.

{ lib, config, options, pkgs, ... }:
let
inherit (lib) mkOption types;
in
{
options = {
services.abstract = mkOption {
type = types.lazyAttrsOf (types.submoduleWith {
modules = [
./instance/generic.nix
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This hardcodes the generic options, but that's not actually necessary and creates some confusion about what the actually used interface really is, in a way that isn't forward compatible.

It should be up to the actual service to decide in which format it wants to be loaded, and process managers can decide which formats they support.

This could be negotiated through specialArgs and may look like:

# postgresql.nix
{ serviceInterface, ... }: {
  imports = [
    serviceInterface.generic_1_0
    serviceInterface.systemd_nixos_23_11 or { }
  ];
  options = {
    # ...
  };
}

This says that a postgresql service expects the process manager to support the generic_1_0 interface and load that.
The systemd_1_0 interface is optional and is only loaded if the process manager supports it.

imports can of course only depend on specialArgs arguments, but that is sufficient for this purpose.

Note that services can already introspect e.g. options?systemd to figure out in which kind of process manager they are loaded. So the addition of such a mechanism is primarily important for forward compatibility as we'll inevitably evolve the taxonomy of services.

This also allows the creation of helper modules that for instance provide polyfills, emulate one process manager's features on top of another, etc. These could be distributed independently, or as part of the suite of modules and interfaces that are built in to the process manager integrations.

Even just within the NixOS context, such versions let use eat the cake and have it too:

  • NixOS/systemd can make breaking changes in new versions
  • third party service modules (e.g. flakes, etc) have stable targets that they can support

Arguably there's a non-zero cost to having a compatibility layer or keeping old code around, but at least it becomes a solvable problem, and the old solution doesn't come with the expectation of parity with the new solution.

{ _module.args.pkgs = pkgs; }
]; });
default = { };
description = ''
All services that are configured on the system.
'';
};
};
}
67 changes: 67 additions & 0 deletions nixos/modules/services/abstract/instance/generic.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# This corresponds to the arguments of createManagedProcess in svanderburg's proposal
{ lib, config, options, ... }:
let
inherit (lib) literalExpression mkOption types;
pathOrStr = types.coercedTo types.path (x: "${x}") types.str;
in
{
options = {
enable = mkOption {
type = types.bool;
default = true;
description = ''
Whether to enable the service. A service is enabled by default, because you took the effort to define its attribute.
'';
};
process = mkOption {
type = pathOrStr;
description = ''
When this property is specified, it translates both to: {option}`foreground.process` and {option}`daemon.process`.
'';
};
foreground = {
process = lib.mkOption {
type = pathOrStr;
description = ''
Path to an executable that launches a process in foreground mode.
'';
default = config.process;
defaultText = literalExpression "config.process";
};
args = lib.mkOption {
type = types.listOf pathOrStr;
description = ''
Arguments to pass to the {option}`foreground.process`.
'';
default = config.args;
defaultText = literalExpression "config.args";
};
};
daemon = {
process = lib.mkOption {
type = pathOrStr;
description = ''
Path to an executable that launches a process in daemon mode.
'';
default = config.process;
defaultText = literalExpression "config.process";
};
args = lib.mkOption {
type = types.listOf pathOrStr;
description = ''
Arguments to pass to the {option}`daemon.process`.
'';
default = config.args;
defaultText = literalExpression "config.args";
};
};
Comment on lines +40 to +57
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think allowing programs to daemonize themselves will be a trap for this. Foremost new programs don't implement this anymore and a generally discouraged to do so. Old programs implement this in completely varying degrees ranging from full signal handling, creating pid files and being their own process manager to basically doing program & and running or anything in between. Also sometimes config options are involved.

IMO we should only take foreground programs and let the service manager handle all of the "daemon" parts. If the service manager doesn't support this, then IMO it is unsuitable.

args = lib.mkOption {
type = types.listOf pathOrStr;
description = ''
Arguments to pass to the `foreground.process` or `daemon.process`. In other words, these arguments are passed regardless of process manager choice.
'';
default = [];
};
# TODO more from svanderburg's proposal. Look for the list of properties after defining `createManagedProcess`
};
}
42 changes: 42 additions & 0 deletions nixos/modules/services/abstract/instance/systemd.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
{ lib, config, options, ... }:
let
inherit (lib) mkOption types;
in
{
options = {
systemd.services = lib.mkOption {
description = ''
This module configures systemd services, with the notable difference that their unit names will be prefixed with the abstract service name.

This option's value is not suitable for reading, but you can define a module here that interacts with just the unit configuration in the host system configuration.
'';
# ^ if we don't want to support this, we can add the systemd options that
# we care about here, and combine them into a host system definition
# outside the control of this module - in services/abstract/systemd.nix.

type = types.lazyAttrsOf (types.deferredModuleWith { staticModules = [
# TODO Add the modules from systemd? They'll be deduplicated, but
# will generate docs in this part of the hierarchy. Is such duplication
# desirable? Perhaps!
]; });
};
};
config = {
# Empty string "" is ok, because it will be prefixed by the abstraction layer
# anyway. Actual names here are for when multiple systemd services are needed.
# I might have overengineered this for a demonstration :thinking_face:.
#
# Note that this is the systemd.services option above, not the system one.
# This pattern allows a module to do all sorts of systemd stuff if it needs to.
systemd.services."" = {
description = "TBD add an option for that";
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "simple";
Restart = "always";
RestartSec = "5";
ExecStart = [ (lib.escapeShellArgs ([ config.foreground.process ] ++ config.foreground.args)) ];
};
};
};
}
41 changes: 41 additions & 0 deletions nixos/modules/services/abstract/systemd.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# This merges systemd support into the generic instance module.
{ lib, config, options, ... }:
let
inherit (lib) concatMapAttrs mkOption types;

dash = before: after:
# maybe add nice clever escaping?
if after == ""
then before
else "${before}-${after}";
in
{
# First half of the magic: mix systemd logic into the otherwise abstract services
options = {
services.abstract = mkOption {
type = types.lazyAttrsOf (types.submoduleWith { modules = [ ./instance/systemd.nix ]; });
};
};

# Second half of the magic: siphon units that were defined in isolation to the system
config = {
systemd.services =
concatMapAttrs
(abstractServiceName: abstractServiceConfig:
if abstractServiceConfig.enable
then
concatMapAttrs
(subServiceName: unitModule: {
"${dash abstractServiceName subServiceName}" = { ... }: {
imports = [ unitModule ];
};
})
abstractServiceConfig.systemd.services
else {})
config.services.abstract;

# systemd.sockets =
# ... same ...

};
}
Loading