Repository: freedomofpress/securedrop-workstation
Pull request #1295: Remove remaining hardcoded references to Whonix version
Author: login: deeplow
Created: 2025-04-29T21:00:13Z | Merged: 2025-05-08T13:16:29Z

## Status

Ready for review

## Description of Changes

Removes hardcoded whonix version from remaining code base.

Refs #1060 (partial fix -- still missing Trixie debs being available for SecureDrop packages)


## Testing (ideas)

### Simulate a Whonix 18 upgrade
Apply the following patches:
`/srv/salt/securedrop_salt/sd-sys-whonix-vms.sls`:

   ```diff
   whonix-{{ component }}-{{ whonix.whonix_version }}-installed:
   +  qvm.vm:
   +    - name: whonix-{{component}}-{{ whonix.whonix_version }}
   +    - present:
   +      - label: black
   -  qvm.template_installed:
   -    - name: whonix-{{ component }}-{{ whonix.whonix_version }}
   -    - fromrepo: {{ whonix.whonix_repo }}
   -    - require:
   -      - sls: qvm.template-whonix-{{ component }}
   ```
`/srv/formulas/base/virtual-machines-formula/qvm/template-whonix-workstation.sls`
   ```diff
   template-whonix-gateway-{{ whonix.whonix_version }}:
   -  qvm.template_installed:
   -    - name:     whonix-gateway-{{ whonix.whonix_version_override_template }}
   -    - fromrepo: {{ whonix.whonix_repo }}
   +  qvm.vm:
   +    - name: whonix-gateway-{{ whonix.whonix_version }}
   +    - present:
   +      - label: black
   ```

### Actual test

- [ ] `make test` passes in dom0 (except for `test_sd_whonix_config`)
- [ ] run updater and confirm in logs that whonix-gateway-18 was updated

### Undo changes

- [ ] Don't forget to undo the changes in `/srv/formulas`. The easiest way is to `sudo qubes-dom0-update --action=reinstall qubes-mgmt-salt-dom0-virtual-machines`.


## Deployment

Any special considerations for deployment? Consider both:

1. Upgrading existing pilot instances
2. New installs

## Checklist

### If you have made changes to the provisioning logic

- [ ] All tests (`make test`) pass in `dom0`

### If you have added or removed files

- [ ] I have updated `MANIFEST.in` and `rpm-build/SPECS/securedrop-workstation-dom0-config.spec`

### If documentation is required

