diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fe7008613..659efecce 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -83,6 +83,18 @@ jobs: NIX_BINARY_CACHE_PRIVATE_KEY: ${{ secrets.NIX_BINARY_CACHE_PRIVATE_KEY }} - run: NIXPKGS_ALLOW_UNFREE=1 ./build vm + build-vm-portable: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: ./.github/actions/prep-build-env + with: + NIX_CACHE_ACCOUNT_ID: ${{ secrets.NIX_CACHE_ACCOUNT_ID }} + NIX_CACHE_ACCESS_KEY_ID: ${{ secrets.NIX_CACHE_ACCESS_KEY_ID }} + NIX_CACHE_SECRET_ACCESS_KEY: ${{ secrets.NIX_CACHE_SECRET_ACCESS_KEY }} + NIX_BINARY_CACHE_PRIVATE_KEY: ${{ secrets.NIX_BINARY_CACHE_PRIVATE_KEY }} + - run: NIXPKGS_ALLOW_UNFREE=1 ./build vm-portable + e2e-tests: runs-on: ubuntu-latest steps: diff --git a/base/controller-service.nix b/base/controller-service.nix index 253d92677..8c542029e 100644 --- a/base/controller-service.nix +++ b/base/controller-service.nix @@ -13,6 +13,12 @@ with lib; description = "DNS-SD service types to browse for and annotate networks with in controller UI"; type = types.listOf types.str; }; + downloadLimit = mkOption { + default = "10M"; + example = "500K"; + description = "curl --limit-rate value for update bundle downloads. If unset, no limit is applied."; + type = types.nullOr types.str; + }; }; }; @@ -58,6 +64,8 @@ with lib; environment = { PLAYOS_ANNOTATE_DISCOVERED_SERVICES = mkIf hasAnnotatedServices (concatStringsSep ";" cfg.annotateDiscoveredServices); + PLAYOS_UPDATE_DL_LIMIT = + mkIf (cfg.downloadLimit != null) cfg.downloadLimit; }; }; }; diff --git a/build b/build index f638a55c6..91dc18a60 100755 --- a/build +++ b/build @@ -5,7 +5,7 @@ set -euo pipefail PRODUCTION_TARGETS="develop|validation|master|release-disk" # other targets used for development/testing/etc -OTHER_TARGETS="vm|stuck|lab-key|shed-key|test-e2e|all" +OTHER_TARGETS="vm|stuck|lab-key|shed-key|test-e2e|portable|vm-portable|all" ALL_TARGETS="$PRODUCTION_TARGETS|$OTHER_TARGETS" @@ -44,13 +44,22 @@ fi # See https://nixos.wiki/wiki/FAQ/How_can_I_install_a_proprietary_or_unfree_package%3F export NIXPKGS_ALLOW_UNFREE=1 -if [ "$TARGET" == "vm" ]; then +if [[ "$TARGET" == "vm" ]] || [[ "$TARGET" == "vm-portable" ]] ; then - (set -x; nix-build \ - --arg buildInstaller false \ - --arg buildBundle false \ - --arg buildLive false \ - --arg buildDisk false) + build_args=( + --arg buildInstaller false + --arg buildBundle false + --arg buildLive false + --arg buildDisk false + ) + + if [ "$TARGET" == "vm-portable" ]; then + build_args+=( + --arg applicationPath ./flavors/portable.nix + ) + fi + + (set -x; nix-build "${build_args[@]}") echo -e " Run ./result/bin/run-in-vm to start a VM. @@ -115,6 +124,23 @@ elif [ "$TARGET" == "master" ]; then echo echo "Run ./result/bin/deploy-update to deploy." +elif [ "$TARGET" == "portable" ]; then + + scripts/info-branch-commit + scripts/confirm-or-abort + + (set -x; nix-build \ + --arg applicationPath ./flavors/portable.nix \ + --arg updateCert ./pki/master/cert.pem \ + --arg updateUrl https://dist.dividat.com/releases/playos/portable/ \ + --arg deployUrl s3://dist.dividat.ch/releases/playos/portable/ \ + --arg kioskUrl https://play.dividat.com/ \ + --arg watchdogUrls '["https://play.dividat.com/" "https://api.dividat.com"]' \ + --arg buildDisk false) + + echo + echo "Run ./result/bin/deploy-update to deploy." + elif [ "$TARGET" == "stuck" ]; then echo "Creating a stuck system that will not self-update." diff --git a/controller/bindings/network-watchdog/network_watchdog.ml b/controller/bindings/network-watchdog/network_watchdog.ml index 56be0b4f2..6ae5f62a3 100644 --- a/controller/bindings/network-watchdog/network_watchdog.ml +++ b/controller/bindings/network-watchdog/network_watchdog.ml @@ -5,7 +5,19 @@ let watchdog_service_name = "playos-network-watchdog.service" let watchdog_disable_filepath = "/home/play/.config/playos-network-watchdog/disabled" -let is_disabled () = Lwt_unix.file_exists watchdog_disable_filepath +let is_disabled systemd = + let%lwt is_installed = + Lwt.catch + (fun () -> + let%lwt _ = Systemd.Manager.get_unit systemd watchdog_service_name in + Lwt.return true + ) + (fun _ -> Lwt.return false) + in + let%lwt is_manually_disabled = + Lwt_unix.file_exists watchdog_disable_filepath + in + Lwt.return (is_manually_disabled || not is_installed) let enable systemd = let%lwt exists = Lwt_unix.file_exists watchdog_disable_filepath in diff --git a/controller/bindings/network-watchdog/network_watchdog.mli b/controller/bindings/network-watchdog/network_watchdog.mli index d7bb62c20..6d644abfa 100644 --- a/controller/bindings/network-watchdog/network_watchdog.mli +++ b/controller/bindings/network-watchdog/network_watchdog.mli @@ -1,6 +1,6 @@ (* Tiny interface for enabling/disabling the PlayOS network watchdog *) -val is_disabled : unit -> bool Lwt.t +val is_disabled : Systemd.Manager.t -> bool Lwt.t val enable : Systemd.Manager.t -> unit Lwt.t diff --git a/controller/server/gui.ml b/controller/server/gui.ml index 84f3bc34c..e1365394f 100644 --- a/controller/server/gui.ml +++ b/controller/server/gui.ml @@ -484,10 +484,11 @@ module StatusGui = struct in reboot () - let get_status ~health_s ~(update_s : Update.state React.signal) ~rauc = + let get_status ~systemd ~health_s ~(update_s : Update.state React.signal) + ~rauc = let health_state = health_s |> Lwt_react.S.value in let update_state = update_s |> Lwt_react.S.value in - let%lwt watchdog_disabled = Network_watchdog.is_disabled () in + let%lwt watchdog_disabled = Network_watchdog.is_disabled systemd in let%lwt booted_slot = Rauc.get_booted_slot rauc in let%lwt rauc = match update_state.process_state with @@ -532,7 +533,7 @@ module StatusGui = struct redirect' (Uri.of_string "/status") ) |> get "/status" (fun _req -> - let%lwt status = get_status ~update_s ~health_s ~rauc in + let%lwt status = get_status ~systemd ~update_s ~health_s ~rauc in Lwt.return (page (Status_page.html status)) ) end diff --git a/controller/server/update_client.ml b/controller/server/update_client.ml index 4a566a4e9..7e0e31b51 100644 --- a/controller/server/update_client.ml +++ b/controller/server/update_client.ml @@ -13,11 +13,13 @@ module type UpdateClientDeps = sig val download_dir : string + val download_limit : string option + val get_proxy : unit -> Uri.t option Lwt.t end -let make_deps ?download_dir_override get_proxy base_url : - (module UpdateClientDeps) = +let make_deps ?download_dir_override ?download_limit_override get_proxy base_url + : (module UpdateClientDeps) = (module struct let base_url = base_url @@ -29,6 +31,13 @@ let make_deps ?download_dir_override get_proxy base_url : Sys.getenv_opt "STATE_DIRECTORY" |> Option.value ~default:"/tmp" in download_dir_override |> Option.value ~default:default_download_dir + + let download_limit = + match download_limit_override with + | Some _ -> + download_limit_override + | None -> + Sys.getenv_opt "PLAYOS_UPDATE_DL_LIMIT" end ) @@ -71,15 +80,16 @@ module UpdateClient (DepsI : UpdateClientDeps) = struct let bundle_path = Format.sprintf "%s/%s" download_dir (bundle_file_name version) in - let options = - [ "--continue-at" - ; "-" (* resume download *) - ; "--limit-rate" - ; "10M" - ; "--output" - ; bundle_path - ] + let limit_rate_opts = + match DepsI.download_limit with + | Some limit -> + [ "--limit-rate"; limit ] + | None -> + [] in + let resume_opts = [ "--continue-at"; "-" ] in + let output_opts = [ "--output"; bundle_path ] in + let options = List.flatten [ limit_rate_opts; resume_opts; output_opts ] in let%lwt proxy = get_proxy () in match%lwt Curl.request ?proxy ~options url with | RequestSuccess _ -> diff --git a/controller/server/update_client.mli b/controller/server/update_client.mli index b411fccc6..8f9749084 100644 --- a/controller/server/update_client.mli +++ b/controller/server/update_client.mli @@ -14,11 +14,15 @@ module type UpdateClientDeps = sig val download_dir : string + (** If set, passed to curl as --limit-rate *) + val download_limit : string option + val get_proxy : unit -> Uri.t option Lwt.t end val make_deps : ?download_dir_override:string + -> ?download_limit_override:string -> (unit -> Uri.t option Lwt.t) -> Uri.t -> (module UpdateClientDeps) diff --git a/controller/tests/server/update/update_client_tests.ml b/controller/tests/server/update/update_client_tests.ml index 240eac6a0..ee94d19fb 100644 --- a/controller/tests/server/update/update_client_tests.ml +++ b/controller/tests/server/update/update_client_tests.ml @@ -60,7 +60,7 @@ let rec wait_for_mock_server ?(timeout = 0.2) ?(remaining_tries = 3) url = in Lwt.fail (Failure err_msg) -let run_test_case ?(proxy = NoProxy) switch f = +let run_test_case ?(proxy = NoProxy) ?download_limit switch f = let server = mock_server () in let server_url, server_task = server#run () in let%lwt () = wait_for_mock_server server_url in @@ -78,8 +78,8 @@ let run_test_case ?(proxy = NoProxy) switch f = in let () = Sys.mkdir temp_dir 0o777 in let module DepsI = - ( val Update_client.make_deps ~download_dir_override:temp_dir get_proxy - base_url + ( val Update_client.make_deps ~download_dir_override:temp_dir + ?download_limit_override:download_limit get_proxy base_url ) in let module UpdateC = Update_client.Make (DepsI) in @@ -153,9 +153,20 @@ let test_invalid_proxy_fail _ (module Client : S) = ^ Printexc.to_string other_exn ) +let test_download_limit_honored server (module Client : S) = + let version = "1.0.0" in + let bundle = String.make (3 * 1024) 'x' in + let () = server#add_bundle version bundle in + let start = Unix.gettimeofday () in + let%lwt _bundle_path = Client.download version in + let elapsed = Unix.gettimeofday () -. start in + Alcotest.(check bool) + "Download took at least 2.0 seconds with 1K/s limit" true (elapsed >= 2.0) ; + Lwt.return () + let () = let () = setup_log () in - (* All tests cases are run with proxy setup and without to verify it works + (* Common test cases are run with proxy setup and without to verify it works always *) let test_cases = [ ("Get latest version", test_get_version_ok) @@ -163,6 +174,11 @@ let () = ; ("Resume download works", test_resume_bundle_download) ] in + let download_limit_case = + Alcotest_lwt.test_case "Download rate is limited" `Slow (fun switch () -> + run_test_case ~download_limit:"1K" switch test_download_limit_honored + ) + in (* An extra case to check that proxy settings are honored in general *) let invalid_proxy_case = Alcotest_lwt.test_case "Invalid proxy specified" `Quick (fun switch () -> @@ -173,13 +189,14 @@ let () = Lwt_main.run @@ Alcotest_lwt.run "Basic tests" [ ( "without-proxy" - , List.map - (fun (name, test_f) -> - Alcotest_lwt.test_case name `Quick (fun switch () -> - run_test_case switch test_f - ) - ) - test_cases + , download_limit_case + :: List.map + (fun (name, test_f) -> + Alcotest_lwt.test_case name `Quick (fun switch () -> + run_test_case switch test_f + ) + ) + test_cases ) ; ( "with-proxy" , invalid_proxy_case diff --git a/flavors/portable.nix b/flavors/portable.nix new file mode 100644 index 000000000..8aa5f05bf --- /dev/null +++ b/flavors/portable.nix @@ -0,0 +1,78 @@ +let + defaults = import ../application.nix; +in +{ + inherit (defaults) safeProductName version overlays greeting; + + fullProductName = "${defaults.fullProductName} (portable)"; + + module = { config, lib, pkgs, ... }: { + imports = [ + defaults.module + ]; + + # use a lower download limit to ensure minimal interference + playos.controller.downloadLimit = "2M"; + + # we do not expect a stable/permanent network connection + playos.networking.watchdog.enable = lib.mkForce false; + + # metrics are optimized for specific hardware and stable setup, probably + # not very useful for ad-hoc portable setups + playos.monitoring.enable = lib.mkForce false; + + # Do not hard-code HDMI as default + hardware.pulseaudio = { + extraConfig = lib.mkForce '' + # Respond to changes in connected outputs + load-module module-switch-on-port-available + load-module module-switch-on-connect blacklist="" + + # Prevent PulseAudio from remembering previous muted states. + # First, we unload the default restore module, then reload it + # explicitly telling it NOT to restore mute states. + unload-module module-device-restore + load-module module-device-restore restore_volume=true restore_muted=false + ''; + }; + + systemd.user.services.force-unmute = { + description = "Force unmute PulseAudio on login"; + wantedBy = [ "default.target" ]; + after = [ "pulseaudio.service" ]; + + path = [ pkgs.pulseaudio ]; + + script = '' + # Brief pause to ensure PulseAudio has fully initialized its sinks + sleep 2 + pactl set-sink-mute @DEFAULT_SINK@ 0 + + # Optional: Force volume to a safe default so it is never at 0% + pactl set-sink-volume @DEFAULT_SINK@ 70% + ''; + + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + }; + }; + + # Add bindings for media keys to allow volume control + environment.etc."sxhkd/sxhkdrc".text = '' + XF86AudioLowerVolume + ${pkgs.pulseaudio}/bin/pactl set-sink-volume @DEFAULT_SINK@ -5% + + XF86AudioRaiseVolume + ${pkgs.pulseaudio}/bin/pactl set-sink-volume @DEFAULT_SINK@ +5% + + XF86AudioMute + ${pkgs.pulseaudio}/bin/pactl set-sink-mute @DEFAULT_SINK@ toggle + ''; + + services.xserver.displayManager.sessionCommands = '' + # Provides media key bindings for volume control + ${pkgs.sxhkd}/bin/sxhkd -c /etc/sxhkd/sxhkdrc & + ''; + }; +} diff --git a/testing/end-to-end/tests/base/proxy-and-update.nix b/testing/end-to-end/tests/base/proxy-and-update.nix index eb3594a85..bbb3cbab1 100644 --- a/testing/end-to-end/tests/base/proxy-and-update.nix +++ b/testing/end-to-end/tests/base/proxy-and-update.nix @@ -131,6 +131,7 @@ pkgs.testers.runNixOSTest { ${builtins.readFile ../../../helpers/nixos-test-script-helpers.py} ${builtins.readFile ./proxy-and-update-helpers.py} import json + import os product_name = "${safeProductName}" current_version = "1.1.1-TESTMAGIC" @@ -143,6 +144,9 @@ pkgs.testers.runNixOSTest { is_legacy_mode = bool(${toString legacyMode}) # `toString false` returns "" bad_ext4_option = "metadata_csum_seed" + def wait_for_state(state, timeout): + return wait_for_logs(playos, state, unit="playos-controller", timeout=timeout) + create_overlay("${disk}", "${overlayPath}") playos.start(allow_reboot=True) sidekick.start() @@ -212,10 +216,7 @@ pkgs.testers.runNixOSTest { ] for state in expected_states: - wait_for_logs(playos, - state, - unit="playos-controller.service", - timeout=61) + wait_for_state(state, timeout=61) with TestCase("Controller installs the new upstream version") as t: next_version = "${nextVersion}" @@ -227,19 +228,26 @@ pkgs.testers.runNixOSTest { # TODO: override config to reduce check interval instead playos.systemctl("restart playos-controller.service") - expected_states = [ - "Downloading", - f"Installing.*{update_server.bundle_filename(next_version)}", - "RebootRequired" - ] + start_time = time.time() + + # expected state transitions: Downloading -> Installing -> RebootRequired + + # -> Downloading + wait_for_state("Downloading", timeout=10) + + # Downloading -> Installing + bundle_size = os.path.getsize("${nextVersionBundle}") + min_download_time = bundle_size / (10 * 1024 * 1024) # 10 M/s default limit + + wait_for_state(f"Installing.*{update_server.bundle_filename(next_version)}", timeout=min_download_time*2) + + elapsed = time.time() - start_time + t.assertGreater(elapsed, min_download_time, + f"Downloaded bundle too fast ({elapsed:.1f}s), rate limiting not applied?") + + # Installing -> RebootRequired + wait_for_state("RebootRequired", timeout=30) - for state in expected_states: - wait_for_logs(playos, - state, - unit="playos-controller.service", - # curl is limited to 10MB/s in controller, so - # a 600 MB bundle will take at least 60s - timeout=75) with TestCase("RAUC status confirms the installation") as t: rauc_status = json.loads(playos.succeed(