diff --git a/.github/npm_test_integration.sh b/.github/npm_test_integration.sh new file mode 100755 index 00000000..a2aad96c --- /dev/null +++ b/.github/npm_test_integration.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +OUR_NIX=$(readlink -f $(which nix)) +OUR_NIX_PARENT=$(dirname $OUR_NIX) +TEE=$(which tee) +OLD_PATH=$PATH +export PATH=$OUR_NIX_PARENT +$OUR_NIX develop .#forNpmTesting --command npm run test:integration "$@" 2>&1 | $TEE output.log +export PATH=$OLD_PATH +PLAYWRIGHT_EXIT_CODE=$? +if grep -q -e "Error: A snapshot doesn't exist at" -e "Screenshot comparison failed" output.log; then + echo "Playwright tests failed due to a snapshot issue" + echo "SNAPSHOT_DIFFERENCES=true" >> $GITHUB_ENV +fi +if grep -q -E -e "npx playwright install" -e "error: attribute '\"[[:digit:]]+\.[[:digit:]]+\.[[:digit:]]+\"' missing" output.log; then + echo "Playwright tests failed due to missing browsers" + echo "MISSING_BROWSERS=true" >> $GITHUB_ENV +fi +if grep -q -E -e "[[:digit:]]+ failed" -e "was not able to start" output.log; then + echo "Playwright tests failed" + exit 1 +fi +exit $PLAYWRIGHT_EXIT_CODE diff --git a/.github/workflows/approve-playwright-snapshots.yml b/.github/workflows/approve-playwright-snapshots.yml index a07ea491..63d75175 100644 --- a/.github/workflows/approve-playwright-snapshots.yml +++ b/.github/workflows/approve-playwright-snapshots.yml @@ -78,6 +78,6 @@ jobs: with: message: | ### 🎉 Successfully updated and committed Playwright snapshots! 🎉 - comment-tag: playwright-snapshots-update-success + comment-tag: playwright-snapshots-update-success mode: recreate github-token: ${{ steps.generate-token.outputs.token }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0b9f9ba2..233b12e4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -90,28 +90,7 @@ jobs: script: | set +e # revert the default `set -e` export THYMIS_FLAKE_ROOT='..' - cd frontend - OUR_NIX=$(readlink -f $(which nix)) - OUR_NIX_PARENT=$(dirname $OUR_NIX) - TEE=$(which tee) - OLD_PATH=$PATH - export PATH=$OUR_NIX_PARENT - $OUR_NIX develop .#forNpmTesting --command npm run test 2>&1 | $TEE output.log - export PATH=$OLD_PATH - PLAYWRIGHT_EXIT_CODE=$? - if grep -q -e "Error: A snapshot doesn't exist at" -e "Screenshot comparison failed" output.log; then - echo "Playwright tests failed due to a snapshot issue" - echo "SNAPSHOT_DIFFERENCES=true" >> $GITHUB_ENV - fi - if grep -q -E -e "npx playwright install" -e "error: attribute '\"[[:digit:]]+\.[[:digit:]]+\.[[:digit:]]+\"' missing" output.log; then - echo "Playwright tests failed due to missing browsers" - echo "MISSING_BROWSERS=true" >> $GITHUB_ENV - fi - if grep -q -E -e "[[:digit:]]+ failed" -e "was not able to start" output.log; then - echo "Playwright tests failed" - exit 1 - fi - exit $PLAYWRIGHT_EXIT_CODE + ../.github/npm_test_integration.sh working-directory: frontend - uses: actions/upload-artifact@v4 id: artifact-upload @@ -209,28 +188,113 @@ jobs: script: | set +e # revert the default `set -e` # export THYMIS_FLAKE_ROOT='..' # stable input needs THYMIS_FLAKE_ROOT to be unset - cd frontend - OUR_NIX=$(readlink -f $(which nix)) - OUR_NIX_PARENT=$(dirname $OUR_NIX) - TEE=$(which tee) - OLD_PATH=$PATH - export PATH=$OUR_NIX_PARENT - $OUR_NIX develop .#forNpmTesting --command npm run test 2>&1 | $TEE output.log - export PATH=$OLD_PATH - PLAYWRIGHT_EXIT_CODE=$? - if grep -q -e "Error: A snapshot doesn't exist at" -e "Screenshot comparison failed" output.log; then - echo "Playwright tests failed due to a snapshot issue" - echo "SNAPSHOT_DIFFERENCES=true" >> $GITHUB_ENV - fi - if grep -q -E -e "npx playwright install" -e "error: attribute '\"[[:digit:]]+\.[[:digit:]]+\.[[:digit:]]+\"' missing" output.log; then - echo "Playwright tests failed due to missing browsers" - echo "MISSING_BROWSERS=true" >> $GITHUB_ENV - fi - if grep -q -E -e "[[:digit:]]+ failed" -e "was not able to start" output.log; then - echo "Playwright tests failed" - exit 1 - fi - exit $PLAYWRIGHT_EXIT_CODE + ../.github/npm_test_integration.sh + working-directory: frontend + - uses: actions/upload-artifact@v4 + id: artifact-upload + if: always() + with: + name: playwright-report-stable-input + path: frontend/playwright-report/ + retention-days: 30 + - name: Comment on PR with report link + uses: thollander/actions-comment-pull-request@v3 + if: ${{ always() && github.event_name == 'pull_request' && env.SNAPSHOT_DIFFERENCES == 'true' }} + with: + message: | + ### Playwright visual snapshot differences were detected. + View the [Playwright report](${{ steps.artifact-upload.outputs.artifact-url }}) to review the visual differences. + **To approve the snapshot changes and update the snapshots, please comment:** /approve-snapshots + comment-tag: playwright-snapshots + mode: recreate + + test-frontend-integration-x64: + runs-on: ubuntu-latest + needs: changes + if: ${{ ! (needs.changes.outputs.package-lock-json == 'true' && github.actor == 'renovate[bot]' && github.event_name == 'pull_request') }} + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup-nix + with: + attic_token: ${{ secrets.ATTIC_TOKEN }} + github_token: ${{ secrets.GITHUB_TOKEN }} + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + - name: Install dependencies + run: | + npm ci + working-directory: frontend + - name: Build application + uses: ./.github/actions/run-command-with-nix-cache-upload + with: + attic_token: ${{ secrets.ATTIC_TOKEN }} + github_token: ${{ secrets.GITHUB_TOKEN }} + script: | + nix build .#thymis-controller --print-build-logs + - name: Run tests + uses: ./.github/actions/run-command-with-nix-cache-upload + with: + attic_token: ${{ secrets.ATTIC_TOKEN }} + github_token: ${{ secrets.GITHUB_TOKEN }} + script: | + set +e # revert the default `set -e` + export THYMIS_FLAKE_ROOT='..' + ../.github/npm_test_integration.sh -- tests/x86_vm.spec.ts + working-directory: frontend + - uses: actions/upload-artifact@v4 + id: artifact-upload + if: always() + with: + name: playwright-report + path: frontend/playwright-report/ + retention-days: 30 + - name: Comment on PR with report link + uses: thollander/actions-comment-pull-request@v3 + if: ${{ always() && github.event_name == 'pull_request' && env.SNAPSHOT_DIFFERENCES == 'true' }} + with: + message: | + ### Playwright visual snapshot differences were detected. + View the [Playwright report](${{ steps.artifact-upload.outputs.artifact-url }}) to review the visual differences. + **To approve the snapshot changes and update the snapshots, please comment:** /approve-snapshots + comment-tag: playwright-snapshots + mode: recreate + + test-frontend-integration-stable-input-x64: + runs-on: ubuntu-latest + needs: changes + if: ${{ ! (needs.changes.outputs.package-lock-json == 'true' && github.actor == 'renovate[bot]' && github.event_name == 'pull_request') }} + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup-nix + with: + attic_token: ${{ secrets.ATTIC_TOKEN }} + github_token: ${{ secrets.GITHUB_TOKEN }} + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + - name: Install dependencies + run: | + npm ci + working-directory: frontend + - name: Build application + uses: ./.github/actions/run-command-with-nix-cache-upload + with: + attic_token: ${{ secrets.ATTIC_TOKEN }} + github_token: ${{ secrets.GITHUB_TOKEN }} + script: | + nix build .#thymis-controller --print-build-logs + - name: Run tests + uses: ./.github/actions/run-command-with-nix-cache-upload + with: + attic_token: ${{ secrets.ATTIC_TOKEN }} + github_token: ${{ secrets.GITHUB_TOKEN }} + script: | + set +e # revert the default `set -e` + # export THYMIS_FLAKE_ROOT='..' # stable input needs THYMIS_FLAKE_ROOT to be unset + ../.github/npm_test_integration.sh -- tests/x86_vm.spec.ts working-directory: frontend - uses: actions/upload-artifact@v4 id: artifact-upload diff --git a/controller/thymis_controller/config.py b/controller/thymis_controller/config.py index 3aefc829..6d2f0a0b 100644 --- a/controller/thymis_controller/config.py +++ b/controller/thymis_controller/config.py @@ -9,6 +9,7 @@ class GlobalSettings(BaseSettings): ALEMBIC_INI_PATH: str = f"{pathlib.Path(__file__).parent.parent}/alembic.ini" BASE_URL: str = "http://localhost:8000" + AGENT_ACCESS_URL: str | None = None FRONTEND_BINARY_PATH: str | None = None AUTH_BASIC: bool = True diff --git a/controller/thymis_controller/main.py b/controller/thymis_controller/main.py index 14aeb094..ce453c36 100644 --- a/controller/thymis_controller/main.py +++ b/controller/thymis_controller/main.py @@ -155,7 +155,7 @@ async def lifespan(app: FastAPI): notification_manager.start() host, port = detect_host_port() db_engine = create_sqlalchemy_engine() - network_relay = NetworkRelay(db_engine) + network_relay = NetworkRelay(db_engine, notification_manager) task_controller = TaskController( f"ws://{host}:{port}/agent/relay_for_clients", network_relay ) diff --git a/controller/thymis_controller/modules/thymis.py b/controller/thymis_controller/modules/thymis.py index 11641cb6..0f5a14da 100644 --- a/controller/thymis_controller/modules/thymis.py +++ b/controller/thymis_controller/modules/thymis.py @@ -349,9 +349,9 @@ class ThymisDevice(modules.Module): en="Thymis Controller URL", de="Thymis Controller-URL", ), - nix_attr_name="thymis.config.agent.controller-url", + # nix_attr_name="thymis.config.agent.controller-url", type="string", - default="", + default=global_settings.AGENT_ACCESS_URL or global_settings.BASE_URL or "", description=modules.LocalizedString( en="URL of this Thymis Controller instance", de="URL dieser Thymis Controller-Instanz", @@ -625,6 +625,12 @@ def write_nix_settings( else self.timezone.default ) + agent_controller_url = ( + module_settings.settings["agent_controller_url"] + if "agent_controller_url" in module_settings.settings + else self.agent_controller_url.default + ) + f.write(" imports = [\n") if write_target_type == "hosts": @@ -638,6 +644,11 @@ def write_nix_settings( f.write(" ];\n") + if agent_controller_url: + f.write( + f" thymis.config.agent.controller-url = lib.mkOverride {priority} {convert_python_value_to_nix(agent_controller_url)};\n" + ) + if authorized_keys: keys = list( map(lambda x: x["key"] if "key" in x else None, authorized_keys) @@ -645,8 +656,8 @@ def write_nix_settings( else: keys = [] - if project.public_key: - keys.append(project.public_key) + # if project.public_key: + # keys.append(project.public_key) if len(keys) > 0: key_list_nix = convert_python_value_to_nix(keys, ident=1) diff --git a/controller/thymis_controller/network_relay.py b/controller/thymis_controller/network_relay.py index 10f4cb5a..e05fdbdf 100644 --- a/controller/thymis_controller/network_relay.py +++ b/controller/thymis_controller/network_relay.py @@ -16,6 +16,7 @@ from thymis_controller.config import global_settings if TYPE_CHECKING: + from thymis_controller.notifications import NotificationManager from thymis_controller.task.controller import TaskController logger = logging.getLogger(__name__) @@ -75,7 +76,7 @@ class NetworkRelay(nr.NetworkRelay): CustomAgentToRelayMessage = agent.AgentToRelayMessage CustomAgentToRelayStartMessage = agent.EdgeAgentToRelayStartMessage - def __init__(self, db_engine): + def __init__(self, db_engine, notification_manager): super().__init__() self.db_engine = db_engine self.public_key_to_connection_id = {} @@ -84,6 +85,7 @@ def __init__(self, db_engine): str, agent.EdgeAgentToRelayStartMessage ] = {} self.task_controller: Optional["TaskController"] = None + self.notification_manager: "NotificationManager" = notification_manager async def handle_custom_agent_message(self, message: agent.AgentToRelayMessage): match message.inner: @@ -173,12 +175,12 @@ async def accept_ws_and_start_msg_loop_for_edge_agents( self, edge_agent_connection: WebSocket, ): - ( - msg_loop, - connection_id, - ) = await super().accept_ws_and_start_msg_loop_for_edge_agents( + res = await super().accept_ws_and_start_msg_loop_for_edge_agents( edge_agent_connection ) + if res is None: + return + msg_loop, connection_id = res # we can establish ssh connections here # check that the public key is valid @@ -281,6 +283,10 @@ async def msg_loop_but_close_connection_at_end(): del self.connection_id_to_public_key[connection_id] del self.connection_id_to_start_message[connection_id] + self.notification_manager.broadcast_invalidate_notification( + ["/api/connected_deployment_infos_by_config_id"] + ) + return msg_loop_but_close_connection_at_end(), connection_id async def get_access_client_permission( diff --git a/controller/thymis_controller/notifications.py b/controller/thymis_controller/notifications.py index d910c3ce..efb48d0d 100644 --- a/controller/thymis_controller/notifications.py +++ b/controller/thymis_controller/notifications.py @@ -3,19 +3,23 @@ import threading import traceback from queue import Queue +from typing import Literal, Union from fastapi import WebSocket, WebSocketDisconnect from fastapi.websockets import WebSocketState +from pydantic import BaseModel, Field + +NotificationDataInner = Union["ShouldInvalidate", "FrontendToast"] class Notification: - message: str + data: "NotificationData" creation_time: datetime.datetime last_try: datetime.datetime send_to: list[WebSocket] - def __init__(self, message: str): - self.message = message + def __init__(self, message: NotificationDataInner): + self.data = NotificationData(inner=message) self.creation_time = datetime.datetime.now() self.last_try = datetime.datetime.max self.send_to = [] @@ -29,6 +33,20 @@ def can_retry(self): return now - self.creation_time < datetime.timedelta(seconds=5) +class NotificationData(BaseModel): + inner: NotificationDataInner = Field(discriminator="kind") + + +class ShouldInvalidate(BaseModel): + kind: Literal["should_invalidate"] = "should_invalidate" + should_invalidate_paths: list[str] + + +class FrontendToast(BaseModel): + kind: Literal["frontend_toast"] = "frontend_toast" + message: str + + class NotificationManager: queue: Queue[Notification] = Queue() retry_queue: Queue[Notification] = Queue() @@ -95,8 +113,8 @@ def is_connection_healthy(self, websocket: WebSocket): websocket.client_state, ) - def broadcast(self, message: str): - self.queue.put(Notification(message)) + def broadcast_toast_notification(self, message: str): + self.queue.put(Notification(FrontendToast(message=message))) async def _broadcast(self, message: Notification): for connection in self.active_connections: @@ -105,8 +123,11 @@ async def _broadcast(self, message: Notification): if not self.is_connection_healthy(connection): continue try: - await connection.send_json({"message": message.message}) + await connection.send_text(message.data.model_dump_json()) message.send_to.append(connection) except Exception: self.disconnect(connection) traceback.print_exc() + + def broadcast_invalidate_notification(self, paths: list[str]): + self.queue.put(Notification(ShouldInvalidate(should_invalidate_paths=paths))) diff --git a/controller/thymis_controller/task/executor.py b/controller/thymis_controller/task/executor.py index 0f3c0044..3659a307 100644 --- a/controller/thymis_controller/task/executor.py +++ b/controller/thymis_controller/task/executor.py @@ -226,9 +226,6 @@ def listen_child_messages(self, conn: Connection, task_id: uuid.UUID): new_task = self.controller.submit( new_task_submission, db_session ) - if task.children is None: - task.children = [] - task.children.append(new_task.id) db_session.commit() case ( models_task.AgentShouldSwitchToNewConfigurationUpdate() diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 6541ccdf..fffc3d36 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -31,7 +31,9 @@ const commandFrame = (cmd) => { THYMIS_PROJECT_PATH: '$TMPDIR', THYMIS_AUTH_BASIC_PASSWORD_FILE: '$TMPDIR/auth-basic-password', - RUNNING_IN_PLAYWRIGHT: 'true' + RUNNING_IN_PLAYWRIGHT: 'true', + THYMIS_AGENT_ACCESS_URL: 'http://10.0.2.2:8000', + UVICORN_HOST: '0.0.0.0' }, withContentInFile('testadminpassword', '$THYMIS_AUTH_BASIC_PASSWORD_FILE', cmd) ) diff --git a/frontend/src/lib/notification.ts b/frontend/src/lib/notification.ts index d645cb5a..47b70957 100644 --- a/frontend/src/lib/notification.ts +++ b/frontend/src/lib/notification.ts @@ -1,6 +1,17 @@ +import { invalidate } from '$app/navigation'; import { toast } from '@zerodevx/svelte-toast'; export type Notification = { + inner: ShouldInvalidate | FrontendToast; +}; + +export type ShouldInvalidate = { + kind: 'should_invalidate'; + should_invalidate_paths: string[]; +}; + +export type FrontendToast = { + kind: 'frontend_toast'; message: string; }; @@ -9,8 +20,17 @@ let socket: WebSocket | undefined; export const startNotificationSocket = () => { const scheme = window.location.protocol === 'https:' ? 'wss' : 'ws'; socket = new WebSocket(`${scheme}://${window.location.host}/api/notification`); - socket.onmessage = (event) => { - const notification = JSON.parse(event.data); - toast.push(notification.message, { pausable: true }); + socket.onmessage = async (event) => { + const notification = JSON.parse(event.data) as Notification; + if (notification.inner.kind === 'frontend_toast') { + toast.push(notification.inner.message, { pausable: true }); + } else if (notification.inner.kind === 'should_invalidate') { + const paths = notification.inner.should_invalidate_paths; + await invalidate((url: URL) => { + return paths.some((path: string) => url.pathname.startsWith(path)); + }); + } else { + const _: never = notification.inner; + } }; }; diff --git a/frontend/src/routes/(authenticated)/configuration/list/CreateDeviceModal.svelte b/frontend/src/routes/(authenticated)/configuration/list/CreateDeviceModal.svelte index afb6cbb0..50b04c95 100644 --- a/frontend/src/routes/(authenticated)/configuration/list/CreateDeviceModal.svelte +++ b/frontend/src/routes/(authenticated)/configuration/list/CreateDeviceModal.svelte @@ -45,7 +45,6 @@ 'sd-card-image', device_name: identifier, nix_state_version: '24.11', - agent_controller_url: `${window.location.protocol}//${window.location.host}`, agent_enabled: true }; const device: Device = { diff --git a/frontend/tests/x86_vm.spec.ts b/frontend/tests/x86_vm.spec.ts new file mode 100644 index 00000000..0d23d9f1 --- /dev/null +++ b/frontend/tests/x86_vm.spec.ts @@ -0,0 +1,79 @@ +import { test, expect, type Page } from '../playwright/fixtures'; +import { clearState, deleteAllTasks } from './utils'; +import * as os from 'os'; + +test.skip(os.arch() !== 'x64', 'You can only run this suite in an x86 VM'); + +const colorSchemes = ['light', 'dark'] as const; + +const createConfiguration = async ( + page: Page, + name: string, + deviceType: string, + tags: string[] +) => { + await page.goto('/configuration/list'); + + const addConfigurationButton = page + .locator('button') + .filter({ hasText: 'Create New Configuration' }); + await addConfigurationButton.click(); + + const displayNameInput = page.locator('#display-name').first(); + await displayNameInput.fill(name); + + const deviceTypeSelect = page.locator('#device-type').first(); + await deviceTypeSelect.selectOption({ label: deviceType }); + + if (tags.length > 0) { + const tagsMultiSelect = page.locator('input[autocomplete]'); + await tagsMultiSelect.click(); + + // for each tag, input and enter + for (const tag of tags) { + await page.getByRole('option', { name: tag }).click(); + } + } + + await page.getByRole('heading', { name: 'Create a new device' }).click(); + + const saveButton = page.locator('button').filter({ hasText: 'Create device configuration' }); + await saveButton.click(); +}; + +colorSchemes.forEach((colorScheme) => { + test.describe(`Color scheme: ${colorScheme}`, () => { + test.use({ colorScheme: colorScheme }); + test('Create a x64 vm and run it', async ({ page, request }) => { + await clearState(page, request); + await deleteAllTasks(page, request); + + await createConfiguration(page, 'VM Test x64', 'Generic x86-64', []); + + await page.goto('/configuration/list'); + + // find row with 'VM Test x64' and click on button 'View Details' + await page + .locator('tr') + .filter({ hasText: 'VM Test x64' }) + .getByRole('button', { name: 'View Details' }) + .first() + .click(); + + // select button "Build and start VM" + await page.locator('button').filter({ hasText: 'Build and start VM' }).first().click(); + + // wait until: 1x on screen "completed", 1x on screen "running" + test.setTimeout(300000); + await page.locator('td', { hasText: 'completed' }).first().waitFor({ timeout: 300000 }); + await page.locator('td', { hasText: 'running' }).first().waitFor({ timeout: 30000 }); + + // wait until "Deployed:" is shown on screen + await page.locator('p', { hasText: 'Deployed:' }).first().waitFor({ timeout: 300000 }); + + await expect(page).toHaveScreenshot({ + mask: [page.locator('.playwright-snapshot-unstable')] + }); + }); + }); +}); diff --git a/frontend/tests/x86_vm.spec.ts-snapshots/Color-scheme-dark-Create-a-x64-vm-and-run-it-1-linux.png b/frontend/tests/x86_vm.spec.ts-snapshots/Color-scheme-dark-Create-a-x64-vm-and-run-it-1-linux.png new file mode 100644 index 00000000..1532377c Binary files /dev/null and b/frontend/tests/x86_vm.spec.ts-snapshots/Color-scheme-dark-Create-a-x64-vm-and-run-it-1-linux.png differ diff --git a/frontend/tests/x86_vm.spec.ts-snapshots/Color-scheme-light-Create-a-x64-vm-and-run-it-1-linux.png b/frontend/tests/x86_vm.spec.ts-snapshots/Color-scheme-light-Create-a-x64-vm-and-run-it-1-linux.png new file mode 100644 index 00000000..25684a6c Binary files /dev/null and b/frontend/tests/x86_vm.spec.ts-snapshots/Color-scheme-light-Create-a-x64-vm-and-run-it-1-linux.png differ