- [ ] I have opened a PR in the [docs repo](https://github.com/freedomofpress/securedrop-workstation-docs) for these changes, or will do so later
- [ ] I would appreciate help with the documentation


Diff:
diff --git a/Makefile b/Makefile
index b7de2895..26bc662b 100644
--- a/Makefile
+++ b/Makefile
@@ -92,10 +92,6 @@ sd-app: prep-dev ## Provisions SD APP VM
 	sudo qubesctl --show-output state.sls securedrop_salt.sd-app
 	sudo qubesctl --show-output --skip-dom0 --targets sd-small-bookworm-template,sd-app state.highstate
 
-sd-whonix: prep-dev ## Provisions SD Whonix VM
-	sudo qubesctl --show-output state.sls securedrop_salt.sd-whonix
-	sudo qubesctl --show-output --skip-dom0 --targets whonix-gateway-17,sd-whonix state.highstate
-
 sd-viewer: prep-dev ## Provisions SD Submission Viewing VM
 	sudo qubesctl --show-output state.sls securedrop_salt.sd-viewer
 	sudo qubesctl --show-output --skip-dom0 --targets sd-large-bookworm-template,sd-viewer state.highstate
diff --git a/files/sdw-admin.py b/files/sdw-admin.py
index a616bccf..6377c337 100755
--- a/files/sdw-admin.py
+++ b/files/sdw-admin.py
@@ -12,6 +12,8 @@
 
 from qubesadmin import Qubes
 
+from sdw_util import Util
+
 # The max concurrency reduction (4->2) was required to avoid "did not return clean data"
 # errors from qubesctl. It may be possible to raise this again.
 MAX_CONCURRENCY = 2
@@ -91,7 +93,7 @@ def provision_and_configure():
     provision("Provisioning base template", "securedrop_salt.sd-base-template")
     configure("Configuring base template", ["sd-base-bookworm-template"])
     provision_all()
-    configure("Enabling Whonix customizations", ["whonix-gateway-17"])
+    configure("Enabling Whonix customizations", [f"whonix-gateway-{Util.get_whonix_version()}"])
     configure(
         "Configure all SecureDrop Workstation VMs with service-specific configs",
         [q.name for q in Qubes().domains if "sd-workstation" in q.tags],
@@ -193,9 +195,10 @@ def sync_appmenus():
     run_cmd(["qvm-sync-appmenus", "sd-large-bookworm-template"])
     run_cmd(["qvm-shutdown", "sd-large-bookworm-template"])
 
-    run_cmd(["qvm-start", "--skip-if-running", "whonix-gateway-17"])
-    run_cmd(["qvm-sync-appmenus", "whonix-gateway-17"])
-    run_cmd(["qvm-shutdown", "whonix-gateway-17"])
+    whonix_gateway = f"whonix-gateway-{Util.get_whonix_version()}"
+    run_cmd(["qvm-start", "--skip-if-running", whonix_gateway])
+    run_cmd(["qvm-sync-appmenus", whonix_gateway])
+    run_cmd(["qvm-shutdown", whonix_gateway])
 
     # These are the ones we show in prod VMs, so sync explicitly
     run_cmd(["qvm-sync-appmenus", "--regenerate-only", "sd-devices"])
diff --git a/launcher/tests/test_updater.py b/launcher/tests/test_updater.py
index 03f632c6..e0adef31 100644
--- a/launcher/tests/test_updater.py
+++ b/launcher/tests/test_updater.py
@@ -20,6 +20,7 @@
     "sd-devices",
 ]
 
+WHONIX_VERSION = 17
 DEBIAN_VERSION = "bookworm"
 
 TEST_RESULTS_OK = {
@@ -51,12 +52,14 @@
 }
 
 
-def test_updater_vms_present():
-    assert len(Updater.current_vms) == 8
+@mock.patch("sdw_util.Util.get_whonix_version", return_value=WHONIX_VERSION)
+def test__get_current_vms(mocked_get_whonix_version):
+    assert len(Updater._get_current_vms()) == 8
 
 
-def test_updater_templatevms_present():
-    assert len(Updater.current_templates) == 4
+@mock.patch("sdw_util.Util.get_whonix_version", return_value=WHONIX_VERSION)
+def test__get_current_templates(mocked_get_whonix_version):
+    assert len(Updater._get_current_templates()) == 4
 
 
 @mock.patch("sdw_updater.Updater._write_updates_status_flag_to_disk")
@@ -91,13 +94,16 @@ def test_apply_updates_dom0_no_updates(
     assert not apply_dom0.called
 
 
+@mock.patch("sdw_updater.Updater._get_current_templates", return_value=[])
 @mock.patch("sdw_updater.Updater._write_updates_status_flag_to_disk")
 @mock.patch("sdw_updater.Updater._write_last_updated_flags_to_disk")
 @mock.patch("sdw_updater.Updater._start_qubes_updater_proc")
 @mock.patch("sdw_updater.Updater.sdlog.error")
 @mock.patch("sdw_updater.Updater.sdlog.info")
-def test_apply_templates_success(mocked_info, mocked_error, mock_proc, write_updated, write_status):
-    result = Updater.apply_updates_templates(templates=[])
+def test_apply_templates_success(
+    mocked_info, mocked_error, mock_proc, write_updated, write_status, mocked_templates
+):
+    result = Updater.apply_updates_templates()
     mock_proc.assert_called_once()
     assert result == UpdateStatus.UPDATES_OK
     assert not mocked_error.called
@@ -123,17 +129,21 @@ def test_apply_templates_success(mocked_info, mocked_error, mock_proc, write_upd
         ),
     ],
 )
