diff --git a/.gitignore b/.gitignore index a74398e..112c18a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .direnv tmp/ +nixos.qcow2 diff --git a/Makefile b/Makefile index a7ac66b..cfba4f0 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help serve test-data +.PHONY: help serve test-data demo help: ## display this help @awk 'BEGIN{FS = ":.*##"; printf "\033[1m\nUsage\n \033[1;92m make\033[0;36m \033[0m\n"} /^[a-zA-Z0-9_-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } ' $(MAKEFILE_LIST) @@ -7,4 +7,7 @@ serve: ## Run "intake serve" with live reload @air -build.cmd "go build -o tmp/intake" -build.bin tmp/intake -build.args_bin serve,--data-dir,tmp -build.include_ext "go,html,css" test-data: ## Recreate test data in tmp/ - @test/test_items.sh \ No newline at end of file + @test/test_items.sh + +demo: ## Run the demo vm + @nix run ".#nixosConfigurations.demo.config.system.build.nixos-shell" diff --git a/README.md b/README.md index a3f136a..8460b88 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ Intake is an arbitrary feed aggregator that generalizes the concept of a feed. Rather than being restricted to parsing items out of an RSS feed, Intake provides a middle layer of executing arbitrary commands that conform to a JSON-based specification. An Intake source can parse an RSS feed, but it can also scrape a website without a feed, provide additional logic to filter or annotate feed items, or integrate with an API. +A demo running in a NixOS VM is available via `make demo` or using `nix run` on the `nixosConfigurations.demo.config.system.build.nixos-shell` flake attribute. + ## Overview In Intake, a _source_ represents a single content feed of discrete _items_, such as a blog and its posts or a website and its pages. @@ -104,8 +106,8 @@ Instead, the web interface can be locked behind a password set via `intake passw Parity features * [ ] source batching -* [ ] NixOS module -* [ ] NixOS vm demo +* [x] NixOS module +* [x] NixOS vm demo Future features diff --git a/demo/alice.sh b/demo/alice.sh new file mode 100644 index 0000000..23ba51f --- /dev/null +++ b/demo/alice.sh @@ -0,0 +1,25 @@ +#!/bin/sh + +if [ -f /home/alice/.intake-setup-done ]; then + echo "already done" + exit 0 +fi + +# intake service gets a crontab wrapper, cheat here +export PATH="/run/wrappers/bin:$PATH" + +mkdir -p $INTAKE_DATA_DIR + +intake source add -s echo +intake action add -s echo -a fetch -- jq -cn '{id: env.ID, title: env.MESSAGE}' +intake source env -s echo --set "ID=hello" +intake source env -s echo --set "MESSAGE=Hello, world!" +intake channel add -s echo -c home + +intake source add -s currenttime +intake action add -s currenttime -a fetch -- sh -c "date +%Y-%m-%d-%H-%M | jq -cR '{id: .}'" +intake source env -s currenttime --set "INTAKE_CRON=* * * * *" +intake channel add -s currenttime -c home + +touch /home/alice/.intake-setup-done +echo "done" diff --git a/demo/bob.sh b/demo/bob.sh new file mode 100644 index 0000000..dab1c98 --- /dev/null +++ b/demo/bob.sh @@ -0,0 +1,20 @@ +#!/bin/sh + +if [ -f /home/bob/.intake-setup-done ]; then + echo "already done" + exit 0 +fi + +# intake service gets a crontab wrapper, cheat here +export PATH="/run/wrappers/bin:$PATH" + +mkdir -p $INTAKE_DATA_DIR + +intake source add -s echo +intake action add -s echo -a fetch -- jq -cn '{id: env.ID, title: env.MESSAGE}' +intake source env -s echo --set "ID=goodbye" +intake source env -s echo --set "MESSAGE=Goodbye, world!" +intake channel add -s echo -c home + +touch /home/bob/.intake-setup-done +echo "done" diff --git a/demo/default.nix b/demo/default.nix new file mode 100644 index 0000000..a8c2ea5 --- /dev/null +++ b/demo/default.nix @@ -0,0 +1,106 @@ +{ pkgs, lib, ... }: + +{ + system.stateVersion = "25.05"; + + environment.systemPackages = with pkgs; [ + jq + ]; + + # Set up two users to demonstrate the user separation + users.users.alice = { + isNormalUser = true; + password = "a"; + uid = 1000; + }; + + users.users.bob = { + isNormalUser = true; + password = "b"; + uid = 1001; + }; + + # Set up intake for both users + services.intake.extraPackages = with pkgs; [ + jq + ]; + services.intake.users = { + alice = { + enable = true; + listen.addr = "0.0.0.0"; + listen.port = 6001; + }; + bob = { + enable = true; + listen.addr = "0.0.0.0"; + listen.port = 6002; + }; + }; + + # Forward both ports + virtualisation.forwardPorts = [ + { + from = "host"; + host.port = 6001; + guest.port = 6001; + } + { + from = "host"; + host.port = 6002; + guest.port = 6002; + } + ]; + + # Disable nixos-shell autologin + services.getty.autologinUser = lib.mkForce null; + + # Disable default mounts + nixos-shell.mounts = { + mountHome = false; + mountNixProfile = false; + cache = "none"; + }; + + # Define a setup service to create some demo content + systemd.services = + let + setupFor = userName: script: { + description = "Intake demo setup for ${userName}"; + serviceConfig = { + User = userName; + Type = "oneshot"; + RemainAfterExit = true; + }; + path = [ pkgs.intake ]; + environment.INTAKE_DATA_DIR = "/home/${userName}/.local/share/intake"; + wantedBy = [ "intake-${userName}.service" ]; + before = [ "intake-${userName}.service" ]; + after = [ "network.target" ]; + script = builtins.readFile ./${userName}.sh; + }; + in + { + intake-alice-setup = setupFor "alice"; + intake-bob-setup = setupFor "bob"; + }; + + # Include some demo instructions + environment.etc.issue.text = '' + ### + # Welcome to the intake demo! Log in as `alice` with password `a` to begin. + # + # Exit the VM with ctrl+a x, or switch to the qemu console with ctrl+a c and `quit`. + ### + + ''; + + users.motd = '' + + ### + # The web interfaces are exposed at http://localhost:6001 and http://localhost:6002 + # + # Within this demo VM, you can run `intake` CLI commands. + ### + + ''; +} diff --git a/flake.lock b/flake.lock index 3e4af70..0dc6b94 100644 --- a/flake.lock +++ b/flake.lock @@ -1,5 +1,25 @@ { "nodes": { + "nixos-shell": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1732727306, + "narHash": "sha256-4R+OVEmJ8yR7/gsxMQtC39b9f61SvELYQwKeXGAyFfo=", + "owner": "Mic92", + "repo": "nixos-shell", + "rev": "c61dce7cf5dc263d237ba8a7fc175b09642f96eb", + "type": "github" + }, + "original": { + "owner": "Mic92", + "repo": "nixos-shell", + "type": "github" + } + }, "nixpkgs": { "locked": { "lastModified": 1736798957, @@ -18,6 +38,7 @@ }, "root": { "inputs": { + "nixos-shell": "nixos-shell", "nixpkgs": "nixpkgs" } } diff --git a/flake.nix b/flake.nix index 798d466..691890b 100644 --- a/flake.nix +++ b/flake.nix @@ -3,14 +3,18 @@ inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + nixos-shell.url = "github:Mic92/nixos-shell"; + nixos-shell.inputs.nixpkgs.follows = "nixpkgs"; }; outputs = { self, nixpkgs, + nixos-shell, }: let + inherit (nixpkgs.lib) nixosSystem; system = "x86_64-linux"; pkgs = nixpkgs.legacyPackages.${system}; in @@ -39,11 +43,23 @@ pkgs.gotools pkgs.cobra-cli pkgs.air + pkgs.nixos-shell ]; }; overlays.default = final: prev: { intake = final.callPackage ./package.nix { }; }; + + nixosModules.default = import ./module.nix self; + + nixosConfigurations."demo" = nixosSystem { + inherit system; + modules = [ + nixos-shell.nixosModules.nixos-shell + self.nixosModules.default + ./demo + ]; + }; }; } diff --git a/module.nix b/module.nix new file mode 100644 index 0000000..41105be --- /dev/null +++ b/module.nix @@ -0,0 +1,130 @@ +flake: +{ + config, + lib, + pkgs, + ... +}: + +let + inherit (lib) + filterAttrs + foldl + imap1 + mapAttrsToList + mkEnableOption + mkIf + mkMerge + mkOption + mkPackageOption + types + ; +in +{ + options = { + services.intake = { + package = mkPackageOption pkgs "intake" { }; + + extraPackages = mkOption { + type = types.listOf types.package; + default = [ ]; + description = "Extra packages available to all enabled users and their intake services."; + }; + + users = mkOption { + description = "User intake service definitions."; + default = { }; + type = types.attrsOf ( + types.submodule { + options = { + enable = mkEnableOption "intake, a universal and extensible feed aggregator."; + + dataDir = mkOption { + type = types.str; + default = "/home/$USER/.local/share/intake"; + description = "The data directory for this user's intake service."; + }; + + listen.addr = mkOption { + type = types.str; + default = "127.0.0.1"; + description = "The listen address for this user's intake service."; + }; + + listen.port = mkOption { + type = types.port; + default = 80; + description = "The listen port for this user's intake service."; + }; + + extraPackages = mkOption { + type = types.listOf types.package; + default = [ ]; + description = "Extra packages available to this user and their intake service."; + }; + }; + } + ); + }; + }; + }; + + config = + let + intakeCfg = config.services.intake; + enabledUsers = filterAttrs (userName: userCfg: userCfg.enable) intakeCfg.users; + enabledUserNames = mapAttrsToList (userName: userCfg: userName) enabledUsers; + userPackages = userName: [ intakeCfg.package ] ++ intakeCfg.extraPackages ++ intakeCfg.users.${userName}.extraPackages; + crontabWrapper = pkgs.writeShellScriptBin "crontab" '' + exec ${config.security.wrapperDir}/crontab "$@" + ''; + in + { + # Apply the overlay so intake is included in pkgs. + nixpkgs.overlays = [ flake.overlays.default ]; + + # Give every intake user the shared packages and their user-specific packages. + users.users = + let + addPackagesToUser = userName: { + ${userName}.packages = userPackages userName; + }; + in + mkMerge (map addPackagesToUser enabledUserNames); + + # Enable cron + services.cron.enable = true; + + # Define a user service for each configured user + systemd.services = + let + runScript = + userName: + pkgs.writeShellScript "intake-run.sh" '' + mkdir -p $INTAKE_DATA_DIR + # Add the setuid wrapper directory so `crontab` is accessible + export PATH="${config.security.wrapperDir}:$PATH" + ${intakeCfg.package}/bin/intake serve --addr ${enabledUsers.${userName}.listen.addr} --port ${toString enabledUsers.${userName}.listen.port} + ''; + # systemd service definition for a single user, given `services.intake.users.userName` = `userCfg` + userServiceConfig = userName: userCfg: { + "intake-${userName}" = { + description = "Intake service for user ${userName}"; + script = "${runScript userName}"; + path = [ crontabWrapper ] ++ intakeCfg.extraPackages ++ userCfg.extraPackages; + environment = { + INTAKE_DATA_DIR = "/home/${userName}/.local/share/intake"; + }; + serviceConfig = { + User = userName; + Type = "simple"; + }; + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" ]; + enable = userCfg.enable; + }; + }; + in + mkMerge (mapAttrsToList userServiceConfig enabledUsers); + }; +}