diff --git a/.forgejo/workflows/ci.yaml b/.forgejo/workflows/ci.yaml index 203eca03..f7f0a438 100644 --- a/.forgejo/workflows/ci.yaml +++ b/.forgejo/workflows/ci.yaml @@ -44,3 +44,9 @@ jobs: steps: - uses: actions/checkout@v4 - run: nix build .#checks.x86_64-linux.deployment-cli -L + + check-deployment-panel: + runs-on: native + steps: + - uses: actions/checkout@v4 + - run: nix build .#checks.x86_64-linux.deployment-panel -L diff --git a/deployment/README.md b/deployment/README.md index 784273dd..f8eeabdc 100644 --- a/deployment/README.md +++ b/deployment/README.md @@ -116,8 +116,8 @@ flowchart LR target_machines -->|get certs| acme ``` -### [WIP] Service deployment check from the panel +### Service deployment check from the FediPanel -This is a full deployment check running the panel on the deployer machine, deploying some services through the panel and checking that they are indeed on the target machines, then cleans them up and checks whether that works, too. +This is a full deployment check running the [FediPanel](../panel) on the deployer machine, deploying some services through it and checking that they are indeed on the target machines, then cleans them up and checks whether that works, too. -It builds upon the basic and CLI deployment checks. +It builds upon the basic and CLI deployment checks, the only difference being that `deployer` runs NixOps4 only indirectly via the panel, and the `client` node is the one that triggers the deployment via a browser, the way a human would. diff --git a/deployment/check/panel/flake-part.nix b/deployment/check/panel/flake-part.nix new file mode 100644 index 00000000..24a3d695 --- /dev/null +++ b/deployment/check/panel/flake-part.nix @@ -0,0 +1,91 @@ +{ + self, + inputs, + lib, + ... +}: + +let + inherit (builtins) + fromJSON + listToAttrs + ; + + targetMachines = [ + "garage" + "mastodon" + "peertube" + "pixelfed" + ]; + pathToRoot = /. + (builtins.unsafeDiscardStringContext self); + pathFromRoot = ./.; + enableAcme = true; + +in +{ + perSystem = + { pkgs, ... }: + { + checks.deployment-panel = pkgs.testers.runNixOSTest { + imports = [ + ../common/nixosTest.nix + ./nixosTest.nix + ]; + _module.args.inputs = inputs; + inherit + targetMachines + pathToRoot + pathFromRoot + enableAcme + ; + }; + }; + + nixops4Deployments = + let + makeTargetResource = nodeName: { + imports = [ ../common/targetResource.nix ]; + _module.args.inputs = inputs; + inherit + nodeName + pathToRoot + pathFromRoot + enableAcme + ; + }; + + ## The deployment function - what we are here to test! + ## + ## TODO: Modularise `deployment/default.nix` to get rid of the nested + ## function calls. + makeTestDeployment = + args: + (import ../..) + { + inherit lib; + inherit (inputs) nixops4 nixops4-nixos; + fediversity = import ../../../services/fediversity; + } + (listToAttrs ( + map (nodeName: { + name = "${nodeName}ConfigurationResource"; + value = makeTargetResource nodeName; + }) targetMachines + )) + args; + + in + { + check-deployment-panel = makeTestDeployment ( + fromJSON ( + let + env = builtins.getEnv "DEPLOYMENT"; + in + if env == "" then + throw "The DEPLOYMENT environment needs to be set. You do not want to use this deployment unless in the `deployment-panel` NixOS test." + else + env + ) + ); + }; +} diff --git a/deployment/check/panel/nixosTest.nix b/deployment/check/panel/nixosTest.nix new file mode 100644 index 00000000..39bd7930 --- /dev/null +++ b/deployment/check/panel/nixosTest.nix @@ -0,0 +1,362 @@ +{ + inputs, + lib, + hostPkgs, + config, + ... +}: + +let + inherit (lib) + getExe + ; + + ## Some places need a dummy file that will in fact never be used. We create + ## it here. + dummyFile = hostPkgs.writeText "dummy" "dummy"; + panelPort = 8000; + + panelUser = "test"; + panelEmail = "test@test.com"; + panelPassword = "ouiprdaaa43"; # panel's manager complains if too close to username or email + + fediUser = "test"; + fediEmail = "test@test.com"; + fediPassword = "testtest"; + fediName = "Testy McTestface"; + + toPythonBool = b: if b then "True" else "False"; + + interactWithPanel = + { + baseUri, + enableMastodon, + enablePeertube, + enablePixelfed, + }: + hostPkgs.writers.writePython3Bin "interact-with-panel" + { + libraries = with hostPkgs.python3Packages; [ selenium ]; + flakeIgnore = [ + "E302" # expected 2 blank lines, found 0 + "E303" # too many blank lines + "E305" # expected 2 blank lines after end of function or class + "E501" # line too long (> 79 characters) + "E731" # do not assign lambda expression, use a def + ]; + } + '' + from selenium import webdriver + from selenium.webdriver.common.by import By + from selenium.webdriver.firefox.options import Options + from selenium.webdriver.support.ui import WebDriverWait + + print("Create and configure driver...") + options = Options() + options.add_argument("--headless") + options.binary_location = "${getExe hostPkgs.firefox-unwrapped}" + service = webdriver.FirefoxService(executable_path="${getExe hostPkgs.geckodriver}") + driver = webdriver.Firefox(options=options, service=service) + driver.set_window_size(1280, 960) + driver.implicitly_wait(360) + driver.command_executor.set_timeout(3600) + + print("Open login page...") + driver.get("${baseUri}/login/") + print("Enter username...") + driver.find_element(By.XPATH, "//input[@name = 'username']").send_keys("${panelUser}") + print("Enter password...") + driver.find_element(By.XPATH, "//input[@name = 'password']").send_keys("${panelPassword}") + print("Click “Login” button...") + driver.find_element(By.XPATH, "//button[normalize-space() = 'Login']").click() + + print("Open configuration page...") + driver.get("${baseUri}/configuration/") + + # Helpers to actually set and not add or switch input values. + def input_set(elt, keys): + elt.clear() + elt.send_keys(keys) + def checkbox_set(elt, new_value): + if new_value != elt.is_selected(): + elt.click() + + print("Enable Fediversity...") + checkbox_set(driver.find_element(By.XPATH, "//input[@name = 'enable']"), True) + + print("Fill in initialUser info...") + input_set(driver.find_element(By.XPATH, "//input[@name = 'initialUser.username']"), "${fediUser}") + input_set(driver.find_element(By.XPATH, "//input[@name = 'initialUser.password']"), "${fediPassword}") + input_set(driver.find_element(By.XPATH, "//input[@name = 'initialUser.email']"), "${fediEmail}") + input_set(driver.find_element(By.XPATH, "//input[@name = 'initialUser.displayName']"), "${fediName}") + + print("Enable services...") + checkbox_set(driver.find_element(By.XPATH, "//input[@name = 'mastodon.enable']"), ${toPythonBool enableMastodon}) + checkbox_set(driver.find_element(By.XPATH, "//input[@name = 'peertube.enable']"), ${toPythonBool enablePeertube}) + checkbox_set(driver.find_element(By.XPATH, "//input[@name = 'pixelfed.enable']"), ${toPythonBool enablePixelfed}) + + print("Start deployment...") + driver.find_element(By.XPATH, "//button[@id = 'deploy-button']").click() + + print("Wait for deployment status to show up...") + get_deployment_result = lambda d: d.find_element(By.XPATH, "//div[@id = 'deployment-result']//p") + WebDriverWait(driver, timeout=3660, poll_frequency=10).until(get_deployment_result) + deployment_result = get_deployment_result(driver).get_attribute('innerHTML') + + print("Quit...") + driver.quit() + + match deployment_result: + case 'Deployment Succeeded': + print("Deployment has succeeded; exiting normally") + exit(0) + case 'Deployment Failed': + print("Deployment has failed; exiting with return code `1`") + exit(1) + case _: + print(f"Unexpected deployment result: {deployment_result}; exiting with return code `2`") + exit(2) + ''; + +in + +{ + name = "deployment-panel"; + + ## The panel's module sets `nixpkgs.overlays` which clashes with + ## `pkgsReadOnly`. We disable it here. + node.pkgsReadOnly = false; + + nodes.deployer = + { pkgs, ... }: + { + imports = [ + (import ../../../panel { }).module + ]; + + ## FIXME: This should be in the common stuff. + security.acme = { + acceptTerms = true; + defaults.email = "test@test.com"; + defaults.server = "https://acme.test/dir"; + }; + security.pki.certificateFiles = [ + (import "${inputs.nixpkgs}/nixos/tests/common/acme/server/snakeoil-certs.nix").ca.cert + ]; + networking.extraHosts = "${config.acmeNodeIP} acme.test"; + + services.panel = { + enable = true; + production = true; + domain = "deployer"; + secrets = { + SECRET_KEY = dummyFile; + }; + port = panelPort; + nixops4Package = inputs.nixops4.packages.${pkgs.system}.default; + + deployment = { + flake = "/run/fedipanel/flake"; + name = "check-deployment-panel"; + }; + }; + + environment.systemPackages = [ pkgs.expect ]; + + ## FIXME: The following dependencies are necessary but I do not + ## understand why they are not covered by the fake node. + system.extraDependencies = with pkgs; [ + peertube + peertube.inputDerivation + gixy # a configuration checker for nginx + gixy.inputDerivation + ]; + + system.extraDependenciesFromModule = { + imports = [ ../../../services/fediversity ]; + fediversity = { + domain = "fediversity.net"; # would write `dummy` but that would not type + garage.enable = true; + mastodon = { + enable = true; + s3AccessKeyFile = dummyFile; + s3SecretKeyFile = dummyFile; + }; + peertube = { + enable = true; + secretsFile = dummyFile; + s3AccessKeyFile = dummyFile; + s3SecretKeyFile = dummyFile; + }; + pixelfed = { + enable = true; + s3AccessKeyFile = dummyFile; + s3SecretKeyFile = dummyFile; + }; + temp.cores = 1; + temp.initialUser = { + username = "dummy"; + displayName = "dummy"; + email = "dummy"; + passwordFile = dummyFile; + }; + }; + }; + }; + + nodes.client = + { pkgs, ... }: + { + environment.systemPackages = with pkgs; [ + httpie + dnsutils # for `dig` + openssl + cacert + wget + python3 + python3Packages.selenium + firefox-unwrapped + geckodriver + ]; + + security.pki.certificateFiles = [ + config.nodes.acme.test-support.acme.caCert + ]; + networking.extraHosts = "${config.acmeNodeIP} acme.test"; + }; + + ## NOTE: The target machines may need more RAM than the default to handle + ## being deployed to, otherwise we get something like: + ## + ## pixelfed # [ 616.785499 ] sshd-session[1167]: Conection closed by 2001:db8:1::2 port 45004 + ## deployer # error: writing to file: No space left on device + ## pixelfed # [ 616.788538 ] sshd-session[1151]: pam_unix(sshd:session): session closed for user port + ## pixelfed # [ 616.793929 ] systemd-logind[719]: Session 4 logged out. Waiting for processes to exit. + ## deployer # Error: Could not create resource + ## + ## These values have been trimmed down to the gigabyte. + nodes.mastodon.virtualisation.memorySize = 4 * 1024; + nodes.pixelfed.virtualisation.memorySize = 4 * 1024; + nodes.peertube.virtualisation.memorySize = 5 * 1024; + + ## FIXME: The test of presence of the services are very simple: we only + ## check that there is a systemd service of the expected name on the + ## machine. This proves at least that NixOps4 did something, and we cannot + ## really do more for now because the services aren't actually working + ## properly, in particular because of DNS issues. We should fix the services + ## and check that they are working properly. + + extraTestScript = '' + ## TODO: We want a nicer way to control where the FediPanel consumes its + ## flake, which can default to the store but could also be somewhere else if + ## someone wanted to change the code of the flake. + ## + with subtest("Give the panel access to the flake"): + deployer.succeed("mkdir /run/fedipanel /run/fedipanel/flake >&2") + deployer.succeed("cp -R . /run/fedipanel/flake >&2") + deployer.succeed("chown -R panel:panel /run/fedipanel >&2") + + ## TODO: I want a programmatic way to provide an SSH key to the panel (and + ## therefore NixOps4). This should happen either in the Python code, but + ## maybe it is fair that that one picks up on the user's key? It could + ## also be in the Nix packaging. + ## + with subtest("Set up the panel's SSH keys"): + deployer.succeed("mkdir /home/panel/.ssh >&2") + deployer.succeed("cp -R /root/.ssh/* /home/panel/.ssh >&2") + deployer.succeed("chown -R panel:panel /home/panel/.ssh >&2") + deployer.succeed("chmod 600 /home/panel/.ssh/* >&2") + + ## TODO: This is a hack to accept the root CA used by Pebble on the client + ## machine. Pebble randomizes everything, so the only way to get it is to + ## call the /roots/0 endpoint at runtime, leaving not much margin for a nice + ## Nixy way of adding the certificate. There is no way around it as this is + ## by design in Pebble, showing in fact that Pebble was not the appropriate + ## tool for our use and that nixpkgs does not in fact provide an easy way to + ## generate _usable_ certificates in NixOS tests. I suggest we merge this, + ## and track the task to set it up in a cleaner way. I would tackle this in + ## a subsequent PR, and hopefully even contribute this BetterWay(tm) to + ## nixpkgs. — Niols + ## + with subtest("Set up ACME root CA on client"): + client.succeed(""" + cd /etc/ssl/certs + curl -o pebble-root-ca.pem https://acme.test:15000/roots/0 + curl -o pebble-intermediate-ca.pem https://acme.test:15000/intermediates/0 + { cat ca-bundle.crt + cat pebble-root-ca.pem + cat pebble-intermediate-ca.pem + } > new-ca-bundle.crt + rm ca-bundle.crt ca-certificates.crt + mv new-ca-bundle.crt ca-bundle.crt + ln -s ca-bundle.crt ca-certificates.crt + """) + + ## TODO: I would hope for a more declarative way to add users. This should + ## be handled by the Nix packaging of the FediPanel. — Niols + ## + with subtest("Create panel user"): + deployer.succeed(""" + expect -c ' + spawn manage createsuperuser --username ${panelUser} --email ${panelEmail} + expect "Password: "; send "${panelPassword}\\n"; + expect "Password (again): "; send "${panelPassword}\\n" + interact + ' >&2 + """) + + with subtest("Check the status of the services - there should be none"): + garage.fail("systemctl status garage.service") + mastodon.fail("systemctl status mastodon-web.service") + peertube.fail("systemctl status peertube.service") + pixelfed.fail("systemctl status phpfpm-pixelfed.service") + + with subtest("Run deployment with no services enabled"): + client.succeed("${ + interactWithPanel { + baseUri = "https://deployer"; + enableMastodon = false; + enablePeertube = false; + enablePixelfed = false; + } + }/bin/interact-with-panel >&2") + + with subtest("Check the status of the services - there should still be none"): + garage.fail("systemctl status garage.service") + mastodon.fail("systemctl status mastodon-web.service") + peertube.fail("systemctl status peertube.service") + pixelfed.fail("systemctl status phpfpm-pixelfed.service") + + with subtest("Run deployment with Mastodon and Pixelfed enabled"): + client.succeed("${ + interactWithPanel { + baseUri = "https://deployer"; + enableMastodon = true; + enablePeertube = false; + enablePixelfed = true; + } + }/bin/interact-with-panel >&2") + + with subtest("Check the status of the services - expecting Garage, Mastodon and Pixelfed"): + garage.succeed("systemctl status garage.service") + mastodon.succeed("systemctl status mastodon-web.service") + peertube.fail("systemctl status peertube.service") + pixelfed.succeed("systemctl status phpfpm-pixelfed.service") + + with subtest("Run deployment with only Peertube enabled"): + client.succeed("${ + interactWithPanel { + baseUri = "https://deployer"; + enableMastodon = false; + enablePeertube = true; + enablePixelfed = false; + } + }/bin/interact-with-panel >&2") + + with subtest("Check the status of the services - expecting Garage and Peertube"): + garage.succeed("systemctl status garage.service") + mastodon.fail("systemctl status mastodon-web.service") + peertube.succeed("systemctl status peertube.service") + pixelfed.fail("systemctl status phpfpm-pixelfed.service") + ''; +} diff --git a/deployment/flake-part.nix b/deployment/flake-part.nix index 5e822688..4f31f2eb 100644 --- a/deployment/flake-part.nix +++ b/deployment/flake-part.nix @@ -2,5 +2,6 @@ imports = [ ./check/basic/flake-part.nix ./check/cli/flake-part.nix + ./check/panel/flake-part.nix ]; } diff --git a/panel/nix/configuration.nix b/panel/nix/configuration.nix index e4de7b81..ccc6506a 100644 --- a/panel/nix/configuration.nix +++ b/panel/nix/configuration.nix @@ -140,7 +140,7 @@ in description = '' A package providing NixOps4. - REVIEW: This should not be at the level of the NixOS module, but instead + TODO: This should not be at the level of the NixOS module, but instead at the level of the panel's package. Until one finds a way to grab NixOps4 from the package's npins-based code, we will have to do with this workaround. @@ -200,7 +200,7 @@ in }; users.users.${name} = { - # REVIEW[Niols]: change to system user or document why we specifically + # TODO[Niols]: change to system user or document why we specifically # need a normal user. isNormalUser = true; }; diff --git a/panel/src/panel/settings.py b/panel/src/panel/settings.py index d613e0ec..5a555495 100644 --- a/panel/src/panel/settings.py +++ b/panel/src/panel/settings.py @@ -42,6 +42,7 @@ def get_secret(name: str, encoding: str = "utf-8") -> str: return secret # SECURITY WARNING: keep the secret key used in production secret! +# This is used nowhere but is required by Django. SECRET_KEY = get_secret("SECRET_KEY") # SECURITY WARNING: don't run with debug turned on in production!