-def test_apply_templates(templates, stderr, expected):
-    with mock.patch(
-        "sdw_updater.Updater._start_qubes_updater_proc",
-        return_value=subprocess.Popen(  # noqa: S602
-            f"echo '{stderr}' >> /dev/stderr",
-            shell=True,
-            stdout=subprocess.PIPE,
-            stderr=subprocess.PIPE,
+@mock.patch("sdw_util.Util.get_whonix_version", return_value=WHONIX_VERSION)
+def test_apply_templates(whonix_ver_mock, templates, stderr, expected):
+    with (
+        mock.patch(
+            "sdw_updater.Updater._start_qubes_updater_proc",
+            return_value=subprocess.Popen(  # noqa: S602
+                f"echo '{stderr}' >> /dev/stderr",
+                shell=True,
+                stdout=subprocess.PIPE,
+                stderr=subprocess.PIPE,
+            ),
         ),
+        mock.patch("sdw_updater.Updater._get_current_templates", return_value=templates),
     ):
-        result = Updater.apply_updates_templates(templates=templates)
+        result = Updater.apply_updates_templates()
         assert result == expected
 
 
diff --git a/launcher/tests/test_util.py b/launcher/tests/test_util.py
index 92aa5f91..46509c09 100644
--- a/launcher/tests/test_util.py
+++ b/launcher/tests/test_util.py
@@ -20,6 +20,7 @@
 FIXTURES_PATH = Path(__file__).parent / "fixtures"
 
 DEBIAN_VERSION = "bookworm"
+WHONIX_VERSION = 17
 
 
 @mock.patch("sdw_util.Util.sdlog.error")
@@ -303,3 +304,14 @@ def test_is_sdapp_halted_error(patched_subprocess, os_release_fixture, version_c
     """
 
     assert not Util.is_sdapp_halted()
+
+
+@mock.patch("subprocess.check_output", return_value=str(WHONIX_VERSION))
+def test_get_whonix_version(patched_subprocess):
+    assert Util.get_whonix_version() == WHONIX_VERSION
+
+
+@mock.patch("subprocess.check_output", side_effect=subprocess.CalledProcessError(1, "check_output"))
+def test_get_whonix_version__on_error(patched_subprocess):
+    with pytest.raises(RuntimeError, match="Whonix version could not be obtained"):
+        Util.get_whonix_version()
diff --git a/sdw_updater/Updater.py b/sdw_updater/Updater.py
index 594bba92..9a80db16 100644
--- a/sdw_updater/Updater.py
+++ b/sdw_updater/Updater.py
@@ -33,26 +33,31 @@
 # logic to leverage the Qubes Python API.
 MIGRATION_DIR = "/tmp/sdw-migrations"
 
-DEBIAN_VERSION = "bookworm"
-
 sdlog = Util.get_logger(module=__name__)
 detail_log = Util.get_logger(prefix=DETAIL_LOGGER_PREFIX, module=__name__)
 
-# The are the TemplateVMs that require full patch level at boot in order to start the client,
-# as well as their associated TemplateVMs.
-# In the future, we could use qvm-prefs to extract this information.
-current_vms = {
-    "fedora": "fedora-41-xfce",
-    "sd-viewer": f"sd-large-{DEBIAN_VERSION}-template",
-    "sd-app": f"sd-small-{DEBIAN_VERSION}-template",
-    "sd-log": f"sd-small-{DEBIAN_VERSION}-template",
-    "sd-devices": f"sd-large-{DEBIAN_VERSION}-template",
-    "sd-proxy": f"sd-small-{DEBIAN_VERSION}-template",
-    "sd-whonix": "whonix-gateway-17",
-    "sd-gpg": f"sd-small-{DEBIAN_VERSION}-template",
-}
 
-current_templates = set([val for key, val in current_vms.items() if key != "dom0"])
+def _get_current_vms():
+    debian_version = "bookworm"
+    whonix_version = Util.get_whonix_version()
+
+    # The are the TemplateVMs that require full patch level at boot in order to start the client,
+    # as well as their associated TemplateVMs.
+    # In the future, we could use qvm-prefs to extract this information.
+    return {
+        "fedora": "fedora-41-xfce",
+        "sd-viewer": f"sd-large-{debian_version}-template",
+        "sd-app": f"sd-small-{debian_version}-template",
+        "sd-log": f"sd-small-{debian_version}-template",
+        "sd-devices": f"sd-large-{debian_version}-template",
+        "sd-proxy": f"sd-small-{debian_version}-template",
+        "sd-whonix": f"whonix-gateway-{whonix_version}",
+        "sd-gpg": f"sd-small-{debian_version}-template",
+    }
+
+
+def _get_current_templates():
+    return set([val for key, val in _get_current_vms().items() if key != "dom0"])
 
 
 def get_dom0_path(folder):
@@ -117,10 +122,11 @@ def apply_updates_dom0():
     return upgrade_results
 
 
-def apply_updates_templates(templates=current_templates, progress_callback=None):
+def apply_updates_templates(progress_callback=None):
     """
     Apply updates to all TemplateVMs.
     """
+    templates = _get_current_templates()
     sdlog.info(f"Applying all updates to VMs: {', '.join(templates)}")
     try:
         proc = _start_qubes_updater_proc(templates)
diff --git a/sdw_updater/UpdaterApp.py b/sdw_updater/UpdaterApp.py
index a76f40ca..ecdd7304 100644
--- a/sdw_updater/UpdaterApp.py
+++ b/sdw_updater/UpdaterApp.py
@@ -5,7 +5,7 @@
 from PyQt5.QtWidgets import QDialog
 
 from sdw_updater import Updater, strings
-from sdw_updater.Updater import UpdateStatus, current_templates
+from sdw_updater.Updater import UpdateStatus
 from sdw_updater.UpdaterAppUiQt5 import Ui_UpdaterDialog
 from sdw_util import Util
 
@@ -282,7 +282,6 @@ def run_full_update(self):
             )
 
         results["templates"] = Updater.apply_updates_templates(
-            current_templates,
             templates_progress_callback,
         )
 
diff --git a/sdw_util/Util.py b/sdw_util/Util.py
index b5867690..d6f4b4b1 100644
--- a/sdw_util/Util.py
+++ b/sdw_util/Util.py
@@ -148,6 +148,40 @@ def get_qubes_version():
     return version
 
 
+def get_whonix_version():
+    """
+    Obtain Whonix version from /srv/formulas/base/virtual-machine-formula/qvm/whonix.jinja
+
+    In '.sls' files the whonix version is obtained with
+
+        {% import "qvm/whonix.jinja" as whonix %}
+        {{ whonix.whonix_version }}
+
+    This function extracts this value so that it can be on this script.
+
+    NOTE: function takes a few seconds to run
+    """
+
+    try:
+        output = subprocess.check_output(
+            [
+                "sudo",
+                "qubesctl",
+                "jinja.load_map",
+                "qvm/whonix.jinja",
+                "whonix_version",
+                "--out",
+                "newline_values_only",
+            ],  # avoid "local:" prefix
+            universal_newlines=True,
+        )
+        whonix_version = int(output.strip())
+    except (AttributeError, ValueError, subprocess.CalledProcessError):
+        raise RuntimeError("Whonix version could not be obtained")
+
+    return whonix_version
+
+
 def get_logger(prefix=SD_LOGGER_PREFIX, module=None):
     if module is None:
         return logging.getLogger(prefix)
diff --git a/securedrop_salt/sd-clean-all.sls b/securedrop_salt/sd-clean-all.sls
index be6096b0..82c08671 100644
--- a/securedrop_salt/sd-clean-all.sls
+++ b/securedrop_salt/sd-clean-all.sls
@@ -2,6 +2,7 @@
 # vim: set syntax=yaml ts=2 sw=2 sts=2 et :
 
 {% import_json "securedrop_salt/config.json" as d %}
+{% import "qvm/whonix.jinja" as whonix %}
 
 set-fedora-as-default-dispvm:
   cmd.run:
@@ -77,8 +78,8 @@ sd-cleanup-sys-firewall:
 sd-cleanup-whonix-gateway:
   cmd.run:
     - names:
-      - qvm-run whonix-gateway-17 'sudo apt purge --yes securedrop-keyring securedrop-qubesdb-tools securedrop-whonix-config'
-      - qvm-run whonix-gateway-17 'sudo rm -f /etc/apt/sources.list.d/apt-test_freedom_press.sources'
+      - qvm-run whonix-gateway-{{ whonix.whonix_version }} 'sudo apt purge --yes securedrop-keyring securedrop-qubesdb-tools securedrop-whonix-config'
+      - qvm-run whonix-gateway-{{ whonix.whonix_version }} 'sudo rm -f /etc/apt/sources.list.d/apt-test_freedom_press.sources'
 
 # Reset desktop icon size to its original value
 dom0-reset-icon-size-xfce:
diff --git a/securedrop_salt/sd-sys-whonix-vms.sls b/securedrop_salt/sd-sys-whonix-vms.sls
index 1735f1a3..9e536ae6 100644
--- a/securedrop_salt/sd-sys-whonix-vms.sls
+++ b/securedrop_salt/sd-sys-whonix-vms.sls
@@ -6,8 +6,7 @@
 # and ensure sys-whonix and anon-whonix use latest version.
 ##
 
-{%- import "qvm/whonix.jinja" as whonix -%}
-{% set whonix_version = salt['pillar.get']('qvm:whonix:version', whonix.whonix_version) %}
+{% import "qvm/whonix.jinja" as whonix %}
 
 include:
   - qvm.anon-whonix
@@ -23,20 +22,20 @@ include:
 # by a single `qvm.vm` state, but to avoid possible bugs and guarantee
 # we are configuring the template, explicitly require qvm.template_installed
 # with the expected template name.
-whonix-{{ component }}-{{ whonix_version }}-installed:
+whonix-{{ component }}-{{ whonix.whonix_version }}-installed:
   qvm.template_installed:
-    - name: whonix-{{ component }}-{{ whonix_version }}
+    - name: whonix-{{ component }}-{{ whonix.whonix_version }}
     - fromrepo: {{ whonix.whonix_repo }}
     - require:
       - sls: qvm.template-whonix-{{ component }}
 
-whonix-{{ component }}-{{ whonix_version }}-apparmor:
+whonix-{{ component }}-{{ whonix.whonix_version }}-apparmor:
   qvm.vm:
-    - name: whonix-{{ component }}-{{ whonix_version }}
+    - name: whonix-{{ component }}-{{ whonix.whonix_version }}
     - prefs:
       - kernelopts: "apparmor=1 security=apparmor"
     - require:
-      - qvm: whonix-{{ component }}-{{ whonix_version }}-installed
+      - qvm: whonix-{{ component }}-{{ whonix.whonix_version }}-installed
 
 # The Qubes logic is too polite about enforcing template
 # settings, using "present" rather than "prefs". Below we
@@ -50,18 +49,18 @@ poweroff-{{ vm }}:
     - onlyif:
       - qvm-check --quiet {{ vm }}
     - unless:
-      - qvm-prefs {{ vm }} template | grep -q whonix-{{ component }}-{{ whonix_version }}
+      - qvm-prefs {{ vm }} template | grep -q whonix-{{ component }}-{{ whonix.whonix_version }}
 
 # cmd.run is used instead of qvm.vm to avoid a recursive
 # requisite issue via the "name" parameter of qvm.vm.
 {{ vm }}-upgrade-template:
   cmd.run:
-    - name: qvm-prefs {{ vm }} template whonix-{{ component }}-{{ whonix_version }}
+    - name: qvm-prefs {{ vm }} template whonix-{{ component }}-{{ whonix.whonix_version }}
     - require:
       - qvm: poweroff-{{ vm }}
-      - qvm: whonix-{{ component }}-{{ whonix_version }}-apparmor
+      - qvm: whonix-{{ component }}-{{ whonix.whonix_version }}-apparmor
       - sls: qvm.{{ vm }}
     - unless:
-      - qvm-prefs {{ vm }} template | grep -q whonix-{{ component }}-{{ whonix_version }}
+      - qvm-prefs {{ vm }} template | grep -q whonix-{{ component }}-{{ whonix.whonix_version }}
 
 {% endfor %}
diff --git a/securedrop_salt/sd-whonix.sls b/securedrop_salt/sd-whonix.sls
index cce30222..82b50e23 100644
--- a/securedrop_salt/sd-whonix.sls
+++ b/securedrop_salt/sd-whonix.sls
@@ -14,8 +14,7 @@
 # Imports "sdvars" for environment config
 {% from 'securedrop_salt/sd-default-config.sls' import sdvars with context %}
 
-{%- import "qvm/whonix.jinja" as whonix -%}
-{% set whonix_version = salt['pillar.get']('qvm:whonix:version', whonix.whonix_version) %}
+{% import "qvm/whonix.jinja" as whonix %}
 
 include:
   - securedrop_salt.sd-sys-whonix-vms
@@ -30,7 +29,7 @@ poweroff-{{ vm }}:
     - onlyif:
       - qvm-check --quiet {{ vm }}
     - unless:
-      - qvm-prefs sd-whonix template | grep -q whonix-gateway-{{ whonix_version }}
+      - qvm-prefs sd-whonix template | grep -q whonix-gateway-{{ whonix.whonix_version }}
 
 {% endfor %}
 
@@ -39,10 +38,10 @@ sd-whonix:
     - name: sd-whonix
     - present:
       - label: purple
-      - template: whonix-gateway-{{ whonix_version }}
+      - template: whonix-gateway-{{ whonix.whonix_version }}
       - mem: 500
     - prefs:
-      - template: whonix-gateway-{{ whonix_version }}
+      - template: whonix-gateway-{{ whonix.whonix_version }}
       - provides-network: true
       - netvm: "sys-firewall"
       - autostart: true
diff --git a/securedrop_salt/sd-workstation.top b/securedrop_salt/sd-workstation.top
index 05b29d17..d17e26f6 100644
--- a/securedrop_salt/sd-workstation.top
+++ b/securedrop_salt/sd-workstation.top
@@ -1,6 +1,8 @@
 # -*- coding: utf-8 -*-
 # vim: set syntax=yaml ts=2 sw=2 sts=2 et :
 
+{% import "qvm/whonix.jinja" as whonix %}
+
 base:
   dom0:
     - securedrop_salt.sd-sys-vms
@@ -34,7 +36,7 @@ base:
   'sd-fedora-41-dvm,sys-usb':
     - match: list
     - securedrop_salt.sd-usb-autoattach-add
-  whonix-gateway-17:
+  whonix-gateway-{{ whonix.whonix_version }}:
     - securedrop_salt.sd-whonix-config
 
   # "Placeholder" config to trigger TemplateVM boots,
diff --git a/tests/test_vms_exist.py b/tests/test_vms_exist.py
index e5a538ec..9104250d 100644
--- a/tests/test_vms_exist.py
+++ b/tests/test_vms_exist.py
@@ -3,6 +3,7 @@
 import unittest
 
 from base import (
+    CURRENT_WHONIX_VERSION,
     SD_DVM_TEMPLATES,
     SD_TEMPLATE_BASE,
     SD_TEMPLATE_LARGE,
@@ -13,6 +14,8 @@
 )
 from qubesadmin import Qubes
 
+from sdw_util import Util
+
 with open("config.json") as f:
     CONFIG = json.load(f)
 
@@ -95,12 +98,18 @@ def test_sd_whonix_config(self):
         self.assertEqual(nvm.name, "sys-firewall")
         wanted_kernelopts = "apparmor=1 security=apparmor"
         self.assertEqual(vm.kernelopts, wanted_kernelopts)
-        self.assertEqual(vm.template, "whonix-gateway-17")
         self.assertTrue(vm.provides_network)
         self.assertTrue(vm.autostart)
         self.assertFalse(vm.template_for_dispvms)
         self.assertIn("sd-workstation", vm.tags)
 
+        # Whonix version checking
+        self.assertEqual(
+            Util.get_whonix_version(),  # If mismatch, whonix may have been updated.
+            CURRENT_WHONIX_VERSION,  # Fix the test by bumping CURRENT_WHONIX_VERSION
+        )
+        self.assertEqual(vm.template.features.get("os-version"), int(CURRENT_WHONIX_VERSION))
+
     def test_sd_proxy_config(self):
         vm = self.app.domains["sd-proxy"]
         self.assertEqual(vm.template, "sd-proxy-dvm")
diff --git a/tests/test_vms_platform.py b/tests/test_vms_platform.py
index 3fba75f3..6adcd9c9 100644
--- a/tests/test_vms_platform.py
+++ b/tests/test_vms_platform.py
@@ -16,7 +16,6 @@
 BOOKWORM_STRING = "Debian GNU/Linux 12 (bookworm)"
 
 SUPPORTED_SD_DEBIAN_DIST = "bookworm"
-SUPPORTED_WHONIX_PLATFORMS = [BOOKWORM_STRING]
 
 IS_CI = os.environ.get("CI") == "true"
 
@@ -46,15 +45,12 @@ def _validate_vm_platform(self, vm):
         """
         Asserts that the given AppVM is based on an OS listed in the
         SUPPORTED_<XX>_PLATFORMS list, as specified in tests.
-        sd-whonix is based on the whonix-17 template.
+        sd-whonix is based on the whonix-XX template.
         All other workstation-provisioned VMs should be
         SUPPORTED_SD_DEBIAN_DIST based.
         """
         platform = self._get_platform_info(vm)
-        if vm.name in ["sd-whonix"]:
-            self.assertIn(platform, SUPPORTED_WHONIX_PLATFORMS)
-        else:
-            self.assertIn(SUPPORTED_SD_DEBIAN_DIST, platform)
+        self.assertIn(SUPPORTED_SD_DEBIAN_DIST, platform)
 
     def _validate_apt_sources(self, vm):
         """


Reviews:
user: login: legoktm
body: Just looked at the Python so far, will test it now.
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-05-01T16:00:11Z
user: login: deeplow
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-01T16:17:15Z
user: login: deeplow
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-01T18:46:33Z
user: login: deeplow
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-01T21:26:49Z
user: login: legoktm
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-02T14:22:39Z
user: login: deeplow
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T19:07:09Z
user: login: legoktm
body: Let's do it.
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-08T13:13:09Z

Review comments:
diff_hunk: @@ -148,6 +150,45 @@ def get_qubes_version():
     return version
 
 
+def get_whonix_version():
+    """
+    Obtain Whonix version from /srv/formulas/base/virtual-machine-formula/qvm/whonix.jinja
+
+    In '.sls' files the whonix version is obtained with
+
+        {% import "qvm/whonix.jinja" as whonix %}
+        {{ whonix.whonix_version }}
+
+    This function extracts this value so that it can be on this script.
+    """
+
+    # "caching" because qubesctl takes several seconds
+    global whonix_version  # noqa: PLW0603
path: sdw_util/Util.py
user: login: legoktm
body: If we need caching, I would suggest using [`functools.cache`](https://docs.python.org/3/library/functools.html#functools.cache), instead of a global variable. 
created_at: 2025-05-01T15:57:39Z
author_association: MEMBER
diff_hunk: @@ -148,6 +150,45 @@ def get_qubes_version():
     return version
 
 
+def get_whonix_version():
+    """
+    Obtain Whonix version from /srv/formulas/base/virtual-machine-formula/qvm/whonix.jinja
+
+    In '.sls' files the whonix version is obtained with
+
+        {% import "qvm/whonix.jinja" as whonix %}
+        {{ whonix.whonix_version }}
+
+    This function extracts this value so that it can be on this script.
+    """
+
+    # "caching" because qubesctl takes several seconds
+    global whonix_version  # noqa: PLW0603
+    if whonix_version:
+        return whonix_version
+
+    try:
+        output = subprocess.check_output(
+            [
+                "sudo",
+                "qubesctl",
+                "jinja.load_map",
+                "qvm/whonix.jinja",
+                "whonix_version",
+                "--out",
+                "newline_values_only",
+            ],  # avoid "local:" prefix
+            universal_newlines=True,
+        )
+        whonix_version = int(output.strip())
+    except subprocess.CalledProcessError:
path: sdw_util/Util.py
user: login: legoktm
body: Is this the right behavior? If we can't get the whonix version, shouldn't we hard fail? (None of the callers have any error handling to check for a `None` value, we're just going to end up with operations on `whonix-gateway-None`.
created_at: 2025-05-01T15:58:42Z
author_association: MEMBER
diff_hunk: @@ -148,6 +150,45 @@ def get_qubes_version():
     return version
 
 
+def get_whonix_version():
+    """
+    Obtain Whonix version from /srv/formulas/base/virtual-machine-formula/qvm/whonix.jinja
+
+    In '.sls' files the whonix version is obtained with
+
+        {% import "qvm/whonix.jinja" as whonix %}
+        {{ whonix.whonix_version }}
+
+    This function extracts this value so that it can be on this script.
+    """
+
+    # "caching" because qubesctl takes several seconds
+    global whonix_version  # noqa: PLW0603
path: sdw_util/Util.py
user: login: deeplow
body: I almost put that global there a bit provocatively as I was almost sure you'd suggest a better approach :). Will implement that instead.
created_at: 2025-05-01T16:17:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -148,6 +150,45 @@ def get_qubes_version():
     return version
 
 
+def get_whonix_version():
+    """
+    Obtain Whonix version from /srv/formulas/base/virtual-machine-formula/qvm/whonix.jinja
+
+    In '.sls' files the whonix version is obtained with
+
+        {% import "qvm/whonix.jinja" as whonix %}
+        {{ whonix.whonix_version }}
+
+    This function extracts this value so that it can be on this script.
+    """
+
+    # "caching" because qubesctl takes several seconds
+    global whonix_version  # noqa: PLW0603
path: sdw_util/Util.py
user: login: deeplow
body: Force-pushed to replace the 'ugly caching' commit. This is much better.
created_at: 2025-05-01T18:46:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -148,6 +150,45 @@ def get_qubes_version():
     return version
 
 
+def get_whonix_version():
+    """
+    Obtain Whonix version from /srv/formulas/base/virtual-machine-formula/qvm/whonix.jinja
+
+    In '.sls' files the whonix version is obtained with
+
+        {% import "qvm/whonix.jinja" as whonix %}
+        {{ whonix.whonix_version }}
+
+    This function extracts this value so that it can be on this script.
+    """
+
+    # "caching" because qubesctl takes several seconds
+    global whonix_version  # noqa: PLW0603
path: sdw_util/Util.py
user: login: deeplow
body: This caching mechanism will need some more thinking. It should be conditional on the hash of `/srv/formulas/base/virtual-machine-formula/qvm/whonix.jinja`. If it changes, we want to check again. Otherwise, during an upgrade, we could still be using the old value.
created_at: 2025-05-01T21:26:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -148,6 +150,45 @@ def get_qubes_version():
     return version
 
 
+def get_whonix_version():
+    """
+    Obtain Whonix version from /srv/formulas/base/virtual-machine-formula/qvm/whonix.jinja
+
+    In '.sls' files the whonix version is obtained with
+
+        {% import "qvm/whonix.jinja" as whonix %}
+        {{ whonix.whonix_version }}
+
+    This function extracts this value so that it can be on this script.
+    """
+
+    # "caching" because qubesctl takes several seconds
+    global whonix_version  # noqa: PLW0603
path: sdw_util/Util.py
user: login: legoktm
body: Hmm. Why don't we just drop it then? Yes, it'll be a little slower, but we can figure out the performance optimizations once we've gotten the functionality working.
created_at: 2025-05-02T14:22:39Z
author_association: MEMBER
diff_hunk: @@ -148,6 +150,45 @@ def get_qubes_version():
     return version
 
 
+def get_whonix_version():
+    """
+    Obtain Whonix version from /srv/formulas/base/virtual-machine-formula/qvm/whonix.jinja
+
+    In '.sls' files the whonix version is obtained with
+
+        {% import "qvm/whonix.jinja" as whonix %}
+        {{ whonix.whonix_version }}
+
+    This function extracts this value so that it can be on this script.
+    """
+
+    # "caching" because qubesctl takes several seconds
+    global whonix_version  # noqa: PLW0603
path: sdw_util/Util.py
user: login: deeplow
body: Yup. Dropping it is better. But I have one improvement: make it so it doesn't get calculated on import and rather calculate it when it is needed.
created_at: 2025-05-06T19:07:09Z
author_association: CONTRIBUTOR

PR comments:
user: login: deeplow
created_at: 2025-05-01T20:35:29Z
body: CI failed on an unrelated error, it feels:
```
INFO:[2025-05-01-19:22:25:335206]        Comment: An error was encountered while installing package(s): E: Release file for tor+https://deb.debian.org/debian-security/dists/bookworm-security/InRelease is not valid yet (invalid for another 35s). Updates for this repository will not be applied.
```

However, the parent issue (fully resolved by this PR) should have fixed this, according to @rocodes. So I think the whonix timing issue is due to something else.
author_association: CONTRIBUTOR
user: login: deeplow
created_at: 2025-05-01T21:43:49Z
body: Still a few things missing:
- [x] fixing launcher tests
- [ ] potentially launcher tests in dom0 (so that util can be fully tested to ensure the whonix version can be obtained) (needs `sudo qubes-dom0-update python3-pytest python3-pytest-xvfb`)
  - add extra Util test: (or equivalent)
     ```python3
     @pytest.mark.skipif(
         platform.uname().node == "dom0",
         reason="Requires running in dom0"
     )
     @mock.patch("subprocess.check_output", side_effect="17")
     def test_get_whonix_version():
         assert Util.get_whonix_version == 17
     ```
- [ ] intelligent `whonix_version` result caching to ensure that during an upgrade this is not affected
- [ ] confirm that dom0 tests / CI works
author_association: CONTRIBUTOR
user: login: deeplow
created_at: 2025-05-06T19:20:34Z
body: >  potentially launcher tests in dom0 (so that util can be fully tested to ensure the whonix version can be obtained) (needs sudo qubes-dom0-update python3-pytest python3-pytest-xvfb) 

Covered this in dom0 testing

>  intelligent whonix_version result caching to ensure that during an upgrade this is not affected

dropped this idea and removed this from being calculated on import and actually call it when used.
author_association: CONTRIBUTOR
user: login: deeplow
created_at: 2025-05-06T21:04:58Z
body: Some back and forth to fix CI. But this is annoying

<details><summary>local lint gives me this</summary>

```
launcher/tests/test_updater.py:137:45: RUF100 [*] Unused `noqa` directive (unused: `S602`)
    |
135 |         mock.patch(
136 |             "sdw_updater.Updater._start_qubes_updater_proc",
137 |             return_value=subprocess.Popen(  # noqa: S602
    |                                             ^^^^^^^^^^^^ RUF100
138 |                 f"echo '{stderr}' >> /dev/stderr",
139 |                 shell=True,
    |
    = help: Remove unused `noqa` directive

launcher/tests/test_updater.py:139:17: S602 `subprocess` call with `shell=True` identified, security issue
    |
137 |             return_value=subprocess.Popen(  # noqa: S602
138 |                 f"echo '{stderr}' >> /dev/stderr",
139 |                 shell=True,
    |                 ^^^^^^^^^^ S602
140 |                 stdout=subprocess.PIPE,
141 |                 stderr=subprocess.PIPE,
    |
```
</details>


<details><summary>But remote CI gives me this</summary>

```
poetry run ruff check . --output-format=full
launcher/tests/test_updater.py:137:26: S602 `subprocess` call with `shell=True` identified, security issue
    |
135 |         mock.patch(
136 |             "sdw_updater.Updater._start_qubes_updater_proc",
137 |             return_value=subprocess.Popen(
    |                          ^^^^^^^^^^^^^^^^ S602
138 |                 f"echo '{stderr}' >> /dev/stderr",
139 |                 shell=True,  # noqa: S602
    |

launcher/tests/test_updater.py:139:30: RUF100 [*] Unused `noqa` directive (unused: `S602`)
    |
137 |             return_value=subprocess.Popen(
138 |                 f"echo '{stderr}' >> /dev/stderr",
139 |                 shell=True,  # noqa: S602
    |                              ^^^^^^^^^^^^ RUF100
140 |                 stdout=subprocess.PIPE,
141 |                 stderr=subprocess.PIPE,
    |
    = help: Remove unused `noqa` directive
```
</details>
author_association: CONTRIBUTOR
user: login: deeplow
created_at: 2025-05-06T21:05:54Z
body: Probably some outdated version issue. I'll blindly trust remote CI since that's the final ack :/ Or maybe it's too late here and my logic is not 100%.
author_association: CONTRIBUTOR
user: login: deeplow
created_at: 2025-05-06T21:06:14Z
body: In any case @legoktm this should be ready for re-review.
author_association: CONTRIBUTOR
user: login: deeplow
created_at: 2025-05-07T14:25:55Z
body: I'll push again to rearrange commits and fix CI.
author_association: CONTRIBUTOR
user: login: deeplow
created_at: 2025-05-07T15:20:47Z
body: The latest changes are:
- Rebased to pick up https://github.com/freedomofpress/securedrop-client/issues/2425
- Dropped caching commit (was later removed anyways)
author_association: CONTRIBUTOR
user: login: deeplow
created_at: 2025-05-08T10:51:43Z
body: > sd-ci-runnerWaiting for status to be reported — The build is queued

Something is up with the CI. It's been running for too long.
author_association: CONTRIBUTOR
user: login: legoktm
created_at: 2025-05-08T13:12:46Z
body: > Some back and forth to fix CI. But this is annoying

You might need to run `poetry install --sync` to pick up the latest version of ruff.

> Something is up with the CI. It's been running for too long.

If it's stuck in the "queued" state, that means it crashed before the job even started running (which does happen sometimes). The solution is to amend and repush it. But...
author_association: MEMBER
user: login: deeplow
created_at: 2025-05-08T15:46:06Z
body: >>  Something is up with the CI. It's been running for too long.
>
> If it's stuck in the "queued" state, that means it crashed before the job even started running (which does happen sometimes). The solution is to amend and repush it. But...

We should have let it rerun. Looks like this had a minor issue. I though I had fixed this in one of the last pushes, but apparently not... It is failing in the [backport PR](https://github.com/freedomofpress/securedrop-workstation/pull/1304).


```
INFO:[2025-05-08-15:30:25:930148] ======================================================================
INFO:[2025-05-08-15:30:25:930195] FAIL: test_sd_whonix_config (test_vms_exist.SD_VM_Tests.test_sd_whonix_config)
INFO:[2025-05-08-15:30:25:930240] ----------------------------------------------------------------------
INFO:[2025-05-08-15:30:25:930287] Traceback (most recent call last):
INFO:[2025-05-08-15:30:25:930333]   File "/home/user/securedrop-workstation/tests/test_vms_exist.py", line 107, in test_sd_whonix_config
INFO:[2025-05-08-15:30:25:930382]     self.assertEqual(
INFO:[2025-05-08-15:30:25:930441] AssertionError: 17 != '17'
```
author_association: CONTRIBUTOR
user: login: legoktm
created_at: 2025-05-08T16:27:55Z
body: Let's have a follow-up PR for that after we get rc1 out? At least it's a clear enough failure that we can verify the intent of the test worked.
author_association: MEMBER