From b93fb633c7dcaf0a94ad8a1c56e2dca262564a87 Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Mon, 21 Sep 2026 16:10:39 +0000 Subject: [PATCH 01/17] Instantiate copier-nuxt-python-intranet-app Switch _src_path from copier-python-package-template to copier-nuxt-python-intranet-app (v0.0.188). Copier applied cleanly with no .rej files. This commit is the raw template output only. The existing src/, tests/, pyproject.toml, pyinstaller.spec, docs/, .readthedocs.yaml and .github/.codecov.yml are untouched at the repo root and are reconciled in the commits that follow. Co-Authored-By: Claude Opus 5 (1M context) --- .coderabbit.yaml | 6 + .config/.copier-answers.yml | 38 +- .config/.copier-managed-files.json | 158 ++++- .../istanbul-ignore-if-must-throw.mjs | 138 ++++ .config/pyrefly.mutmut.toml | 3 +- .config/pyrefly.toml | 22 +- .config/ruff.toml | 2 +- .config/taskfiles/backend.yaml | 19 + .config/taskfiles/codegen.yaml | 39 + .config/taskfiles/executable-stack.yaml | 59 ++ .config/taskfiles/frontend.yaml | 24 + .config/vacuum-functions/bodyDescription.js | 72 ++ .config/vacuum-functions/bodyExample.js | 101 +++ .config/vacuum-functions/resourcesPlural.js | 77 ++ .config/vacuum-ignore.yaml | 22 + .config/vacuum-ruleset.yaml | 236 +++++++ .devcontainer/Dockerfile | 13 +- .devcontainer/code-artifact-auth.sh | 2 +- .devcontainer/devcontainer.json | 22 +- .devcontainer/docker-compose.yml | 18 +- .devcontainer/envs.json | 9 +- .github/actions/ecr-auth/action.yml | 2 +- .github/dependabot.yml | 26 +- .github/workflows/build-docker-image.yaml | 268 +++++++ .github/workflows/build-installer.yaml | 108 +++ .github/workflows/ci.yaml | 319 ++++++--- .github/workflows/git_tag.py | 4 +- .github/workflows/release.yaml | 84 ++- .gitignore | 3 + .pre-commit-config.yaml | 150 ++-- AGENTS.md | 16 +- README.md | 60 +- Taskfile.yaml | 12 + {.config => backend}/.coveragerc | 9 + backend/.python-version | 1 + backend/kiota_nullable_fixer.py | 459 ++++++++++++ backend/pyinstaller.spec | 118 ++++ backend/pyproject.toml | 85 +++ backend/src/__init__.py | 7 + backend/src/backend_api/__init__.py | 8 + backend/src/backend_api/app_def.py | 142 ++++ backend/src/backend_api/app_runner.py | 61 ++ backend/src/backend_api/camel_case_model.py | 33 + .../src/backend_api/entrypoint/__init__.py | 7 + backend/src/backend_api/entrypoint/cli.py | 46 ++ .../src/backend_api/entrypoint/crash_dump.py | 42 ++ .../backend_api/entrypoint/grant_rights.py | 54 ++ backend/src/backend_api/entrypoint/parser.py | 37 + .../entrypoint/service_install_password.py | 50 ++ .../src/backend_api/entrypoint/win_service.py | 152 ++++ .../fast_api_exception_handlers.py | 277 ++++++++ backend/src/backend_api/jinja_constants.py | 17 + backend/src/backend_api/logger_config.py | 149 ++++ .../backend_api/openapi_problem_responses.py | 70 ++ .../backend_api/openapi_schema_simplifier.py | 72 ++ backend/src/entrypoint.py | 21 + backend/src/static/.gitignore | 11 + backend/src/static/favicon.ico | Bin 0 -> 15406 bytes backend/tests/__init__.py | 7 + backend/tests/conftest.py | 15 + backend/tests/e2e/__init__.py | 7 + backend/tests/e2e/app_bootup.py | 321 +++++++++ backend/tests/e2e/conftest.py | 95 +++ .../backend/api/api_request_builder.py | 50 ++ .../healthcheck_request_builder.py | 101 +++ .../api/shutdown/shutdown_request_builder.py | 89 +++ .../open_api/backend/backend_client.py | 57 ++ .../models/h_t_t_p_validation_error.py | 64 ++ .../backend/models/healthcheck_response.py | 50 ++ .../backend/models/problem_details.py | 81 +++ .../backend/models/shutdown_response.py | 50 ++ .../backend/models/validation_error.py | 58 ++ backend/tests/e2e/jinja_constants.py | 18 + backend/tests/unit/__init__.py | 7 + .../test_openapi_schema.json | 307 ++++++++ backend/tests/unit/conftest.py | 41 ++ backend/tests/unit/entrypoint/__init__.py | 7 + backend/tests/unit/entrypoint/fixtures.py | 55 ++ .../tests/unit/entrypoint/test_arg_parsing.py | 119 ++++ .../tests/unit/entrypoint/test_crash_dump.py | 57 ++ .../tests/unit/entrypoint/test_entrypoint.py | 82 +++ .../unit/entrypoint/test_grant_rights.py | 73 ++ .../test_service_install_password.py | 75 ++ .../entrypoint/test_service_subcommand.py | 38 + .../unit/entrypoint/test_stopping_behavior.py | 120 ++++ backend/tests/unit/snapshot.py | 51 ++ backend/tests/unit/spy_helpers.py | 14 + .../unit/test_basic_server_functionality.py | 84 +++ backend/tests/unit/test_camel_case_model.py | 27 + .../unit/test_fast_api_exception_handlers.py | 189 +++++ .../unit/test_openapi_problem_responses.py | 81 +++ backend/tests/windows_service/__init__.py | 7 + backend/tests/windows_service/conftest.py | 16 + .../windows_service/test_service_lifecycle.py | 275 ++++++++ cloud-courier.code-workspace | 41 ++ deployment/deploy.bat | 62 ++ deployment/docker-compose.yaml | 35 + frontend/app/app.config.ts | 27 + frontend/app/assets/css/main.css | 11 + frontend/app/layouts/default.vue | 14 + frontend/app/pages/index.vue | 12 + frontend/eslint.config.mjs | 14 + frontend/eslint.shared.mjs | 158 +++++ frontend/kiota_nullable_fixer.py | 665 ++++++++++++++++++ frontend/nuxt.config.ts | 89 +++ frontend/package.json | 67 ++ frontend/playwright.config.ts | 52 ++ frontend/scripts/postinstall.mjs | 31 + frontend/tests/compiled/index.spec.ts | 25 + .../backend-behavior.spec.ts | 19 + frontend/tests/e2e/compose.ts | 92 +++ frontend/tests/e2e/fixtures.ts | 106 +++ frontend/tests/e2e/global-setup.ts | 88 +++ frontend/tests/e2e/global-teardown.ts | 69 ++ frontend/tests/e2e/helpers/vrt-masks.ts | 32 + frontend/tests/e2e/helpers/vrt.ts | 119 ++++ frontend/tests/e2e/index.spec.ts | 18 + frontend/tests/e2e/ports.ts | 77 ++ frontend/tests/setup/auto-unmount.ts | 14 + frontend/tests/setup/bun-test-stub.ts | 14 + frontend/tests/setup/constants.ts | 12 + frontend/tests/setup/faker.ts | 19 + .../istanbul-ignore-if-must-throw.spec.ts | 426 +++++++++++ .../unit/pages/default-layout.nuxt.spec.ts | 23 + frontend/tests/unit/pages/index.nuxt.spec.ts | 21 + frontend/tsconfig.json | 7 + frontend/types/nuxt.config.d.ts | 23 + frontend/vitest.config.ts | 128 ++++ installer/.gitignore | 9 + installer/ARCHITECTURE.md | 75 ++ installer/INSTALL.txt | 118 ++++ installer/README.md | 76 ++ installer/license.rtf | 6 + installer/package.ps1 | 196 ++++++ installer/scripts/install-service.ps1 | 117 +++ installer/scripts/remove-service.ps1 | 49 ++ installer/stage.ps1 | 51 ++ installer/wix/Product.wxs | 200 ++++++ pnpm-workspace.yaml | 23 + pytest.ini | 4 +- 140 files changed, 10129 insertions(+), 253 deletions(-) create mode 100644 .config/eslint-rules/istanbul-ignore-if-must-throw.mjs create mode 100644 .config/taskfiles/backend.yaml create mode 100644 .config/taskfiles/codegen.yaml create mode 100644 .config/taskfiles/executable-stack.yaml create mode 100644 .config/taskfiles/frontend.yaml create mode 100644 .config/vacuum-functions/bodyDescription.js create mode 100644 .config/vacuum-functions/bodyExample.js create mode 100644 .config/vacuum-functions/resourcesPlural.js create mode 100644 .config/vacuum-ignore.yaml create mode 100644 .config/vacuum-ruleset.yaml create mode 100644 .github/workflows/build-docker-image.yaml create mode 100644 .github/workflows/build-installer.yaml rename {.config => backend}/.coveragerc (75%) create mode 100644 backend/.python-version create mode 100644 backend/kiota_nullable_fixer.py create mode 100644 backend/pyinstaller.spec create mode 100644 backend/pyproject.toml create mode 100644 backend/src/__init__.py create mode 100644 backend/src/backend_api/__init__.py create mode 100644 backend/src/backend_api/app_def.py create mode 100644 backend/src/backend_api/app_runner.py create mode 100644 backend/src/backend_api/camel_case_model.py create mode 100644 backend/src/backend_api/entrypoint/__init__.py create mode 100644 backend/src/backend_api/entrypoint/cli.py create mode 100644 backend/src/backend_api/entrypoint/crash_dump.py create mode 100644 backend/src/backend_api/entrypoint/grant_rights.py create mode 100644 backend/src/backend_api/entrypoint/parser.py create mode 100644 backend/src/backend_api/entrypoint/service_install_password.py create mode 100644 backend/src/backend_api/entrypoint/win_service.py create mode 100644 backend/src/backend_api/fast_api_exception_handlers.py create mode 100644 backend/src/backend_api/jinja_constants.py create mode 100644 backend/src/backend_api/logger_config.py create mode 100644 backend/src/backend_api/openapi_problem_responses.py create mode 100644 backend/src/backend_api/openapi_schema_simplifier.py create mode 100644 backend/src/entrypoint.py create mode 100644 backend/src/static/.gitignore create mode 100644 backend/src/static/favicon.ico create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/e2e/__init__.py create mode 100644 backend/tests/e2e/app_bootup.py create mode 100644 backend/tests/e2e/conftest.py create mode 100644 backend/tests/e2e/generated/open_api/backend/api/api_request_builder.py create mode 100644 backend/tests/e2e/generated/open_api/backend/api/healthcheck/healthcheck_request_builder.py create mode 100644 backend/tests/e2e/generated/open_api/backend/api/shutdown/shutdown_request_builder.py create mode 100644 backend/tests/e2e/generated/open_api/backend/backend_client.py create mode 100644 backend/tests/e2e/generated/open_api/backend/models/h_t_t_p_validation_error.py create mode 100644 backend/tests/e2e/generated/open_api/backend/models/healthcheck_response.py create mode 100644 backend/tests/e2e/generated/open_api/backend/models/problem_details.py create mode 100644 backend/tests/e2e/generated/open_api/backend/models/shutdown_response.py create mode 100644 backend/tests/e2e/generated/open_api/backend/models/validation_error.py create mode 100644 backend/tests/e2e/jinja_constants.py create mode 100644 backend/tests/unit/__init__.py create mode 100644 backend/tests/unit/__snapshots__/test_basic_server_functionality/test_openapi_schema.json create mode 100644 backend/tests/unit/conftest.py create mode 100644 backend/tests/unit/entrypoint/__init__.py create mode 100644 backend/tests/unit/entrypoint/fixtures.py create mode 100644 backend/tests/unit/entrypoint/test_arg_parsing.py create mode 100644 backend/tests/unit/entrypoint/test_crash_dump.py create mode 100644 backend/tests/unit/entrypoint/test_entrypoint.py create mode 100644 backend/tests/unit/entrypoint/test_grant_rights.py create mode 100644 backend/tests/unit/entrypoint/test_service_install_password.py create mode 100644 backend/tests/unit/entrypoint/test_service_subcommand.py create mode 100644 backend/tests/unit/entrypoint/test_stopping_behavior.py create mode 100644 backend/tests/unit/snapshot.py create mode 100644 backend/tests/unit/spy_helpers.py create mode 100644 backend/tests/unit/test_basic_server_functionality.py create mode 100644 backend/tests/unit/test_camel_case_model.py create mode 100644 backend/tests/unit/test_fast_api_exception_handlers.py create mode 100644 backend/tests/unit/test_openapi_problem_responses.py create mode 100644 backend/tests/windows_service/__init__.py create mode 100644 backend/tests/windows_service/conftest.py create mode 100644 backend/tests/windows_service/test_service_lifecycle.py create mode 100644 cloud-courier.code-workspace create mode 100644 deployment/deploy.bat create mode 100644 deployment/docker-compose.yaml create mode 100644 frontend/app/app.config.ts create mode 100644 frontend/app/assets/css/main.css create mode 100644 frontend/app/layouts/default.vue create mode 100644 frontend/app/pages/index.vue create mode 100644 frontend/eslint.config.mjs create mode 100644 frontend/eslint.shared.mjs create mode 100644 frontend/kiota_nullable_fixer.py create mode 100644 frontend/nuxt.config.ts create mode 100644 frontend/package.json create mode 100644 frontend/playwright.config.ts create mode 100644 frontend/scripts/postinstall.mjs create mode 100644 frontend/tests/compiled/index.spec.ts create mode 100644 frontend/tests/e2e/application-behavior/backend-behavior.spec.ts create mode 100644 frontend/tests/e2e/compose.ts create mode 100644 frontend/tests/e2e/fixtures.ts create mode 100644 frontend/tests/e2e/global-setup.ts create mode 100644 frontend/tests/e2e/global-teardown.ts create mode 100644 frontend/tests/e2e/helpers/vrt-masks.ts create mode 100644 frontend/tests/e2e/helpers/vrt.ts create mode 100644 frontend/tests/e2e/index.spec.ts create mode 100644 frontend/tests/e2e/ports.ts create mode 100644 frontend/tests/setup/auto-unmount.ts create mode 100644 frontend/tests/setup/bun-test-stub.ts create mode 100644 frontend/tests/setup/constants.ts create mode 100644 frontend/tests/setup/faker.ts create mode 100644 frontend/tests/unit/eslint-rules/istanbul-ignore-if-must-throw.spec.ts create mode 100644 frontend/tests/unit/pages/default-layout.nuxt.spec.ts create mode 100644 frontend/tests/unit/pages/index.nuxt.spec.ts create mode 100644 frontend/tsconfig.json create mode 100644 frontend/types/nuxt.config.d.ts create mode 100644 frontend/vitest.config.ts create mode 100644 installer/.gitignore create mode 100644 installer/ARCHITECTURE.md create mode 100644 installer/INSTALL.txt create mode 100644 installer/README.md create mode 100644 installer/license.rtf create mode 100644 installer/package.ps1 create mode 100644 installer/scripts/install-service.ps1 create mode 100644 installer/scripts/remove-service.ps1 create mode 100644 installer/stage.ps1 create mode 100644 installer/wix/Product.wxs create mode 100644 pnpm-workspace.yaml diff --git a/.coderabbit.yaml b/.coderabbit.yaml index addc595..56d5917 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -10,6 +10,12 @@ early_access: true reviews: profile: assertive path_instructions: + - path: "**/generated/open-api/**" + instructions: "This is generated client code for the API. Refer to it if you need to understand how the main code interacts with it, but do not make comments about it." + - path: "**/generated/open_api/**" + instructions: "This is generated client code for the API. Refer to it if you need to understand how the main code interacts with it, but do not make comments about it." + - path: "**/generated/graphql.ts" + instructions: "This is generated client code for the API. Refer to it if you need to understand how the main code interacts with it, but do not make comments about it." - path: "**/vendor_files/**" instructions: "These files came from a vendor and we're not allowed to change them. Refer to it if you need to understand how the main code interacts with it, but do not make comments about it." - path: "**/*.py" diff --git a/.config/.copier-answers.yml b/.config/.copier-answers.yml index 7080775..b562f81 100644 --- a/.config/.copier-answers.yml +++ b/.config/.copier-answers.yml @@ -1,27 +1,41 @@ # Changes here will be overwritten by Copier -_commit: v0.0.76 -_src_path: gh:LabAutomationAndScreening/copier-python-package-template +_commit: v0.0.188 +_src_path: gh:LabAutomationAndScreening/copier-nuxt-python-intranet-app.git +auth_to_ecr_for_e2e_in_ci: false +backend_deployed_port_number: 4000 +backend_makes_api_calls: false +backend_rest_api_description: Agent to upload files to cloud +backend_uses_graphql: false configure_python_asyncio: false configure_vcrpy: false -create_docs: true deploy_as_executable: true description: Agent to upload files to cloud -full_repo_url: https://github.com/LabAutomationAndScreening/cloud-courier +extra_windows_service_rights: [] +frontend_uses_graphql: false +frontend_uses_zod: false +has_backend: true +human_friendly_app_name: Cloud Courier +install_as_windows_service: true install_aws_ssm_port_forwarding_plugin: false install_claude_cli: true +installer_branding_dir: frontend/public +installer_manufacturer: Lab Automation and SCreening +installer_upgrade_code: 65bd762e-bd98-4ead-ae8f-2eeee6f1d754 +is_circuit_python_driver: false is_open_source: true -package_name: cloud-courier -primary_author: Eli Fine +node_version: 24.11.1 +pnpm_minimum_release_age_exclude: '' pull_from_ecr: false -python_ci_versions: -- 3.13.9 python_package_registry: PyPI python_version: 3.13.9 repo_name: cloud-courier repo_names_for_codespaces_read_permissions: '' repo_org_name: LabAutomationAndScreening -ssh_port_number: 61335 -use_codecov: true -use_windows_in_ci: false -use_windows_in_exe_ci: true +run_backend_e2e_in_ci: false +sign_installer: false +ssh_port_number: 51779 +use_upx_for_executable: false +use_windows_in_ci: true +windows_service_bind_policy: configurable_loopback_default +windows_service_org_prefix: LabAutomationAndScreening- diff --git a/.config/.copier-managed-files.json b/.config/.copier-managed-files.json index de2050c..1f4b841 100644 --- a/.config/.copier-managed-files.json +++ b/.config/.copier-managed-files.json @@ -1,5 +1,27 @@ { "templates": [ + { + "src": "gh:LabAutomationAndScreening/copier-python-package-template", + "parent_src": "gh:LabAutomationAndScreening/copier-base-template.git", + "managed_files": [ + ".config/.copier-answers.yml", + ".devcontainer/code-artifact-auth.sh", + ".github/.codecov.yml", + ".github/actions/ecr-auth/action.yml", + ".github/workflows/release.yaml", + ".readthedocs.yaml", + "docs/Makefile", + "docs/_static/.gitkeep", + "docs/conf.py", + "docs/index.rst", + "docs/make.bat", + "docs/requirements.txt", + "pyrightconfig.json", + "src/entrypoint.py", + "tests/unit/__init__.py", + "tests/unit/conftest.py" + ] + }, { "src": "gh:LabAutomationAndScreening/copier-base-template.git", "managed_files": [ @@ -55,7 +77,6 @@ ".claude/skills/fix-mutants/utils.py", ".claude/skills/fix-mutants/verify-mutant.py", ".coderabbit.yaml", - ".config/.coveragerc", ".config/.prettierrc", ".config/_typos.toml", ".config/pylintrc.toml", @@ -103,34 +124,135 @@ "README.md", "Taskfile.yaml", "biome.jsonc", - "pyproject.toml", + "pnpm-workspace.yaml", "pytest.ini", "scripts/delete_false_positive_rej.py", - "sh.bat", - "tests/__init__.py", - "tests/conftest.py" + "sh.bat" ] }, { - "src": "gh:LabAutomationAndScreening/copier-python-package-template", + "src": "gh:LabAutomationAndScreening/copier-nuxt-python-intranet-app.git", "parent_src": "gh:LabAutomationAndScreening/copier-base-template.git", "managed_files": [ ".config/.copier-answers.yml", + ".config/eslint-rules/istanbul-ignore-if-must-throw.mjs", + ".config/taskfiles/backend.yaml", + ".config/taskfiles/codegen.yaml", + ".config/taskfiles/executable-stack.yaml", + ".config/taskfiles/frontend.yaml", + ".config/vacuum-functions/bodyDescription.js", + ".config/vacuum-functions/bodyExample.js", + ".config/vacuum-functions/resourcesPlural.js", + ".config/vacuum-ignore.yaml", + ".config/vacuum-ruleset.yaml", ".devcontainer/code-artifact-auth.sh", - ".github/.codecov.yml", ".github/actions/ecr-auth/action.yml", + ".github/workflows/build-docker-image.yaml", + ".github/workflows/build-installer.yaml", ".github/workflows/release.yaml", - ".readthedocs.yaml", - "docs/Makefile", - "docs/_static/.gitkeep", - "docs/conf.py", - "docs/index.rst", - "docs/make.bat", - "docs/requirements.txt", - "pyrightconfig.json", - "src/entrypoint.py", - "tests/unit/__init__.py", - "tests/unit/conftest.py" + "backend/.python-version", + "backend/kiota_nullable_fixer.py", + "backend/pyinstaller.spec", + "backend/pyproject.toml", + "backend/src/__init__.py", + "backend/src/backend_api/__init__.py", + "backend/src/backend_api/app_def.py", + "backend/src/backend_api/app_runner.py", + "backend/src/backend_api/camel_case_model.py", + "backend/src/backend_api/entrypoint/__init__.py", + "backend/src/backend_api/entrypoint/cli.py", + "backend/src/backend_api/entrypoint/crash_dump.py", + "backend/src/backend_api/entrypoint/grant_rights.py", + "backend/src/backend_api/entrypoint/parser.py", + "backend/src/backend_api/entrypoint/service_install_password.py", + "backend/src/backend_api/entrypoint/win_service.py", + "backend/src/backend_api/fast_api_exception_handlers.py", + "backend/src/backend_api/jinja_constants.py", + "backend/src/backend_api/logger_config.py", + "backend/src/backend_api/openapi_problem_responses.py", + "backend/src/backend_api/openapi_schema_simplifier.py", + "backend/src/entrypoint.py", + "backend/src/static/.gitignore", + "backend/src/static/favicon.ico", + "backend/tests/__init__.py", + "backend/tests/conftest.py", + "backend/tests/e2e/__init__.py", + "backend/tests/e2e/app_bootup.py", + "backend/tests/e2e/conftest.py", + "backend/tests/e2e/generated/open_api/backend/api/api_request_builder.py", + "backend/tests/e2e/generated/open_api/backend/api/healthcheck/healthcheck_request_builder.py", + "backend/tests/e2e/generated/open_api/backend/api/shutdown/shutdown_request_builder.py", + "backend/tests/e2e/generated/open_api/backend/backend_client.py", + "backend/tests/e2e/generated/open_api/backend/models/h_t_t_p_validation_error.py", + "backend/tests/e2e/generated/open_api/backend/models/healthcheck_response.py", + "backend/tests/e2e/generated/open_api/backend/models/problem_details.py", + "backend/tests/e2e/generated/open_api/backend/models/shutdown_response.py", + "backend/tests/e2e/generated/open_api/backend/models/validation_error.py", + "backend/tests/e2e/jinja_constants.py", + "backend/tests/unit/__init__.py", + "backend/tests/unit/__snapshots__/test_basic_server_functionality/test_openapi_schema.json", + "backend/tests/unit/conftest.py", + "backend/tests/unit/entrypoint/__init__.py", + "backend/tests/unit/entrypoint/fixtures.py", + "backend/tests/unit/entrypoint/test_arg_parsing.py", + "backend/tests/unit/entrypoint/test_crash_dump.py", + "backend/tests/unit/entrypoint/test_entrypoint.py", + "backend/tests/unit/entrypoint/test_grant_rights.py", + "backend/tests/unit/entrypoint/test_service_install_password.py", + "backend/tests/unit/entrypoint/test_service_subcommand.py", + "backend/tests/unit/entrypoint/test_stopping_behavior.py", + "backend/tests/unit/snapshot.py", + "backend/tests/unit/spy_helpers.py", + "backend/tests/unit/test_basic_server_functionality.py", + "backend/tests/unit/test_camel_case_model.py", + "backend/tests/unit/test_fast_api_exception_handlers.py", + "backend/tests/unit/test_openapi_problem_responses.py", + "backend/tests/windows_service/__init__.py", + "backend/tests/windows_service/conftest.py", + "backend/tests/windows_service/test_service_lifecycle.py", + "deployment/deploy.bat", + "deployment/docker-compose.yaml", + "frontend/app/app.config.ts", + "frontend/app/assets/css/main.css", + "frontend/app/layouts/default.vue", + "frontend/app/pages/index.vue", + "frontend/eslint.config.mjs", + "frontend/eslint.shared.mjs", + "frontend/kiota_nullable_fixer.py", + "frontend/nuxt.config.ts", + "frontend/package.json", + "frontend/playwright.config.ts", + "frontend/scripts/postinstall.mjs", + "frontend/tests/compiled/index.spec.ts", + "frontend/tests/e2e/application-behavior/backend-behavior.spec.ts", + "frontend/tests/e2e/compose.ts", + "frontend/tests/e2e/fixtures.ts", + "frontend/tests/e2e/global-setup.ts", + "frontend/tests/e2e/global-teardown.ts", + "frontend/tests/e2e/helpers/vrt-masks.ts", + "frontend/tests/e2e/helpers/vrt.ts", + "frontend/tests/e2e/index.spec.ts", + "frontend/tests/e2e/ports.ts", + "frontend/tests/setup/auto-unmount.ts", + "frontend/tests/setup/bun-test-stub.ts", + "frontend/tests/setup/constants.ts", + "frontend/tests/setup/faker.ts", + "frontend/tests/unit/eslint-rules/istanbul-ignore-if-must-throw.spec.ts", + "frontend/tests/unit/pages/default-layout.nuxt.spec.ts", + "frontend/tests/unit/pages/index.nuxt.spec.ts", + "frontend/tsconfig.json", + "frontend/types/nuxt.config.d.ts", + "frontend/vitest.config.ts", + "installer/.gitignore", + "installer/ARCHITECTURE.md", + "installer/INSTALL.txt", + "installer/README.md", + "installer/license.rtf", + "installer/package.ps1", + "installer/scripts/install-service.ps1", + "installer/scripts/remove-service.ps1", + "installer/stage.ps1", + "installer/wix/Product.wxs" ] } ] diff --git a/.config/eslint-rules/istanbul-ignore-if-must-throw.mjs b/.config/eslint-rules/istanbul-ignore-if-must-throw.mjs new file mode 100644 index 0000000..b668bc0 --- /dev/null +++ b/.config/eslint-rules/istanbul-ignore-if-must-throw.mjs @@ -0,0 +1,138 @@ +/* + * ============== WARNING ============================================================================== + * File is managed by copier template: gh:LabAutomationAndScreening/copier-nuxt-python-intranet-app.git + * See .config/.copier-managed-files.json for details. + * + * You are welcome to make changes to this file in your repo if they are custom to your project, + * but if the change should be shared with other projects, please backport it to the template repo. + * ===================================================================================================== + */ +/* + * Enforces the defensive-assertion contract for coverage-ignored branches: + * + * A branch marked `istanbul ignore if` (or `istanbul ignore next` when it sits on + * an `if`) must throw: per ESLint's code path analysis, the end of the branch is + * unreachable and no reachable return/break/continue inside it targets a construct + * outside it. Coverage-ignoring a guard means "this is unreachable"; a silent + * `return` there hides a real bug instead of surfacing it. If the absence is a + * legitimate state rather than a contract violation, the author opts out with + * `return-ok ` in the ignore comment (a colon after the token is optional) — + * "cannot happen" is what the throw is for, not what the escape is for. The reason is + * required rather than conventional, since a bare `return-ok` is indistinguishable + * from silencing the guard. + * Ignore comments on anything other than an `if` are left alone — the rule + * only makes a claim about branches whose shape it can verify. + */ + +const IGNORE_IF_OR_NEXT = /istanbul ignore (?:if|next)\b/; +// `return-ok` contains a hyphen, so `\b` would accept one as a boundary and read the token out of +// `not-return-ok`; the delimiter must exclude hyphens on both sides. +const RETURN_OK = /(?`. The escape claims this branch is a state the code tolerates rather than a violated invariant, and that claim is the reviewer's only evidence the guard was not simply silenced.", + }, + }, + create(context) { + const sourceCode = context.sourceCode; + const segmentStacks = []; + // Ignored ifs can nest; depth-first traversal keeps the innermost one on top. + const branchStack = []; + + function isReachable() { + return [...segmentStacks.at(-1)].some((segment) => segment.reachable); + } + + function report(entry) { + if (entry.reported) return; + entry.reported = true; + context.report({ node: entry.node, messageId: "mustThrow" }); + } + + function onSilentExit(node) { + const entry = branchStack.at(-1); + // Depth check keeps exits inside a nested function (its own code path) from counting. + if (entry === undefined || entry.depth !== segmentStacks.length) return; + if (isReachable() && leavesBranch(node, entry.consequent)) report(entry); + } + + return { + onCodePathStart() { + segmentStacks.push(new Set()); + }, + onCodePathEnd() { + segmentStacks.pop(); + }, + onCodePathSegmentStart(segment) { + segmentStacks.at(-1).add(segment); + }, + onCodePathSegmentEnd(segment) { + segmentStacks.at(-1).delete(segment); + }, + onUnreachableCodePathSegmentStart(segment) { + segmentStacks.at(-1).add(segment); + }, + onUnreachableCodePathSegmentEnd(segment) { + segmentStacks.at(-1).delete(segment); + }, + IfStatement(node) { + const leading = sourceCode.getCommentsBefore(node); + const ignoreComment = leading.find((comment) => IGNORE_IF_OR_NEXT.test(comment.value)); + if (ignoreComment === undefined) return; + if (RETURN_OK.test(ignoreComment.value)) { + if (RETURN_OK_WITH_REASON.test(ignoreComment.value)) return; + context.report({ node, messageId: "escapeNeedsReason" }); + return; + } + branchStack.push({ node, consequent: node.consequent, depth: segmentStacks.length, reported: false }); + }, + ":statement:exit"(node) { + if (node !== branchStack.at(-1)?.consequent) return; + const entry = branchStack.pop(); + // ESLint applies a throw/return/break/continue to the code path only after emitting that + // node's :exit, so for a braceless consequent the segments seen here are still the ones + // from before it. A bare throw satisfies the rule; a bare silent exit was already judged + // by onSilentExit on entry. + const { type } = entry.consequent; + if (type === "ThrowStatement" || SILENT_EXIT_TYPES.has(type)) return; + if (isReachable()) report(entry); + }, + ReturnStatement: onSilentExit, + BreakStatement: onSilentExit, + ContinueStatement: onSilentExit, + }; + }, +}; diff --git a/.config/pyrefly.mutmut.toml b/.config/pyrefly.mutmut.toml index fc33dd8..a2097a4 100644 --- a/.config/pyrefly.mutmut.toml +++ b/.config/pyrefly.mutmut.toml @@ -11,7 +11,8 @@ use-ignore-files = false disable-project-excludes-heuristics = true python-platform = "linux" -project-includes = ["../mutants/src/**"] +preset = "strict" +project-includes = ["../backend/mutants/src/**"] project-excludes = [ # paths are relative to this config file (in .config/), so prefix with ../ to anchor at the repo root "../**/generated/**", diff --git a/.config/pyrefly.toml b/.config/pyrefly.toml index 2fe7758..fe3c8c4 100644 --- a/.config/pyrefly.toml +++ b/.config/pyrefly.toml @@ -18,10 +18,12 @@ project-excludes = [ ] # without this explicit "project-includes" and "search-path", pyrefly struggles to understand the tests folder project-includes = [ - "../src", - "../tests" + "../backend/src", + "../backend/tests" ] -search-path = [".."] +# "../backend" is needed so the tests package (rooted at backend/) resolves relative imports; +# without it pyrefly infers the import root from this config's dir (.config/) and can't name the test modules +search-path = ["../backend/src", "../backend"] disable-project-excludes-heuristics = true # with this being enabled by default, pyrefly thinks the src is excluded because it's an editable local install python-platform = "linux" infer-with-first-use = false # more similar to pyright. must define the empty list type when instantiated @@ -33,7 +35,11 @@ ignore-errors-in-generated-code = true # not sure yet how effective this is no-access = "ignore" # (already covered by ruff SLF001) untyped-import = "ignore" # equivalent of reportMissingTypeStubs=false in pyright unannotated-return = "ignore" # ruff ANN2xx already requires this, but with suppress-none-returning so that None-returning functions don't need the noise. pyrefly has no equivalent option---the error kind is all-or-nothing. -implicit-reexport = "ignore" # TODO: convert cloud-courier into the nuxt-app template anyway, which has this by default....cloud courier is not a "library" +# The check keeps a distributed library's public API deliberate: consumers should only reach symbols the +# package explicitly re-exports via __all__ or a redundant alias. This project is an application — nothing +# imports backend_api from outside the repo, and its only real API surface is the HTTP interface — so every +# importer is in-repo and the __all__ bookkeeping buys nothing. +implicit-reexport = "ignore" [[sub-config]] matches = "../**/tests/**" @@ -46,3 +52,11 @@ implicitly-defined-attribute = "ignore" # doesn't make any sense for test cases. # and `call_args[0]` is untyped by construction --- so enforcing here only buys narrowing ceremony at every spy. unknown-variable-type = "ignore" unknown-argument-type = "ignore" + +# TODO: move this into win_service.py as a file-level per-code directive once pyrefly supports it. +# Tracking: https://github.com/facebook/pyrefly/issues/3723 +[[sub-config]] +matches = "../**/entrypoint/win_service.py" + +[sub-config.errors] +unused-ignore = "ignore" diff --git a/.config/ruff.toml b/.config/ruff.toml index 82c3263..f3df30d 100644 --- a/.config/ruff.toml +++ b/.config/ruff.toml @@ -93,7 +93,7 @@ unfixable = [ dummy-variable-rgx = "^_$|^__$" [lint.per-file-ignores] -"__init__.py" = ["F401"] # imported but unused is not applicable to init +"__init__.py" = ["F401"] # imported but unused is not applicable to init except for libraries (which this is not) "tests/**/__init__.py" = ["F401"] # imported but unused is not applicable to init in tests "exceptions.py" = ["D100", "D101", "D102"] # Exceptions don't always require docstrings for modules, classes or methods "constants.py" = ["D100"] # Constants don't always require docstrings for modules diff --git a/.config/taskfiles/backend.yaml b/.config/taskfiles/backend.yaml new file mode 100644 index 0000000..176961c --- /dev/null +++ b/.config/taskfiles/backend.yaml @@ -0,0 +1,19 @@ +# ============== WARNING ============================================================================== +# File is managed by copier template: gh:LabAutomationAndScreening/copier-nuxt-python-intranet-app.git +# See .config/.copier-managed-files.json for details. +# +# You are welcome to make changes to this file in your repo if they are custom to your project, +# but if the change should be shared with other projects, please backport it to the template repo. +# ===================================================================================================== +version: '3' + +tasks: + dev-backend: + desc: Start the API server on port 4000 with autoreload (does not write log files) + cmds: + - uv --directory=backend run uvicorn src.entrypoint:app --reload --host '::' --port 4000 + + run-backend-entrypoint: + desc: Run the backend through its CLI entrypoint on port 4000, which accepts CLI options and logs to file + cmds: + - uv --directory=backend run python src/entrypoint.py diff --git a/.config/taskfiles/codegen.yaml b/.config/taskfiles/codegen.yaml new file mode 100644 index 0000000..a02305e --- /dev/null +++ b/.config/taskfiles/codegen.yaml @@ -0,0 +1,39 @@ +# ============== WARNING ============================================================================== +# File is managed by copier template: gh:LabAutomationAndScreening/copier-nuxt-python-intranet-app.git +# See .config/.copier-managed-files.json for details. +# +# You are welcome to make changes to this file in your repo if they are custom to your project, +# but if the change should be shared with other projects, please backport it to the template repo. +# ===================================================================================================== +version: '3' + +tasks: + codegen: + desc: Regenerate every generated client in the repo + cmds: + - task: gen-backend-openapi-client + - task: gen-frontend-openapi-client + + gen-openapi-snapshot: + desc: Regenerate the openapi.json snapshot that both OpenAPI clients are generated from + # `run: once` so the umbrella task refreshes the snapshot a single time rather than once per dependent client + run: once + cmds: + # --no-cov because pytest.ini always adds --cov and .coveragerc sets fail_under=100, so a single-file run + # would exit 1 on coverage even though the snapshot was written; -p no:tach since module-boundary checking + # has nothing to say about regenerating a snapshot + - uv --directory=backend run pytest tests/unit/test_basic_server_functionality.py --snapshot-update --no-cov -p no:tach + + gen-backend-openapi-client: + desc: Regenerate the Python Kiota client that the backend E2E tests import + deps: [gen-openapi-snapshot] + cmds: + - mkdir -p "./backend/tests/e2e/generated/open_api/backend" + - docker run --rm --user $(id -u):$(id -g) -v "./backend/tests/e2e/generated/open_api/backend:/app/output" -v "./backend/tests/unit/__snapshots__/test_basic_server_functionality/test_openapi_schema.json:/app/openapi.json" mcr.microsoft.com/openapi/kiota:1.32.4 generate -l python -c BackendClient -n client -d openapi.json --exclude-backward-compatible --clean-output --additional-data false + - uv --directory=backend run python kiota_nullable_fixer.py "./tests/unit/__snapshots__/test_basic_server_functionality/test_openapi_schema.json" "./tests/e2e/generated/open_api/backend" + + gen-frontend-openapi-client: + desc: Regenerate the TypeScript Kiota client that the frontend imports + deps: [gen-openapi-snapshot] + cmds: + - pnpm --dir=frontend openapi-codegen diff --git a/.config/taskfiles/executable-stack.yaml b/.config/taskfiles/executable-stack.yaml new file mode 100644 index 0000000..16bd0d0 --- /dev/null +++ b/.config/taskfiles/executable-stack.yaml @@ -0,0 +1,59 @@ +# ============== WARNING ============================================================================== +# File is managed by copier template: gh:LabAutomationAndScreening/copier-nuxt-python-intranet-app.git +# See .config/.copier-managed-files.json for details. +# +# You are welcome to make changes to this file in your repo if they are custom to your project, +# but if the change should be shared with other projects, please backport it to the template repo. +# ===================================================================================================== +version: '3' + +vars: + APP_NAME: cloud-courier + DIST_DIR: ./backend/dist/{{.APP_NAME}} + +tasks: + copy-frontend-static: + desc: Copy the generated frontend bundle into the backend static directory it is served from + cmds: + # git clean rather than rm -rf: this directory also holds tracked files (favicon.ico, the .gitignore itself, + # the vendored GraphiQL assets) that must survive. -x covers the gitignored build output, which is the stale part. + - git clean -xdf ./backend/src/static + - cp -a ./frontend/.output/public/. ./backend/src/static/ + + bundle-executable: + # The single definition of the pyinstaller invocation. Callers supply PYINSTALLER_ARGS for whatever their + # context needs; keeping the base command here is what stops the local and CI builds from drifting apart. + internal: true + cmds: + - uv --directory=backend run pyinstaller pyinstaller.spec {{.PYINSTALLER_ARGS}} + + build-executable: + desc: Generate the frontend, bundle it into the backend, and build the single-file executable + cmds: + - task: build-frontend + - task: copy-frontend-static + - task: bundle-executable + vars: + PYINSTALLER_ARGS: --noconfirm + + bundle-executable-from-staged-static: + desc: Build the executable from static assets already present in backend/src/static, with a verbose build log + cmds: + - task: bundle-executable + vars: + PYINSTALLER_ARGS: --log-level=DEBUG + + run-stack: + desc: Build the full stack and run it + cmds: + - task: build-executable + - "{{.DIST_DIR}}/{{.APP_NAME}}" + + reload-frontend-in-stack: + desc: Rebuild only the frontend, swap it into the already-built executable, and run it + cmds: + - task: build-frontend + - rm -rf "{{.DIST_DIR}}/_internal/static" + - task: copy-frontend-static + - cp -a ./backend/src/static "{{.DIST_DIR}}/_internal/static" + - "{{.DIST_DIR}}/{{.APP_NAME}}" diff --git a/.config/taskfiles/frontend.yaml b/.config/taskfiles/frontend.yaml new file mode 100644 index 0000000..5e8d4f7 --- /dev/null +++ b/.config/taskfiles/frontend.yaml @@ -0,0 +1,24 @@ +# ============== WARNING ============================================================================== +# File is managed by copier template: gh:LabAutomationAndScreening/copier-nuxt-python-intranet-app.git +# See .config/.copier-managed-files.json for details. +# +# You are welcome to make changes to this file in your repo if they are custom to your project, +# but if the change should be shared with other projects, please backport it to the template repo. +# ===================================================================================================== +version: '3' + +tasks: + dev-frontend: + desc: Start the frontend development server on http://localhost:3000 + cmds: + - pnpm --dir=frontend dev + + build-frontend: + desc: Generate the production frontend bundle into frontend/.output/public + cmds: + - pnpm --dir=frontend generate + + preview-frontend: + desc: Locally preview the generated production frontend bundle + cmds: + - pnpm --dir=frontend preview diff --git a/.config/vacuum-functions/bodyDescription.js b/.config/vacuum-functions/bodyDescription.js new file mode 100644 index 0000000..f8d3595 --- /dev/null +++ b/.config/vacuum-functions/bodyDescription.js @@ -0,0 +1,72 @@ +/* + * ============== WARNING ============================================================================== + * File is managed by copier template: gh:LabAutomationAndScreening/copier-nuxt-python-intranet-app.git + * See .config/.copier-managed-files.json for details. + * + * You are welcome to make changes to this file in your repo if they are custom to your project, + * but if the change should be shared with other projects, please backport it to the template repo. + * ===================================================================================================== + */ +// Vacuum custom JS function (goja runtime: no npm, no require/import — plain self-contained JS). +// +// Replaces the built-in `operation-description` rule's requestBody/response checks. That rule only looks +// at requestBody.description / response.description directly and does not resolve $ref, so it can't see +// a description that comes from the referenced schema's docstring. That forced every route to carry a +// hand-typed description duplicating the payload/response model's own docstring. +// +// Vacuum resolves $ref before handing the node to a custom function, so `input.content[mediaType].schema` +// here is already the dereferenced schema — its `description` (from the pydantic model's docstring) is +// visible. This function passes if there's a description directly on the body object OR on any of its +// content schemas, and only flags a body with no description anywhere. +// +// An Optional payload (`payload: Model | None = None`) produces an `anyOf: [, {type: null}]` +// wrapper instead of the model schema directly, so the description lives one level deeper — branches of +// anyOf/oneOf are checked too. + +function getSchema() { + return { name: "bodyDescription" }; +} + +function hasOwnDescription(node) { + return !!node && typeof node.description === "string" && node.description.length > 0; +} + +// An Optional[Model] field (e.g. `payload: Model | None = None`) resolves to +// {"anyOf": [{...model schema, with its docstring as description...}, {"type": "null"}]} — the +// description lives on a branch, not on the anyOf node itself, so branches must be checked too. +function schemaHasDescription(schema) { + if (!schema || typeof schema !== "object") { + return false; + } + if (hasOwnDescription(schema)) { + return true; + } + var branches = schema.anyOf || schema.oneOf; + if (Array.isArray(branches)) { + for (var i = 0; i < branches.length; i++) { + if (schemaHasDescription(branches[i])) { + return true; + } + } + } + return false; +} + +function runRule(input) { + if (!input || typeof input !== "object") { + return []; + } + if (hasOwnDescription(input)) { + return []; + } + var content = input.content; + if (content && typeof content === "object") { + for (var mediaType in content) { + var schema = content[mediaType] && content[mediaType].schema; + if (schemaHasDescription(schema)) { + return []; + } + } + } + return [{ message: "body is missing a description (own, or via its schema's docstring)" }]; +} diff --git a/.config/vacuum-functions/bodyExample.js b/.config/vacuum-functions/bodyExample.js new file mode 100644 index 0000000..3646bcf --- /dev/null +++ b/.config/vacuum-functions/bodyExample.js @@ -0,0 +1,101 @@ +/* + * ============== WARNING ============================================================================== + * File is managed by copier template: gh:LabAutomationAndScreening/copier-nuxt-python-intranet-app.git + * See .config/.copier-managed-files.json for details. + * + * You are welcome to make changes to this file in your repo if they are custom to your project, + * but if the change should be shared with other projects, please backport it to the template repo. + * ===================================================================================================== + */ +// Vacuum custom JS function (goja runtime: no npm, no require/import — plain self-contained JS). +// +// Replaces the built-in `oas3-missing-example` rule's requestBody/response checks. That rule only looks +// for an `example`/`examples` key on the media type object itself (or, at the schema-property level, on +// that property's own node) and does not drill into a `$ref`'d schema's own fields. Every field-level +// `Field(examples=[...])` on a payload/response model is therefore invisible to it, which forced routes +// to carry a hand-typed body example duplicating examples that already exist on the model. +// +// Vacuum resolves $ref before handing the node to a custom function, so `input.content[mediaType].schema` +// here is already the dereferenced schema. This function passes if there's an example directly on the +// body/media object, or anywhere within its (dereferenced) schema: on the schema itself, on any of its +// properties (recursively), on array items, or on an anyOf/oneOf branch (the shape of an `Optional[Model]` +// field). It only flags a body with no example reachable anywhere in that tree. + +function getSchema() { + return { name: "bodyExample" }; +} + +function hasOwnExample(node) { + if (!node || typeof node !== "object") { + return false; + } + if (Array.isArray(node.examples) && node.examples.length > 0) { + return true; + } + return "example" in node && node.example !== undefined; +} + +// Walks a resolved schema looking for an example anywhere within it: on itself, on a property +// (recursively, since a nested model's own field examples count), inside array items, or on an +// anyOf/oneOf branch (how an `Optional[Model]` field is represented). `seen` guards against infinite +// recursion on a self-referencing schema. +function schemaHasExample(schema, seen) { + if (!schema || typeof schema !== "object") { + return false; + } + if (seen.indexOf(schema) !== -1) { + return false; + } + seen.push(schema); + + if (hasOwnExample(schema)) { + return true; + } + + var branches = schema.anyOf || schema.oneOf || schema.allOf; + if (Array.isArray(branches)) { + for (var i = 0; i < branches.length; i++) { + if (schemaHasExample(branches[i], seen)) { + return true; + } + } + } + + if (schema.items && schemaHasExample(schema.items, seen)) { + return true; + } + + var properties = schema.properties; + if (properties && typeof properties === "object") { + for (var key in properties) { + if (schemaHasExample(properties[key], seen)) { + return true; + } + } + } + + return false; +} + +function runRule(input) { + if (!input || typeof input !== "object") { + return []; + } + var content = input.content; + if (!content || typeof content !== "object") { + return []; + } + + var results = []; + for (var mediaType in content) { + var mediaObject = content[mediaType]; + if (hasOwnExample(mediaObject)) { + continue; + } + if (schemaHasExample(mediaObject && mediaObject.schema, [])) { + continue; + } + results.push({ message: "body (" + mediaType + ") is missing an example, own or via its schema's fields" }); + } + return results; +} diff --git a/.config/vacuum-functions/resourcesPlural.js b/.config/vacuum-functions/resourcesPlural.js new file mode 100644 index 0000000..45a8dba --- /dev/null +++ b/.config/vacuum-functions/resourcesPlural.js @@ -0,0 +1,77 @@ +/* + * ============== WARNING ============================================================================== + * File is managed by copier template: gh:LabAutomationAndScreening/copier-nuxt-python-intranet-app.git + * See .config/.copier-managed-files.json for details. + * + * You are welcome to make changes to this file in your repo if they are custom to your project, + * but if the change should be shared with other projects, please backport it to the template repo. + * ===================================================================================================== + */ +// Vacuum custom JS function (goja runtime: no npm, no require/import — plain self-contained JS). +// +// Flags static path segments that look like singular resource nouns; REST collections should be plural +// (gov.au naming). `given: $.paths[*]~` passes each path key (e.g. "/api/data/devices/{device_id}") as +// the single `input` argument. We split on "/", ignore "{param}" segments, and check each static segment. +// +// Pluralization is heuristic. To keep false positives down we DO NOT try to pluralize every word; instead +// we flag a segment only when it is clearly singular (does not end in "s") AND is not in the ALLOWLIST of +// non-collection segments (namespaces, versions, actions, status/singleton sub-resources, uncountable +// nouns). Projects can edit ALLOWLIST below — this file is copier-managed but locally customizable. + +// Segments that are legitimately not plural collections. +var ALLOWLIST = [ + // namespace / versioning + "api", "v1", "v2", "v3", + // uncountable / mass nouns + "data", "series", "telemetry", "equipment", "info", "metadata", "media", "config", "configuration", + // health / status / lifecycle singletons + "health", "healthcheck", "healthz", "readyz", "livez", "status", "shutdown", "ping", "version", + // common actions, filters and singleton sub-resources + "me", "self", "search", "online", "offline", "push", "polling", "dashboard", "summary", "latest", "current", + // non-REST endpoints (not resource collections) + "graphql" +]; + +function getSchema() { + return { name: "resourcesPlural" }; +} + +function isAllowed(seg) { + var lower = seg.toLowerCase(); + for (var i = 0; i < ALLOWLIST.length; i++) { + if (ALLOWLIST[i] === lower) { + return true; + } + } + return false; +} + +// Lenient: anything ending in "s" is treated as already plural. This intentionally under-flags words like +// "status"/"analysis" (safe: those are allowlisted or uncountable) so we never wrongly flag a plural. +function looksPlural(seg) { + return seg.length > 1 && seg.charAt(seg.length - 1) === "s"; +} + +function runRule(input) { + if (typeof input !== "string") { + return []; + } + var results = []; + var segments = input.split("/"); + for (var i = 0; i < segments.length; i++) { + var seg = segments[i]; + if (seg === "") { + continue; // leading slash / empty + } + if (seg.charAt(0) === "{") { + continue; // path parameter, not a resource segment + } + if (isAllowed(seg) || looksPlural(seg)) { + continue; + } + results.push({ + message: "path segment '" + seg + "' should be a plural resource noun (or added to the allowlist if it is not a collection)" + }); + } + return results; +} diff --git a/.config/vacuum-ignore.yaml b/.config/vacuum-ignore.yaml new file mode 100644 index 0000000..cc1b4f6 --- /dev/null +++ b/.config/vacuum-ignore.yaml @@ -0,0 +1,22 @@ +# ============== WARNING ============================================================================== +# File is managed by copier template: gh:LabAutomationAndScreening/copier-nuxt-python-intranet-app.git +# See .config/.copier-managed-files.json for details. +# +# You are welcome to make changes to this file in your repo if they are custom to your project, +# but if the change should be shared with other projects, please backport it to the template repo. +# ===================================================================================================== +# Ignored vacuum findings, with justification. +# +# owasp-define-error-validation requires every operation to document a 400/422/4XX validation-error +# response. These five endpoints take no path/query/body parameters, so there is no honest client-error +# to document. The rule stays enabled so it enforces error documentation on all *future* endpoints; only +# these input-less operations are exempted. +owasp-define-error-validation: + - "$.paths['/api/shutdown'].get.responses" + +# ValidationError is generated by FastAPI (the 422 body); its `loc` union contains an int we cannot +# annotate with a format/bounds without owning the model, so exempt that one generated node. +owasp-integer-format: + - "$.components.schemas['ValidationError'].properties['loc'].items.anyOf[1]" +owasp-integer-limit: + - "$.components.schemas['ValidationError'].properties['loc'].items.anyOf[1]" diff --git a/.config/vacuum-ruleset.yaml b/.config/vacuum-ruleset.yaml new file mode 100644 index 0000000..2951318 --- /dev/null +++ b/.config/vacuum-ruleset.yaml @@ -0,0 +1,236 @@ +# ============== WARNING ============================================================================== +# File is managed by copier template: gh:LabAutomationAndScreening/copier-nuxt-python-intranet-app.git +# See .config/.copier-managed-files.json for details. +# +# You are welcome to make changes to this file in your repo if they are custom to your project, +# but if the change should be shared with other projects, please backport it to the template repo. +# ===================================================================================================== +# Curated vacuum ruleset for the backend OpenAPI schema. +# +# Extends vacuum's built-in OpenAPI "recommended" set, then disables two rules that are noise/false +# positives for a FastAPI-generated spec and enables a handful of opt-in best-practice rules plus three +# OWASP data-hardening rules. Every non-default rule carries a comment explaining WHY. +extends: + - - vacuum:oas + - recommended +rules: + # DISABLED: flags identical description strings, but ours repeat legitimately — the same field + # (e.g. SensorInfo.id) is inherited across many response models, and the same 404 text is reused + # across endpoints. Forcing unique wording would make the docs less consistent, not better. + description-duplication: "off" + + # DISABLED: `servers` is meaningless here — single-server, air-gapped device, no multi-environment + # URLs to document. FastAPI's default "/ - This server" relative entry is all Swagger UI needs. + oas3-api-servers: "off" + + # DISABLED: false positive — it does not follow `$ref`, so enum/model-typed properties + # (severity, direction, status, measurementType, etc.) that reference a fully-typed component are wrongly + # flagged as "missing type". Every code-level workaround trips a different recommended rule + # (allOf -> no-unnecessary-combinator; bare $ref -> oas3-missing-example and loses the field + # description), so disabling this single shallow rule is the cleanest option. The fields keep their + # normal Field(description=, examples=). A FastAPI-generated spec can otherwise only trigger this on + # `Any`-typed fields, which are rare and usually unavoidable (e.g. the generated ValidationError.input). + oas-missing-type: "off" + + # DISABLED: same false-positive family as `oas-missing-type` above — it checks requestBody.description + # / response.description directly and does not follow `$ref`, so it can't see that the payload/response + # model's own docstring already documents the body. That forced every route to hand-type a description + # duplicating the model docstring almost verbatim. Replaced below by `request-body-description` / + # `response-body-description`, which resolve the schema first and pass if either the body itself or its + # (dereferenced) schema has a description. + operation-description: "off" + + # DISABLED: same false-positive family as `operation-description` above — it only checks for an + # `example`/`examples` key on the media type object (or, at the property level, on that property's own + # node) and does not resolve `$ref`, so it can't see a body/response model's own field-level examples. + # Replaced below by `request-body-example` / `response-body-example`, which resolve the schema first and + # pass if an example exists anywhere within it. + oas3-missing-example: "off" + + # ENABLED: guarantees every tag used by an operation is declared at the top level with real metadata — + # this drives navigation/grouping in docs and Kiota-generated client namespaces. + # `true` pulls vacuum's built-in definition verbatim (it's an opt-in rule, absent from `recommended`). + openapi-tags: true + + # ENABLED: a tag with no description is useless to a consumer reading the docs — enforce a + # human-readable purpose for each of system/debug/devices/data/alarms. + tag-description: true + + # ENABLED: keeps each operation to exactly one tag so it appears once in generated navigation/clients + # (multi-tagging duplicates operations across groups and confuses codegen). We already tag singly. + operation-singular-tag: true + + # ENABLED: a POST with no documented success response is an incomplete contract; ensures every + # create/action endpoint declares its 2xx/3xx (we already return 201) so clients know the success shape. + post-response-success: true + + # ENABLED: correctness guard — every entry in a schema's `required` list must actually exist in + # `properties`. Catches real bugs (typo'd/removed field left in required) that break client codegen. + required-fields-defined: true + + # ENABLED (OWASP): three data-hardening rules worth keeping even on an air-gapped/no-auth API. The + # rest of the OWASP pack (auth/HTTPS/rate-limit/protection-*/string-restricted) is intentionally NOT + # enabled for this trusted air-gapped, no-auth deployment; revisit if the API is ever exposed/authed. + owasp-define-error-validation: true + + # Integers must declare int32/int64 `format` so codegen picks the right numeric type. + # (Config, not `true`: OWASP rules aren't in the extended set, so `true` would be dropped.) + owasp-integer-format: true + + # Integers must declare min/max bounds (input validation / resource-exhaustion guard). + owasp-integer-limit: true + + # ENABLED: path id parameters must be explicit and resource-qualified, never a bare generic + # identifier. Bare `{id}` is ambiguous in a multi-resource spec and produces collision-prone Kiota + # client method names (every resource collapses to `by_id`). The denylist is Tier 1 only + # (unambiguously generic tokens). Tier 2 natural-key tokens (code|slug|name|type) are intentionally + # excluded because they are often legitimate (e.g. /countries/{code}); add them only if wanted. + path-no-generic-id: + description: Path id parameters must be explicit, not a bare generic identifier. + given: $.paths[*]~ + severity: error + then: + function: pattern + functionOptions: + notMatch: "\\{(id|pk|fk|uid|guid|uuid|oid|key|ref|hash|idx|index|num|no|identifier|value|item|object|entity|record|data|thing)\\}" + + # ENABLED: path PARAMETERS must be snake_case. The kebab-case + lowercase half of our hybrid casing + # decision (kebab static segments, snake_case params) is ALREADY enforced by the recommended set's + # `paths-kebab-case` rule, which flags non-kebab STATIC segments and ignores `{param}` segments + # entirely. This rule adds the parameter half that `paths-kebab-case` does not cover. FastAPI binds a + # path parameter's name to a Python function argument (which cannot contain hyphens), so snake_case is + # the only practical casing for parameters. + path-param-snake-case: + description: Path parameters must be snake_case. + given: $.paths[*][*].parameters[?(@.in=='path')].name + severity: error + then: + function: casing + functionOptions: + type: snake + + # ENABLED: query parameters must be camelCase — body params are already camelCase, so matching that + # for query params keeps frontend clients consistent instead of converting between casings per field. + query-param-camel-case: + description: Query parameters must be camelCase. + given: $.paths[*][*].parameters[?(@.in=='query')].name + severity: error + then: + function: casing + functionOptions: + type: camel + + # ENABLED (gov.au API Response): response bodies must be application/json. `application/problem+json` + # is also allowed because custom_openapi emits RFC 7807 ProblemDetails error bodies with that media + # type. Fires zero violations against the current snapshot. + response-media-type-json: + description: Response bodies must be application/json (or application/problem+json for errors). + given: $.paths[*][*].responses[*].content[*]~ + severity: error + then: + function: pattern + functionOptions: + match: "^application/(json|problem\\+json)$" + + # ENABLED (gov.au API Request): request bodies must be application/json. Fires zero violations against + # the current snapshot. + request-media-type-json: + description: Request bodies must be application/json. + given: $.paths[*][*].requestBody.content[*]~ + severity: error + then: + function: pattern + functionOptions: + match: "^application/json$" + + # ENABLED: POST request bodies must not REQUIRE an id — the backend generates it (a client may still + # supply an optional one). This targets each entry of the resolved request-body schema's `required` + # array and asserts none is literally "id"; if `required` is absent, no nodes match and the rule + # passes. (A schema-of-schema `not/contains` approach was tried first but vacuum reported it as a + # false positive on every POST body, so this pattern-based form is used instead.) + create-body-no-required-id: + description: POST request bodies must not require an id (backend-generated). + given: $.paths[*].post.requestBody.content.*.schema.required[*] + resolved: true + severity: error + then: + function: pattern + functionOptions: + notMatch: "^id$" + + # ENABLED (gov.au Naming: nouns not verbs). The recommended set's `no-http-verbs-in-path` already + # catches HTTP-method names used as path segments; this adds the non-HTTP action verbs it does not + # cover. Fires zero violations against the current snapshot. + no-action-verbs-in-path: + description: Path segments must be nouns, not action verbs. + given: $.paths[*]~ + severity: error + then: + function: pattern + functionOptions: + notMatch: "/(list|create|add|update|edit|remove|fetch|retrieve|make)(-|/|$)" + + # ENABLED (gov.au API Response): a 201 Created must return the new resource's Location. This fires on + # POST operations that declare a 201 response, so genuine resource creates (add device, create + # threshold, create view) declare + emit a Location header. Non-create POST actions should return a + # non-201 success (e.g. push measurements returns 202 Accepted) so this rule does not apply to them. + # (A `function: schema` form with a nested `required: [Location]` was tried first but vacuum reported + # it as a false positive even when the header was present, so this truthy field check is used instead.) + location-header-on-201: + description: 201 Created responses must declare a Location header. + given: $.paths[*].post.responses['201'] + severity: error + then: + field: headers.Location + function: truthy + + # ENABLED (gov.au Naming: plural resources). Static path segments should be plural resource nouns. + # Declarative casing/pattern rules cannot pluralize, so this uses a custom JavaScript function + # (.config/vacuum-functions/resourcesPlural.js) run via `vacuum lint -f .config/vacuum-functions`. The + # check is heuristic: it flags a static segment only when it is clearly singular (does not end in "s") + # and is not in the function's ALLOWLIST of non-collection segments (namespaces, actions, uncountable + # nouns). Tune that allowlist in the JS file rather than here. + resources-plural: + description: Path resource segments should be plural nouns. + given: $.paths[*]~ + severity: error + then: + function: resourcesPlural + + # ENABLED: replaces the requestBody half of the built-in `operation-description` (disabled above). + # Uses .config/vacuum-functions/bodyDescription.js, which resolves the requestBody's schema and passes + # if either the requestBody itself or its (dereferenced) schema has a description — so a payload + # model's docstring is enough, no hand-typed duplicate needed. + request-body-description: + description: Request bodies must have a description, either their own or via their schema's docstring. + given: $.paths[*][*].requestBody + severity: error + then: + function: bodyDescription + + # ENABLED: same replacement as `request-body-description` above, for response bodies. + response-body-description: + description: Response bodies must have a description, either their own or via their schema's docstring. + given: $.paths[*][*].responses[*] + severity: error + then: + function: bodyDescription + + # ENABLED: replaces the requestBody half of the built-in `oas3-missing-example` (disabled above). Uses + # .config/vacuum-functions/bodyExample.js, which resolves the requestBody's schema and passes if an + # example exists on the body itself or anywhere within its (dereferenced) schema — so field-level + # `Field(examples=[...])` on the payload model is enough, no hand-typed duplicate needed. + request-body-example: + description: Request bodies must have an example, either their own or via their schema's fields. + given: $.paths[*][*].requestBody + severity: error + then: + function: bodyExample + + # ENABLED: same replacement as `request-body-example` above, for response bodies. + response-body-example: + description: Response bodies must have an example, either their own or via their schema's fields. + given: $.paths[*][*].responses[*] + severity: error + then: + function: bodyExample diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 1c57e92..2d36d49 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -17,13 +17,18 @@ RUN apt-get update -y && apt-get install -y \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* -# Create the venv folder and set permissions for anyone to modify---this is necessary to be able to break out the venv folder as a separate docker volume for better performance on Windows hosts +# Create the workspace folders that have docker volume (see docker-compose.yaml) and set permissions for anyone to modify +# this is necessary to be able to break out these folders as a separate docker volume for better performance on Windows hosts ARG REPO_NAME=copier-base-template -ENV VENV_PATH=/workspaces/${REPO_NAME}/.venv +ENV VENV_PATH=/workspaces/${REPO_NAME}/backend/.venv +ENV PNPM_STORE=/workspaces/${REPO_NAME}/.pnpm-store +ENV FRONTEND_NODE_MODULES=/workspaces/${REPO_NAME}/node_modules RUN mkdir -p /workspaces && \ mkdir -p ${VENV_PATH} && \ - chmod -R 777 /workspaces ${VENV_PATH} && \ - chgrp -R 0 /workspaces ${VENV_PATH} + mkdir -p ${PNPM_STORE} && \ + mkdir -p ${FRONTEND_NODE_MODULES} && \ + chmod -R 777 /workspaces ${VENV_PATH} ${PNPM_STORE} ${FRONTEND_NODE_MODULES} && \ + chgrp -R 0 /workspaces ${VENV_PATH} ${PNPM_STORE} ${FRONTEND_NODE_MODULES} # SSH EXPOSE 2222 diff --git a/.devcontainer/code-artifact-auth.sh b/.devcontainer/code-artifact-auth.sh index 9820288..f824531 100644 --- a/.devcontainer/code-artifact-auth.sh +++ b/.devcontainer/code-artifact-auth.sh @@ -1,7 +1,7 @@ # Placeholder file not being used by these copier template answers # ============== WARNING ============================================================================== -# File is managed by copier template: gh:LabAutomationAndScreening/copier-python-package-template +# File is managed by copier template: gh:LabAutomationAndScreening/copier-nuxt-python-intranet-app.git # See .config/.copier-managed-files.json for details. # # You are welcome to make changes to this file in your repo if they are custom to your project, diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index ada59a7..21cd307 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,7 +1,8 @@ { "hostRequirements": { "cpus": 2, - "memory": "4gb" + // Static site generation requires more memory + "memory": "10gb" }, "dockerComposeFile": "docker-compose.yml", "service": "devcontainer", @@ -18,6 +19,12 @@ // view latest version https://raw.githubusercontent.com/aws/aws-cli/v2/CHANGELOG.rst "version": "2.36.49" }, + "ghcr.io/devcontainers/features/node:2.1.0": { + // https://github.com/devcontainers/features/blob/main/src/node/devcontainer-feature.json + "version": "24.11.1", + "pnpmVersion": "12.4.2", + "nvmVersion": "0.40.5", + }, // https://github.com/anthropics/devcontainer-features/blob/main/src/claude-code/devcontainer-feature.json "ghcr.io/anthropics/devcontainer-features/claude-code:1.0.5": {}, }, @@ -40,6 +47,13 @@ "ms-vscode-remote.remote-containers@0.414.0", "charliermarsh.ruff@2026.68.0", + // VueJS + "vue.volar@3.2.5", + "vitest.explorer@1.36.0", + + // All javascript + "dbaeumer.vscode-eslint@3.0.24", + // Misc file formats "bierner.markdown-mermaid@1.29.0", "samuelcolvin.jinjahtml@0.20.0", @@ -70,8 +84,12 @@ } }, }, + "forwardPorts": [3000, 4000], + "portsAttributes": { + "3000": { "label": "Frontend" }, + "4000": { "label": "Backend" } + }, "initializeCommand": "sh .devcontainer/initialize-command.sh", "onCreateCommand": "sh .devcontainer/on-create-command.sh", "postStartCommand": "sh .devcontainer/post-start-command.sh" - // Devcontainer context hash (do not manually edit this, it's managed by a pre-commit hook): ca55a45c # spellchecker:disable-line } diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index b0e0159..1b1e0df 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -17,10 +17,13 @@ services: volumes: - ..:/workspaces/cloud-courier:cached # Break out the venv folder as a separate docker volume for better performance on Windows hosts - - python_venv:/workspaces/cloud-courier/.venv + - python_venv:/workspaces/cloud-courier/backend/.venv + # Break out the pnpm store folder as a separate docker volume for better performance on Windows hosts + - pnpm_store:/workspaces/cloud-courier/.pnpm-store + - frontend_code_libraries:/workspaces/cloud-courier/node_modules command: sleep infinity ports: - - "61335:2222" + - "51779:2222" environment: - AWS_PROFILE=localstack - AWS_DEFAULT_REGION=us-east-1 @@ -43,16 +46,9 @@ services: - DOLT_DATABASE=beads_work - DOLT_DISABLE_EVENT_FLUSH=1 # dolt contacts doltremoteapi.dolthub.com with usage metrics by default, even with no remotes configured (see beads SECURITY.md) - localstack: - image: localstack/localstack:4.14.0 # no known reason for not using newer versions, just pinning for best practice - container_name: localstack - ports: - - "4566:4566" # Main edge port for all AWS service emulation - environment: - # really cryptic error messages unless DEBUG is set - - DEBUG=1 - - SERVICES=s3,sts,cloudwatch,ssm,events,iam volumes: beads_dolt_data: {} python_venv: {} + pnpm_store: {} + frontend_code_libraries: {} diff --git a/.devcontainer/envs.json b/.devcontainer/envs.json index edc195d..f74b293 100644 --- a/.devcontainer/envs.json +++ b/.devcontainer/envs.json @@ -1,7 +1,12 @@ [ { - "description": "main", - "relative_directory": ".", + "description": "frontend", + "relative_directory": "frontend", + "package_manager": "pnpm" + }, + { + "description": "backend", + "relative_directory": "backend", "package_manager": "uv" } ] diff --git a/.github/actions/ecr-auth/action.yml b/.github/actions/ecr-auth/action.yml index d826ce1..e9c45df 100644 --- a/.github/actions/ecr-auth/action.yml +++ b/.github/actions/ecr-auth/action.yml @@ -1,5 +1,5 @@ # ============== WARNING ============================================================================== -# File is managed by copier template: gh:LabAutomationAndScreening/copier-python-package-template +# File is managed by copier template: gh:LabAutomationAndScreening/copier-nuxt-python-intranet-app.git # See .config/.copier-managed-files.json for details. # # You are welcome to make changes to this file in your repo if they are custom to your project, diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 1f257aa..48a8b32 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -20,7 +20,7 @@ updates: - "version-update:semver-patch" # we don't want to be bothered with patch updates for anything except security updates - package-ecosystem: "uv" directories: - - "/" + - "/backend" schedule: interval: "weekly" day: "sunday" @@ -47,3 +47,27 @@ updates: dependency-type: "development" update-types: - "minor" + - package-ecosystem: "npm" + directories: + - "/frontend" + schedule: + interval: "weekly" + day: "wednesday" + time: "12:00" + open-pull-requests-limit: 5 + ignore: + - dependency-name: "*" + update-types: + - "version-update:semver-patch" # we don't want to be bothered with patch updates for anything except security updates + - dependency-name: "@types/node" + update-types: + - "version-update:semver-major" # @types/node major should be pinned to the node engine itself + groups: + prod-dependencies: + dependency-type: "production" + update-types: + - "minor" + dev-dependencies: + dependency-type: "development" + update-types: + - "minor" diff --git a/.github/workflows/build-docker-image.yaml b/.github/workflows/build-docker-image.yaml new file mode 100644 index 0000000..8604f19 --- /dev/null +++ b/.github/workflows/build-docker-image.yaml @@ -0,0 +1,268 @@ +# ============== WARNING ============================================================================== +# File is managed by copier template: gh:LabAutomationAndScreening/copier-nuxt-python-intranet-app.git +# See .config/.copier-managed-files.json for details. +# +# You are welcome to make changes to this file in your repo if they are custom to your project, +# but if the change should be shared with other projects, please backport it to the template repo. +# ===================================================================================================== +name: Build Docker Image + +on: + workflow_call: + inputs: + repository: + description: 'Docker repository' + type: string + required: true + image_name: + description: 'Docker image name' + type: string + required: true + tag-for-production: + description: 'Whether or not to add a tag indicating this is being used in production' + type: boolean + required: false + default: false + release-tag: + description: 'Optional release tag (e.g. 1.0.6) to push to ECR; applied even if an image with the same build-context-hash already exists' + type: string + required: false + default: '' + context: + description: 'Build context path' + type: string + required: false + default: './' + build-contexts: + description: 'Additional named build contexts (e.g. "repo-root=.")' + type: string + required: false + default: '' + additional-hash-paths: + description: 'Space-separated extra file paths (relative to repo root) to include in the build context hash' + type: string + required: false + default: '' + push-role-name: + type: string + description: What's the IAM role name to use for Pushing to the ECR? + required: false + default: no-push + save-as-artifact: + type: boolean + description: 'Should the image be saved as an artifact?' + required: false + default: false + artifact-retention-days: + type: number + description: 'Number of days to retain the uploaded image artifact' + required: false + default: 4 + outputs: + artifact-name: + description: 'The name of the uploaded artifact of the image tarball' + value: ${{ jobs.build-image.outputs.artifact-name }} + full-image-tag: + description: 'The full image tag used for the built image (repository/name:context-hash)' + value: ${{ jobs.build-image.outputs.full-image-tag }} + +permissions: + id-token: write + contents: write # needed for mutex + +jobs: + build-image: + name: Build Docker Image + timeout-minutes: 15 + runs-on: ubuntu-24.04 + outputs: + artifact-name: ${{ steps.calculate-build-context-hash.outputs.image_name_no_slashes }} + full-image-tag: ${{ steps.calculate-build-context-hash.outputs.full_image_tag }} + steps: + - name: Parse ECR URL + if: ${{ inputs.push-role-name != 'no-push' }} + id: parse_ecr_url + run: | + ECR_URL="${{ inputs.repository}}" + + # Extract the AWS Account ID, which is the first field + AWS_ACCOUNT_ID=$(echo "$ECR_URL" | cut -d. -f1) + + # Extract the AWS Region, which is the fourth field in the URL structure + AWS_REGION=$(echo "$ECR_URL" | cut -d. -f4) + + # Set the outputs for use in later steps + echo "aws_account_id=${AWS_ACCOUNT_ID}" >> "$GITHUB_OUTPUT" + echo "aws_region=${AWS_REGION}" >> "$GITHUB_OUTPUT" + shell: bash + + - name: Checkout code + uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + + - name: OIDC Auth for ECR + if: ${{ inputs.push-role-name != 'no-push' }} + uses: ./.github/actions/ecr-auth + with: + role-arn: arn:aws:iam::${{ steps.parse_ecr_url.outputs.aws_account_id }}:role/${{ inputs.push-role-name }} + aws-region: ${{ steps.parse_ecr_url.outputs.aws_region }} + + - name: Calculate hash of files in build context + id: calculate-build-context-hash + run: | + python3 --version + EXTRA_PATHS="${{ inputs.additional-hash-paths }}" + EXTRA_ARGS="" + if [ -n "$EXTRA_PATHS" ]; then + EXTRA_ARGS="--extra-paths $EXTRA_PATHS" + fi + BUILD_HASH=$(python3 .github/workflows/hash_git_files.py ${{ inputs.context }} $EXTRA_ARGS) + echo "build_context_tag=context-${BUILD_HASH}" >> "$GITHUB_OUTPUT" + echo "Calculated build context tag as: ${BUILD_HASH}" + + IMAGE_NAME_WITH_NAMESPACE="${{ inputs.image_name }}" + IMAGE_NAME_NO_SLASHES="${IMAGE_NAME_WITH_NAMESPACE//\//-}" + echo "image_name_no_slashes=${IMAGE_NAME_NO_SLASHES}" >> "$GITHUB_OUTPUT" + echo "Image name without slashes: ${IMAGE_NAME_NO_SLASHES}" + echo "full_image_tag=${{ inputs.repository }}/${{ inputs.image_name }}:context-${BUILD_HASH}" >> "$GITHUB_OUTPUT" + + - name: Test if docker image exists # deliberately before the mutex; describe-images is a read, so it is safe to run concurrently + if: ${{ inputs.push-role-name != 'no-push' }} + id: check-if-exists + run: | + BUILD_HASH=${{ steps.calculate-build-context-hash.outputs.build_context_tag }} + echo Checking for : $BUILD_HASH + if aws ecr describe-images --region ${{ steps.parse_ecr_url.outputs.aws_region }} --registry-id=${{ steps.parse_ecr_url.outputs.aws_account_id }} --repository-name=${{ inputs.image_name }} --image-ids=imageTag=$BUILD_HASH; then \ + echo "Image was found in ECR"; \ + echo "status=found" >> $GITHUB_OUTPUT + else \ + echo "Image was not found in ECR"; \ + echo "status=notfound" >> $GITHUB_OUTPUT + fi + + - name: Set up mutex # Github concurrency management is horrible, things get arbitrarily cancelled if queued up. So using mutex until github fixes itself. When multiple jobs are modifying cache at once, weird things can happen. possible issue is https://github.com/actions/toolkit/issues/658 + if: ${{ inputs.push-role-name != 'no-push' && steps.check-if-exists.outputs.status == 'notfound' }} # only serialize when this job may have to build and push a new image + uses: ben-z/gh-action-mutex@1ebad517141198e08d47cf72f3c0975316620a65 # v1.0.0-alpha.10 + with: + branch: mutex-${{ inputs.repository }}-${{ inputs.image_name }} + timeout-minutes: 8 # this is the amount of time this action will wait to attempt to acquire the mutex lock before failing, e.g. if other jobs are queued up in front of it + + - name: Re-test if docker image exists # another job may have pushed this exact build context while we waited on the mutex + if: ${{ inputs.push-role-name != 'no-push' && steps.check-if-exists.outputs.status == 'notfound' }} + id: recheck-if-exists + run: | + BUILD_HASH=${{ steps.calculate-build-context-hash.outputs.build_context_tag }} + echo Checking for : $BUILD_HASH + if aws ecr describe-images --region ${{ steps.parse_ecr_url.outputs.aws_region }} --registry-id=${{ steps.parse_ecr_url.outputs.aws_account_id }} --repository-name=${{ inputs.image_name }} --image-ids=imageTag=$BUILD_HASH; then \ + echo "Image was found in ECR"; \ + echo "status=found" >> $GITHUB_OUTPUT + else \ + echo "Image was not found in ECR"; \ + echo "status=notfound" >> $GITHUB_OUTPUT + fi + + - name: Resolve whether the image needs building # collapses the two probes into the single status the rest of the job keys off + if: ${{ inputs.push-role-name != 'no-push' }} + id: image-status + run: | + if [ "${{ steps.check-if-exists.outputs.status }}" = "found" ]; then + echo "status=found" >> $GITHUB_OUTPUT + else + echo "status=${{ steps.recheck-if-exists.outputs.status }}" >> $GITHUB_OUTPUT + fi + + - name: Pull existing image to package as artifact + if: ${{ inputs.save-as-artifact && steps.image-status.outputs.status == 'found' }} + run: | + docker pull ${{ steps.calculate-build-context-hash.outputs.full_image_tag }} + + - name: Set up Docker Buildx + if: ${{ (inputs.save-as-artifact && inputs.push-role-name == 'no-push') || steps.image-status.outputs.status == 'notfound' }} + uses: docker/setup-buildx-action@v4.4.1 + with: + version: v0.37.1 + + - name: Build Docker Image + if: ${{ (inputs.save-as-artifact && inputs.push-role-name == 'no-push') || steps.image-status.outputs.status == 'notfound' }} + uses: docker/build-push-action@v7.4.0 + with: + context: ${{ inputs.context }} + build-contexts: ${{ inputs.build-contexts }} + push: ${{ inputs.push-role-name != 'no-push' && steps.image-status.outputs.status == 'notfound' }} + load: ${{ inputs.save-as-artifact }} # make the image available later for the `docker save` step + tags: ${{ steps.calculate-build-context-hash.outputs.full_image_tag }} + + - name: Add git sha tag + if: ${{ inputs.push-role-name != 'no-push' }} + run: | + GIT_SHA_TAG="git-sha-${{ github.sha }}" + if aws ecr describe-images --region ${{ steps.parse_ecr_url.outputs.aws_region }} --registry-id=${{ steps.parse_ecr_url.outputs.aws_account_id }} --repository-name=${{ inputs.image_name }} --image-ids imageTag="$GIT_SHA_TAG" > /dev/null 2>&1; then + echo "Tag $GIT_SHA_TAG already exists in ECR, skipping" + else + aws ecr batch-get-image --registry-id=${{ steps.parse_ecr_url.outputs.aws_account_id }} --repository-name=${{ inputs.image_name }} --image-ids imageTag=${{ steps.calculate-build-context-hash.outputs.build_context_tag }} --query 'images[].imageManifest' --output text > manifest.json + # Every job on the push path tags the git sha, and jobs that find their image already in ECR no longer serialize on the mutex, + # so another job can apply this same tag between the check above and the put below. ECR reports that as + # ImageAlreadyExistsException, which by definition means the tag already points at this exact manifest, so it is safe to ignore. + # ImageTagAlreadyExistsException (a different manifest on an immutable repository) is a real conflict and still fails the step. + if ! aws ecr put-image --registry-id=${{ steps.parse_ecr_url.outputs.aws_account_id }} --repository-name=${{ inputs.image_name }} --image-tag "$GIT_SHA_TAG" --image-manifest file://manifest.json 2> put-image-error.txt; then + cat put-image-error.txt + if ! grep -q 'ImageAlreadyExistsException' put-image-error.txt; then + exit 1 + fi + echo "Tag $GIT_SHA_TAG was applied concurrently by another job, nothing to do" + fi + fi + + - name: Add tag for Production + if: ${{ inputs.push-role-name != 'no-push' && inputs.tag-for-production }} + run: | + # Unlike the git sha tag above, a concurrent ImageAlreadyExistsException is deliberately left fatal here. + # This step only runs from deploy workflows, which are not expected to overlap for the same image, so a + # duplicate put-image points at something unexpected about how the deploy was triggered and should be loud + # rather than swallowed. Note that PRODUCTION_CONTEXT_TAG keys on the build context hash rather than the + # commit, so two commits that leave the build context unchanged do share a tag. + aws ecr batch-get-image --registry-id=${{ steps.parse_ecr_url.outputs.aws_account_id }} --repository-name=${{ inputs.image_name }} --image-ids imageTag=${{ steps.calculate-build-context-hash.outputs.build_context_tag }} --query 'images[].imageManifest' --output text > manifest.json + PRODUCTION_CONTEXT_TAG="production--${{ steps.calculate-build-context-hash.outputs.build_context_tag }}" + if aws ecr describe-images --region ${{ steps.parse_ecr_url.outputs.aws_region }} --registry-id=${{ steps.parse_ecr_url.outputs.aws_account_id }} --repository-name=${{ inputs.image_name }} --image-ids imageTag="$PRODUCTION_CONTEXT_TAG" > /dev/null 2>&1; then + echo "Tag $PRODUCTION_CONTEXT_TAG already exists in ECR, skipping" + else + aws ecr put-image --registry-id=${{ steps.parse_ecr_url.outputs.aws_account_id }} --repository-name=${{ inputs.image_name }} --image-tag "$PRODUCTION_CONTEXT_TAG" --image-manifest file://manifest.json + fi + PRODUCTION_SHA_TAG="production--git-sha-${{ github.sha }}" + if aws ecr describe-images --region ${{ steps.parse_ecr_url.outputs.aws_region }} --registry-id=${{ steps.parse_ecr_url.outputs.aws_account_id }} --repository-name=${{ inputs.image_name }} --image-ids imageTag="$PRODUCTION_SHA_TAG" > /dev/null 2>&1; then + echo "Tag $PRODUCTION_SHA_TAG already exists in ECR, skipping" + else + aws ecr put-image --registry-id=${{ steps.parse_ecr_url.outputs.aws_account_id }} --repository-name=${{ inputs.image_name }} --image-tag "$PRODUCTION_SHA_TAG" --image-manifest file://manifest.json + fi + + - name: Add release tag + if: ${{ inputs.push-role-name != 'no-push' && inputs.release-tag != '' }} + run: | + RELEASE_TAG="${{ inputs.release-tag }}" + if ! [[ "$RELEASE_TAG" =~ ^[A-Za-z0-9_][A-Za-z0-9._-]{0,127}$ ]]; then + echo "Invalid release-tag: '$RELEASE_TAG'. Must start with alphanumeric or underscore and contain only alphanumerics, dots, hyphens, underscores (max 128 chars)." + exit 1 + fi + # This check is an intentional immutability guard rather than a concurrency one: reusing a version number is a + # human mistake worth failing on. A concurrent ImageAlreadyExistsException below is likewise left fatal, since + # release workflows are not expected to run twice at once for the same tag. + if aws ecr describe-images --region ${{ steps.parse_ecr_url.outputs.aws_region }} --registry-id=${{ steps.parse_ecr_url.outputs.aws_account_id }} --repository-name=${{ inputs.image_name }} --image-ids imageTag="$RELEASE_TAG" > /dev/null 2>&1; then + echo "Release tag '$RELEASE_TAG' already exists in ECR repository '${{ inputs.image_name }}'. Release tags are immutable; pick a new version or delete the existing tag if this was unintended." + exit 1 + fi + aws ecr batch-get-image --registry-id=${{ steps.parse_ecr_url.outputs.aws_account_id }} --repository-name=${{ inputs.image_name }} --image-ids imageTag=${{ steps.calculate-build-context-hash.outputs.build_context_tag }} --query 'images[].imageManifest' --output text > manifest.json + aws ecr put-image --registry-id=${{ steps.parse_ecr_url.outputs.aws_account_id }} --repository-name=${{ inputs.image_name }} --image-tag "$RELEASE_TAG" --image-manifest file://manifest.json + + - name: Save Docker Image as tar + if: ${{ inputs.save-as-artifact }} + run: docker save -o ${{ steps.calculate-build-context-hash.outputs.image_name_no_slashes }}.tar ${{ steps.calculate-build-context-hash.outputs.full_image_tag }} + + - name: Upload Docker Image Artifact + if: ${{ inputs.save-as-artifact }} + uses: actions/upload-artifact@v7.0.1 + with: + name: ${{ steps.calculate-build-context-hash.outputs.image_name_no_slashes }} + path: ${{ steps.calculate-build-context-hash.outputs.image_name_no_slashes }}.tar + if-no-files-found: error + retention-days: ${{ inputs.artifact-retention-days }} diff --git a/.github/workflows/build-installer.yaml b/.github/workflows/build-installer.yaml new file mode 100644 index 0000000..9ee7ce3 --- /dev/null +++ b/.github/workflows/build-installer.yaml @@ -0,0 +1,108 @@ +# ============== WARNING ============================================================================== +# File is managed by copier template: gh:LabAutomationAndScreening/copier-nuxt-python-intranet-app.git +# See .config/.copier-managed-files.json for details. +# +# You are welcome to make changes to this file in your repo if they are custom to your project, +# but if the change should be shared with other projects, please backport it to the template repo. +# ===================================================================================================== +name: Build Windows Installer + +on: + workflow_dispatch: + workflow_call: + inputs: + artifact-run-id: + type: string + required: false + # Empty downloads from the current run (CI); a CI run id downloads + # cross-run (release pulling the bundle the suite validated). + default: '' + artifact-retention-days: + type: number + required: false + default: 14 + +permissions: + contents: read + actions: read # download-artifact needs this to pull a bundle from another run (release) + +jobs: + build-installer: + name: Build Windows Installer + runs-on: windows-2025-vs2026 + timeout-minutes: 45 + steps: + - uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + + - name: Set up .NET SDK + uses: actions/setup-dotnet@v6.0.0 + with: + dotnet-version: '8.0.424' + + - name: Install WiX 7 + shell: pwsh + run: | + dotnet tool install --global wix --version 7.0.0 + "$env:USERPROFILE\.dotnet\tools" | Out-File -FilePath $env:GITHUB_PATH -Append + & "$env:USERPROFILE\.dotnet\tools\wix.exe" eula accept wix7 + + - name: Resolve CI run id for the bundle + id: resolve-run + shell: bash + env: + GH_TOKEN: ${{ github.token }} + PROVIDED_RUN_ID: ${{ inputs.artifact-run-id }} + EVENT_NAME: ${{ github.event_name }} + run: | + if [ -n "$PROVIDED_RUN_ID" ]; then + # Release passes the guarded CI run explicitly. + echo "Using provided artifact-run-id: $PROVIDED_RUN_ID" + echo "run-id=$PROVIDED_RUN_ID" >> "$GITHUB_OUTPUT" + exit 0 + fi + if [ "$EVENT_NAME" != "workflow_dispatch" ]; then + # Called from ci.yaml within the same run (pull_request/push): the + # bundle was built earlier in this run, so use the current run id. + echo "Using current run id: ${{ github.run_id }}" + echo "run-id=${{ github.run_id }}" >> "$GITHUB_OUTPUT" + exit 0 + fi + # Standalone manual build (workflow_dispatch): no bundle in this run, so + # reuse the most recent successful ci.yaml run for this commit. + ALL_RUNS=$(gh api "/repos/${{ github.repository }}/actions/workflows/ci.yaml/runs?head_sha=${{ github.sha }}") + LATEST_RUN=$(echo "$ALL_RUNS" | jq '.workflow_runs | first') + RUN_ID=$(echo "$LATEST_RUN" | jq -r '.id') + STATUS=$(echo "$LATEST_RUN" | jq -r '.status') + CONCLUSION=$(echo "$LATEST_RUN" | jq -r '.conclusion') + echo "Most recent ci.yaml run for ${{ github.sha }}: id=$RUN_ID status=$STATUS conclusion=$CONCLUSION" + if [ "$RUN_ID" = "null" ] || [ "$STATUS" != "completed" ] || [ "$CONCLUSION" != "success" ]; then + echo "No successful ci.yaml run found for ${{ github.sha }}. Pass artifact-run-id or wait for CI to pass." >&2 + exit 1 + fi + echo "run-id=$RUN_ID" >> "$GITHUB_OUTPUT" + + - name: Download built bundle + uses: actions/download-artifact@v8.0.1 + with: + name: built-cloud-courier-windows-2025-vs2026 + path: backend/dist/cloud-courier + run-id: ${{ steps.resolve-run.outputs.run-id }} + github-token: ${{ github.token }} + + - name: Build installer + shell: pwsh + run: | + $buildArgs = @{} + ./installer/package.ps1 @buildArgs + + - name: Upload installer + uses: actions/upload-artifact@v7.0.1 + with: + name: cloud-courier-windows-2025-vs2026-installer + path: | + installer/dist/CloudCourier-*.msi + installer/dist/INSTALL.txt + if-no-files-found: error + retention-days: ${{ inputs.artifact-retention-days }} diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 3ea4f27..f4e7530 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -44,10 +44,6 @@ jobs: - id: check uses: ./.github/actions/check-skip-duplicates - confirm-on-tagged-copier-template: - if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }} - uses: ./.github/workflows/confirm-on-tagged-copier-template.yaml - lint: needs: - get-values @@ -60,8 +56,45 @@ jobs: id-token: write # needed to assume OIDC roles (e.g. for downloading from CodeArtifact) with: python-version: 3.13.9 + setup-node: true + node-version: 24.11.1 + + unit-test-frontend: + name: Unit Test Frontend + timeout-minutes: 8 + runs-on: ubuntu-24.04 + needs: + - lint + - check-skip-duplicate + if: needs.check-skip-duplicate.outputs.should-run == 'true' + steps: + - name: Checkout code + uses: actions/checkout@v7.0.1 + + - name: Install latest versions of packages + uses: ./.github/actions/install_deps + env: + SKIP_PLAYWRIGHT_INSTALL: "1" + with: + node-version: 24.11.1 + skip-installing-ssm-plugin-manager: true - test: + - name: Unit test + run: pnpm --dir=frontend test-unit + + - name: Upload test coverage on failure + if: ${{ failure() }} + uses: actions/upload-artifact@v7.0.1 + with: + name: frontend-test-coverage + path: frontend/.coverage + if-no-files-found: error + include-hidden-files: true + retention-days: 5 + + unit-test-backend: + name: Unit Test Backend + timeout-minutes: 8 needs: - lint - check-skip-duplicate @@ -70,66 +103,72 @@ jobs: matrix: os: - "ubuntu-24.04" + - windows-2025-vs2026 + runs-on: ${{ matrix.os }} + steps: + - name: Checkout code + uses: actions/checkout@v7.0.1 - python-version: + - name: Install latest versions of python packages + uses: ./.github/actions/install_deps + with: + python-version: 3.13.9 + skip-installing-ssm-plugin-manager: true - - 3.13.9 + - name: Unit test + run: uv --directory=backend run pytest tests/unit --durations=5 - include: - - os: "ubuntu-24.04" - python-version: "3.13.9" - JOB_MATCHING_DEV_ENV: true + - name: Upload test coverage on failure + if: ${{ failure() }} + uses: actions/upload-artifact@v7.0.1 + with: + name: backend-test-coverage-${{ matrix.os }} + path: backend/coverage-report-pytest + if-no-files-found: error + retention-days: 5 + + build-frontend: + needs: + - lint + - unit-test-frontend + - unit-test-backend + - check-skip-duplicate + if: needs.check-skip-duplicate.outputs.should-run == 'true' - runs-on: ${{ matrix.os }} timeout-minutes: 8 - env: - UV_PYTHON: ${{ matrix.python-version }} - services: - localstack: - image: localstack/localstack:4.14.0 - ports: - - 4566:4566 - env: - AWS_ACCESS_KEY_ID: test - AWS_SECRET_ACCESS_KEY: test - SERVICES: s3,sts,cloudwatch,ssm,events,iam + runs-on: ubuntu-24.04 steps: - name: Checkout code uses: actions/checkout@v7.0.1 - - name: Install python tooling + - name: Install latest versions of node packages uses: ./.github/actions/install_deps + env: + SKIP_PLAYWRIGHT_INSTALL: "1" with: + node-version: 24.11.1 skip-installing-ssm-plugin-manager: true - skip-installing-pulumi-cli: true - python-version: ${{ matrix.python-version }} - - - name: Create AWS profile - run: | - sh .devcontainer/create-aws-profile.sh - cat ~/.aws/config - - name: Unit test - run: uv run pytest tests/unit --cov-report=xml --durations=5 + - name: Build frontend + run: task build-frontend - - name: Upload coverage to Codecov - # only upload coverage from fastest job - if: ${{ matrix.JOB_MATCHING_DEV_ENV && github.actor != 'dependabot[bot]' }} # dependabot by default doesn't have access to the necessary secret...and dependabot should never be changing coverage anyway so it's fine not uploading it - uses: codecov/codecov-action@v7.1.0 + - name: Upload build artifact + uses: actions/upload-artifact@v7.0.1 with: - files: ./coverage.xml - flags: unittests - env_vars: OS,PYTHON - name: codecov-umbrella - fail_ci_if_error: true - token: ${{ secrets.CODECOV_TOKEN }} - slug: LabAutomationAndScreening/cloud-courier + name: frontend-static-assets + path: frontend/.output/public/**/* + if-no-files-found: error + retention-days: 5 + - executable: + build-backend: needs: - - test + - lint + - unit-test-frontend + - unit-test-backend + - build-frontend - check-skip-duplicate if: needs.check-skip-duplicate.outputs.should-run == 'true' strategy: @@ -137,68 +176,181 @@ jobs: os: - "ubuntu-24.04" - windows-2025-vs2026 - python-version: - - - 3.13.9 - - runs-on: ${{ matrix.os }} timeout-minutes: 8 - env: - UV_PYTHON: ${{ matrix.python-version }} + runs-on: ${{ matrix.os }} steps: - name: Checkout code uses: actions/checkout@v7.0.1 - - name: Install python tooling + - name: Download Frontend Static Assets + uses: actions/download-artifact@v8.0.1 + with: + name: frontend-static-assets + path: backend/src/static + + - name: Install latest versions of python packages uses: ./.github/actions/install_deps with: + python-version: 3.13.9 skip-installing-ssm-plugin-manager: true - skip-installing-pulumi-cli: true - python-version: ${{ matrix.python-version }} - - name: Build executable - run: uv run pyinstaller pyinstaller.spec --log-level=DEBUG - - name: Upload executable artifact + - name: Build application + run: task bundle-executable-from-staged-static + - name: Tar files to preserve permissions on Linux during artifact upload # https://github.com/actions/upload-artifact?tab=readme-ov-file#permission-loss + run: tar -cvf app.tar backend/dist/cloud-courier + - name: Upload application artifact (Windows) + if: runner.os == 'Windows' uses: actions/upload-artifact@v7.0.1 with: - name: exe-${{ matrix.os }}-${{ matrix.python-version }} - path: dist/ + name: built-cloud-courier-${{ matrix.os }} + path: backend/dist/cloud-courier/**/* if-no-files-found: error + retention-days: 5 + - name: Upload application artifact (Linux) + if: runner.os == 'Linux' + uses: actions/upload-artifact@v7.0.1 + with: + name: built-cloud-courier-${{ matrix.os }} + path: app.tar + if-no-files-found: error + retention-days: 5 + + test-compiled-frontend-dev: + name: Test the compiled Frontend for pnpm dev mode + timeout-minutes: 8 + runs-on: ubuntu-24.04 + needs: + - build-frontend + - build-backend + - check-skip-duplicate + if: needs.check-skip-duplicate.outputs.should-run == 'true' + steps: + - name: Checkout code + uses: actions/checkout@v7.0.1 + + - name: Install latest versions of node packages + uses: ./.github/actions/install_deps + with: + node-version: 24.11.1 + skip-installing-ssm-plugin-manager: true + - name: Test + run: pnpm --dir=frontend test-compiled-dev - build-docs: + e2e-test-windows-service: + name: Windows Service Smoke Tests + timeout-minutes: 15 + runs-on: windows-2025-vs2026 needs: - - lint + - build-backend - check-skip-duplicate if: needs.check-skip-duplicate.outputs.should-run == 'true' - strategy: - matrix: - python-version: + steps: + - name: Checkout code + uses: actions/checkout@v7.0.1 - - 3.13.9 + - name: Install latest versions of python packages + uses: ./.github/actions/install_deps + with: + python-version: 3.13.9 + skip-installing-ssm-plugin-manager: true + - name: Download Built executable + uses: actions/download-artifact@v8.0.1 + with: + name: built-cloud-courier-windows-2025-vs2026 + path: backend/dist/cloud-courier - runs-on: ubuntu-24.04 - timeout-minutes: 8 - env: - UV_PYTHON: ${{ matrix.python-version }} + - name: Windows service smoke tests + run: uv --directory=backend run pytest tests/windows_service --durations=5 --basetemp=dist/pytest-tmp + - name: Dump service crash logs on failure + if: ${{ failure() }} + shell: pwsh + run: | + $dumps = Get-ChildItem -Path "backend/dist/pytest-tmp" -Recurse -Filter "service-crash.log" -ErrorAction SilentlyContinue + if ($dumps.Count -eq 0) { + Write-Host "No service-crash.log files found under backend/dist/pytest-tmp" + } else { + foreach ($dump in $dumps) { + Write-Host "=== $($dump.FullName) ===" + Get-Content $dump.FullName + } + } + + - name: Upload service logs on failure + if: ${{ failure() }} + uses: actions/upload-artifact@v7.0.1 + with: + name: windows-service-logs + path: backend/dist/pytest-tmp/**/service-logs/** + if-no-files-found: warn + retention-days: 5 + + build-installer: + needs: + - build-backend + - check-skip-duplicate + if: needs.check-skip-duplicate.outputs.should-run == 'true' + permissions: + contents: read + actions: read # build-installer.yaml downloads the built bundle artifact + # Intentionally no `secrets: inherit` here (unlike the release job, which passes it when + # signing is enabled): CI builds an unsigned MSI as a build-breakage check; release signs it. + uses: ./.github/workflows/build-installer.yaml + + + e2e-test: + name: End-to-end Testing + timeout-minutes: 45 + strategy: + matrix: + os: + - "ubuntu-24.04" + - windows-2025-vs2026 + runs-on: ${{ matrix.os }} + needs: + - build-frontend + - build-backend + - check-skip-duplicate + if: needs.check-skip-duplicate.outputs.should-run == 'true' steps: - name: Checkout code uses: actions/checkout@v7.0.1 - - name: Install python tooling + - name: Install latest versions of node packages uses: ./.github/actions/install_deps with: - skip-installing-ssm-plugin-manager: true - skip-installing-pulumi-cli: true - python-version: ${{ matrix.python-version }} + node-version: 24.11.1 + - name: Download Built executable + uses: actions/download-artifact@v8.0.1 + with: + name: built-cloud-courier-${{ matrix.os }} + path: backend/dist/cloud-courier + - name: Untar the artifact (on Linux) + if: runner.os == 'Linux' + run: tar -xvf backend/dist/cloud-courier/app.tar -C . + + - name: E2E test + run: pnpm --dir=frontend test-e2e + + - name: Upload Playwright report on failure + if: ${{ failure() }} + uses: actions/upload-artifact@v7.0.1 + with: + name: playwright-report-${{ matrix.os }} + path: | + frontend/playwright-report + frontend/test-results + if-no-files-found: error + retention-days: 5 + - - name: Build docs - working-directory: ./docs - run: uv run make html SPHINXOPTS="-W" + confirm-on-tagged-copier-template: + if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }} + uses: ./.github/workflows/confirm-on-tagged-copier-template.yaml workflow-summary: runs-on: ubuntu-24.04 @@ -207,9 +359,14 @@ jobs: - get-values - check-skip-duplicate - lint - - test - - build-docs - - executable + - unit-test-frontend + - build-frontend + - unit-test-backend + - build-backend + - test-compiled-frontend-dev + - e2e-test-windows-service + - build-installer + - e2e-test - confirm-on-tagged-copier-template permissions: statuses: write # needed for updating status on Dependabot PRs diff --git a/.github/workflows/git_tag.py b/.github/workflows/git_tag.py index 9f99df3..adc1524 100644 --- a/.github/workflows/git_tag.py +++ b/.github/workflows/git_tag.py @@ -55,8 +55,8 @@ def main() -> None: if args.push_tag_to_remote: ensure_tag_not_present(tag, args.remote) - _ = subprocess.run(["git", "tag", tag], check=True) # noqa: S603,S607 # this is trusted input, it's our own version string. and if `git` isn't in PATH, then there are larger problems anyway - _ = subprocess.run(["git", "push", args.remote, tag], check=True) # noqa: S603,S607 # this is trusted input, it's our own version string. and if `git` isn't in PATH, then there are larger problems anyway + _ = subprocess.run(["git", "tag", tag], check=True) # noqa: S603,S607 # this is trusted input, it's our own pyproject.toml file. and if `git` isn't in PATH, then there are larger problems anyway + _ = subprocess.run(["git", "push", args.remote, tag], check=True) # noqa: S603,S607 # this is trusted input, it's our own pyproject.toml file. and if `git` isn't in PATH, then there are larger problems anyway return if args.confirm_tag_not_present: diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index be83fe3..0a7f29f 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -1,5 +1,5 @@ # ============== WARNING ============================================================================== -# File is managed by copier template: gh:LabAutomationAndScreening/copier-python-package-template +# File is managed by copier template: gh:LabAutomationAndScreening/copier-nuxt-python-intranet-app.git # See .config/.copier-managed-files.json for details. # # You are welcome to make changes to this file in your repo if they are custom to your project, @@ -22,20 +22,17 @@ concurrency: group: release cancel-in-progress: false -permissions: - contents: read # need to explicitly provide this whenever defining permissions because the default value is 'none' for anything not explicitly set when permissions are defined - jobs: guard: - name: Guard on a passing CI run runs-on: ubuntu-24.04 timeout-minutes: 2 permissions: - contents: read # needed for checkout + contents: read # needed for checkout and git ls-remote actions: read # needed to query CI workflow runs outputs: version: ${{ steps.get-version.outputs.version }} ci-run-id: ${{ steps.get-ci-run-id.outputs.ci-run-id }} + docker-image-tag: ${{ steps.set-docker-image-tag.outputs.docker-image-tag }} # empty on dry run to skip ECR tag push steps: - name: Fail if real release is not on main if: ${{ !inputs.dry_run && github.ref != 'refs/heads/main' }} @@ -75,71 +72,94 @@ jobs: fi - name: Checkout code uses: actions/checkout@v7.0.1 - - name: Setup python - uses: actions/setup-python@v7.0.0 - with: - python-version: 3.13.9 - name: Extract project version id: get-version run: | - VERSION=$(python3 .github/workflows/extract_project_version.py pyproject.toml) + VERSION=$(python3 .github/workflows/extract_project_version.py backend/pyproject.toml) echo "Project version: $VERSION" echo "version=$VERSION" >> $GITHUB_OUTPUT - name: Check version tag does not already exist run: | - if python3 .github/workflows/git_tag.py --version "${{ steps.get-version.outputs.version }}" --confirm-tag-not-present; then - echo "Tag v${{ steps.get-version.outputs.version }} does not exist yet." + VERSION="${{ steps.get-version.outputs.version }}" + if python3 .github/workflows/git_tag.py --version "$VERSION" --confirm-tag-not-present; then + echo "Tag v$VERSION does not exist yet." else if [ "${{ inputs.dry_run }}" = "true" ]; then - echo "Tag v${{ steps.get-version.outputs.version }} already exists (dry run, continuing)." + echo "Tag v$VERSION already exists (dry run, continuing)." else - echo "Tag v${{ steps.get-version.outputs.version }} already exists. Bump the version before releasing." + echo "Tag v$VERSION already exists. Bump the version before releasing." exit 1 fi fi + - name: Set Docker image tag + id: set-docker-image-tag + run: | + if [ "${{ inputs.dry_run }}" = "true" ]; then + echo "docker-image-tag=" >> $GITHUB_OUTPUT + else + echo "docker-image-tag=${{ steps.get-version.outputs.version }}" >> $GITHUB_OUTPUT + fi + build-installer: + needs: + - guard + permissions: + contents: read + actions: read # download-artifact pulls the tested bundle from the guarded CI run + uses: ./.github/workflows/build-installer.yaml + with: + artifact-run-id: ${{ needs.guard.outputs.ci-run-id }} + release: name: Create GitHub Release - if: ${{ !inputs.dry_run }} runs-on: ubuntu-24.04 timeout-minutes: 2 needs: - guard + - build-installer permissions: contents: write # needed to create GitHub releases - actions: read # needed to download the executable artifacts from the CI run + actions: read # needed to download artifacts from CI runs steps: - - name: Download executable artifact (ubuntu-24.04 py3.13.9) + + - name: Download executable artifact (ubuntu-24.04) uses: actions/download-artifact@v8.0.1 with: - name: exe-ubuntu-24.04-3.13.9 - path: artifacts/ubuntu-24.04-3.13.9 + name: built-cloud-courier-ubuntu-24.04 + path: artifacts/ubuntu-24.04 run-id: ${{ needs.guard.outputs.ci-run-id }} github-token: ${{ github.token }} - - name: Package executable (ubuntu-24.04 py3.13.9) - shell: bash - run: tar -cf "cloud-courier-ubuntu-24.04-3.13.9-v${{ needs.guard.outputs.version }}.tar" -C "artifacts/ubuntu-24.04-3.13.9" . + - name: Prepare release asset (ubuntu-24.04) + run: mv artifacts/ubuntu-24.04/app.tar cloud-courier-ubuntu-24.04-v${{ needs.guard.outputs.version }}.tar - - name: Download executable artifact (windows-2025-vs2026 py3.13.9) + - name: Download executable artifact (windows-2025-vs2026) uses: actions/download-artifact@v8.0.1 with: - name: exe-windows-2025-vs2026-3.13.9 - path: artifacts/windows-2025-vs2026-3.13.9 + name: built-cloud-courier-windows-2025-vs2026 + path: artifacts/windows-2025-vs2026 run-id: ${{ needs.guard.outputs.ci-run-id }} github-token: ${{ github.token }} - - name: Package executable (windows-2025-vs2026 py3.13.9) - shell: bash - run: (cd "artifacts/windows-2025-vs2026-3.13.9" && zip -r "../../cloud-courier-windows-2025-vs2026-3.13.9-v${{ needs.guard.outputs.version }}.zip" .) + - name: Prepare release asset (windows-2025-vs2026) + run: (cd artifacts/windows-2025-vs2026 && zip -r ../../cloud-courier-windows-2025-vs2026-v${{ needs.guard.outputs.version }}.zip .) + + - name: Download Windows installer (MSI + install guide) + uses: actions/download-artifact@v8.0.1 + with: + name: cloud-courier-windows-2025-vs2026-installer + path: artifacts/windows-installer + - name: Prepare release asset (Windows installer) + run: (cd artifacts/windows-installer && zip -r ../../cloud-courier-windows-2025-vs2026-installer-v${{ needs.guard.outputs.version }}.zip .) - name: Create GitHub Release + if: ${{ !inputs.dry_run }} uses: softprops/action-gh-release@v3.0.3 with: tag_name: v${{ needs.guard.outputs.version }} - target_commitish: ${{ github.sha }} name: cloud-courier v${{ needs.guard.outputs.version }} generate_release_notes: true fail_on_unmatched_files: true files: | - cloud-courier-ubuntu-24.04-3.13.9-v${{ needs.guard.outputs.version }}.tar - cloud-courier-windows-2025-vs2026-3.13.9-v${{ needs.guard.outputs.version }}.zip + cloud-courier-ubuntu-24.04-v${{ needs.guard.outputs.version }}.tar + cloud-courier-windows-2025-vs2026-v${{ needs.guard.outputs.version }}.zip + cloud-courier-windows-2025-vs2026-installer-v${{ needs.guard.outputs.version }}.zip diff --git a/.gitignore b/.gitignore index 4916f46..1829544 100644 --- a/.gitignore +++ b/.gitignore @@ -109,6 +109,9 @@ node_modules/ # Nuxt generate creates a symlink to the dist folder, which can't have trailing slash dist +# vacuum OpenAPI lint reports (generated ad-hoc; the linter runs against the committed snapshot) +vacuum-report-*.json + # Infrastructure as Code .terraform/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 288bebb..95dfd56 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -18,34 +18,33 @@ repos: # package management - repo: local hooks: - - id: uv-check - name: check pyproject.toml for valid uv config - entry: bash -c 'uv lock --check' - language: system - pass_filenames: false - verbose: true - files: "^pyproject.toml$|^uv.lock$" + - id: uv-check-backend + name: check backend pyproject.toml for valid uv config + entry: bash -c 'uv lock --check --directory=./backend' + language: system + pass_filenames: false + verbose: true + files: '^backend/pyproject.toml$|^backend/uv.lock$' - - id: sync-dependencies-on-commit - name: ensure python dependencies are up to date during commits - stages: - [pre-commit] - # When the install does change things, it exits 0, so needed to check for the phrase 'operations' in stdout in order to cause things pre-commit command to fail - entry: python -c "import subprocess,sys; results=subprocess.run(['uv', 'sync', '--frozen'],timeout=360,capture_output=True); print ('stdout- ' + results.stdout.decode('utf-8', 'strict')); print ('stderr- ' + results.stderr.decode('utf-8', 'strict')); sys.exit(int(results.returncode or int('Installed' in results.stdout.decode('utf-8', 'strict'))));" - # don't pass filenames else the command line sees them twice - pass_filenames: false - language: system - files: "uv.lock$" - verbose: true + - id: sync-dependencies-on-commit-backend + name: ensure backend python dependencies are up to date during commits + stages: [pre-commit] + # When the install does change things, it exits 0, so needed to check for the phrase 'operations' in stdout in order to cause things pre-commit command to fail + entry: python -c "import subprocess,sys; results=subprocess.run(['uv', 'sync', '--frozen', '--directory=./backend'],timeout=360,capture_output=True); print ('stdout- ' + results.stdout.decode('utf-8', 'strict')); print ('stderr- ' + results.stderr.decode('utf-8', 'strict')); sys.exit(int(results.returncode or int('Installed' in results.stdout.decode('utf-8', 'strict'))));" + # don't pass filenames else the command line sees them twice + pass_filenames: false + language: system + files: 'backend/uv.lock$' + verbose: true - - id: sync-dependencies-post-checkout - name: ensure python dependencies are up to date during checkouts - stages: [post-checkout] - entry: bash -c 'uv sync --frozen' - pass_filenames: false - language: system - always_run: true - verbose: true + - id: sync-dependencies-post-checkout-backend + name: ensure backend python dependencies are up to date during checkouts + stages: [post-checkout] + entry: bash -c 'uv sync --frozen --directory=./backend' + pass_filenames: false + language: system + always_run: true + verbose: true # Reformatting (should generally come before any file format or other checks, because reformatting can change things) - repo: https://github.com/crate-ci/typos @@ -71,7 +70,8 @@ repos: )$ - id: end-of-file-fixer # the XML formatter hook doesn't leave a blank line at the end, so excluding XML files from this hook to avoid conflicts - exclude: | # the extra trailing newline in the pull request template makes it easier to click there in the github console. The CLI that programmatically generates devcontainer-lock.json always ends the file incompatible with this hook. + # the extra trailing newline in the pull request template makes it easier to click there in the github console. The CLI that programmatically generates devcontainer-lock.json always ends the file incompatible with this hook. + exclude: | (?x)^( \.github/pull_request_template.md| template/\.github/pull_request_template.md| @@ -95,7 +95,6 @@ repos: .*generated/graphql.ts| .*/generated/open[-_]api/.*| .*/schema.graphql| - .*pyrightconfig\.json| )$ - repo: https://github.com/rbubley/mirrors-prettier @@ -122,7 +121,6 @@ repos: .*/schema.graphql| .*/generated/open[-_]api/.*| .*generated/graphql.ts| - template/.*| )$ files: (.*.json)|(.*.ts)|(.*.jsx)|(.*.tsx)|(.*.yaml)|(.*.yml)|(.*.md)|(.*.html)|(.*.css)|(.*.scss)|(.*.less)|(.*.vue)|(.*.graphql)|(.*.gql) @@ -144,7 +142,7 @@ repos: hooks: - id: check-added-large-files args: ["--maxkb=123"] - exclude: (uv.lock)|(pnpm-lock.yaml) + exclude: (uv.lock)|(pnpm-lock.yaml)|(frontend/public/favicon.ico)|(backend/src/static/favicon.ico)|(static.*graphiql.*react-dom.production.min.js) - id: check-json # exclude JSON5 or JSONC files with comments exclude: | @@ -152,7 +150,6 @@ repos: .devcontainer/devcontainer.json| .devcontainer/devcontainer-lock.json| .*devcontainer.json| - .*pyrightconfig\.json| .*tsconfig.json| )$ - id: check-yaml @@ -177,9 +174,9 @@ repos: - id: check-github-workflows - id: check-jsonschema name: check taskfiles - # Task itself silently ignores unknown keys, so a typo'd key is only caught by the schema. + # Task itself silently ignores unknown keys, so a typo'd key is only caught by the schema args: [--schemafile, 'https://taskfile.dev/schema.json'] - files: ^(Taskfile\.yaml|(template/)?\.config/taskfiles/[^/{]+\.yaml)$ + files: ^(Taskfile\.yaml|\.config/taskfiles/[^/]+\.yaml)$ - repo: https://github.com/maresb/check-json5 rev: bd4737432a2175617a9eeaa510ab369cbc1cbd3d # frozen: v1.0.1 @@ -207,13 +204,62 @@ repos: hooks: - id: detect-private-key - # - repo: https://github.com/woodruffw/zizmor-pre-commit # TODO: implement this: https://github.com/LabAutomationAndScreening/copier-base-template/issues/95 - # rev: b933184438555436e38621f46ceb0c417cbed400 # frozen: v1.13.0 - # hooks: - # - id: zizmor +# - repo: https://github.com/woodruffw/zizmor-pre-commit +# rev: b933184438555436e38621f46ceb0c417cbed400 # frozen: v1.13.0 +# hooks: +# - id: zizmor + + - repo: local + hooks: + - id: vacuum-openapi + name: vacuum OpenAPI schema lint + # Lints the generated OpenAPI snapshot against the curated ruleset. + # `language: node` with additional_dependencies lets pre-commit install vacuum in an + # isolated env, so this runs in CI without any global/devcontainer install. Fails on warn-or-worse. + entry: vacuum lint --no-banner --no-style --fail-severity warn --min-score 100 -d --no-message --no-clip -f .config/vacuum-functions -r .config/vacuum-ruleset.yaml --ignore-file .config/vacuum-ignore.yaml backend/tests/unit/__snapshots__/test_basic_server_functionality/test_openapi_schema.json + language: node + additional_dependencies: ['@quobix/vacuum@0.30.0'] + files: | + (?x)^( + backend/tests/unit/__snapshots__/test_basic_server_functionality/test_openapi_schema\.json| + .config/vacuum-ruleset\.yaml| + .config/vacuum-ignore\.yaml| + .config/vacuum-functions/.*\.js + )$ + pass_filenames: false + require_serial: true + verbose: true # Linting + - repo: local + hooks: + - id: typescript-check + name: typescript-check + entry: bash -c "pnpm --dir frontend type-check" + files: '.+\.ts$|.+\.vue$' + # don't pass filenames else the command line sees them twice + pass_filenames: false + language: system + # use require_serial so that script is only called once per commit + require_serial: true + # print the number of files as a sanity-check + verbose: true + + - repo: local + hooks: + - id: eslint + name: eslint + entry: bash -c "pnpm --dir frontend lint" + files: '.+\.ts$|.+\.vue$|.+\.js$' + # don't pass filenames else the command line sees them twice + pass_filenames: false + language: system + # use require_serial so that script is only called once per commit + require_serial: true + # print the number of files as a sanity-check + verbose: true + - repo: https://github.com/Lucas-C/pre-commit-hooks-markup rev: 501f3d60cee13c712492103343bc23efdc7b3d1f #v1.0.1 hooks: @@ -243,7 +289,6 @@ repos: (?x)^( .*/generated/graphql/.*| .*/generated/open[-_]api/.*| - template/.*| )$ - id: ruff name: ruff-non-src # tests, scripts and other non-source code @@ -253,7 +298,6 @@ repos: (?x)^( .*/generated/graphql/.*| .*/generated/open[-_]api/.*| - template/.*| )$ - id: ruff-format args: [--config=./.config/ruff.toml] @@ -270,13 +314,11 @@ repos: name: pylint # exclude any generated code # flagging duplicate code for __all__ lists is a false positive - # exclude the template files---duplication within them will be discovered during CI of that template instantiation exclude: | (?x)^( .*/generated/graphql/.*| .*/generated/open[-_]api/.*| .*__init__\.py| - template/.*| )$ args: - --rcfile=./.config/pylintrc.toml @@ -286,7 +328,7 @@ repos: hooks: - id: pyrefly name: pyrefly - entry: bash -c "uv run pyrefly check -c ./.config/pyrefly.toml" + entry: bash -c "uv --directory=backend run pyrefly check -c ../.config/pyrefly.toml" files: '.+\.py$' exclude: | (?x)^( @@ -302,25 +344,6 @@ repos: # print the number of files as a sanity-check verbose: true - - repo: local - hooks: - - id: pyright-on-tests - name: Run pyright on tests of the library # to mimic a downstream consumer of the library who uses pyright as their type checker, see https://pyrefly.org/blog/too-many-type-checkers/ - entry: bash -c "uv run pyright" - files: '(.+\.py|pyproject\.toml|pyrightconfig\.json)$' - exclude: | - (?x)^( - .*/generated/.*| - )$ - # don't pass filenames else the command line sees them twice - pass_filenames: false - language: system - types: [python] - # use require_serial so that script is only called once per commit - require_serial: true - # print the number of files as a sanity-check - verbose: true - # Updating repo config/tooling files - repo: local hooks: @@ -328,13 +351,12 @@ repos: # Keep Claude's settings.json synced with the JSON5 files. It's only for local development, so don't run it in CI name: merge Claude settings entry: bash -c '[[ "${GITHUB_ACTIONS:-}" == "true" || "${CI:-}" == "true" ]] && exit 0; bash .claude/helpers/merge-claude-settings.sh' - files: ^\.claude/settings/.*\.(json|jsonc)$ + files: .claude/settings/.*\.(json|jsonc)$ pass_filenames: false language: system - id: task-list - # Schema validation cannot see across files: only Task itself resolves the include paths in the root shim and - # rejects a task name defined by two flattened includes. Listing is enough to trigger both errors. + # Schema validation cannot see across files: only Task itself resolves the include paths in the root shim and rejects a task name defined by two flattened includes name: validate taskfile includes and task names entry: bash -c "task --list-all > /dev/null" files: ^(Taskfile\.yaml|\.config/taskfiles/.*)$ diff --git a/AGENTS.md b/AGENTS.md index 793fedd..498a8ae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,8 @@ # Project Structure -This project is a Python library. +- This is a statically generated frontend---using the Nuxt and @nuxt/ui frameworks---meant to operate in an air-gapped environment. That code is in the `frontend/` directory. +- There may also be a backend that the frontend interacts with, in `backend/`. If present, it will be a Python FastAPI uvicorn server. +- Kiota is used for codegen from the OpenAPI schema # Code Guidelines @@ -64,11 +66,22 @@ This project is a Python library. - **Never hand-edit syrupy snapshot files.** Snapshots are auto-generated — to create or update them, run `uv run pytest --snapshot-update --no-cov`. A missing snapshot causes the test to fail, which is expected until you run with `--snapshot-update`. When a snapshot mismatch occurs, fix the code if the change was unintentional; run `--snapshot-update` if it was intentional. - **Never hand-write or hand-edit pytest-reserial `.jsonl` recording files.** Recordings must be captured from real serial port traffic by running the test with `--record` while the device is connected: `uv run pytest --record --no-cov`. The default mode replays recordings — a missing recording causes an error, which is expected until recorded against a live device. +#### FastAPI Testing + +- Assert on the response model, not raw JSON: `problem = ProblemDetails.model_validate(response.json())`, then `assert problem.error_type == ...` rather than `response.json()["errorType"]`. String keys are invisible to the type checker and yield implicit `Any`, and validating checks the whole payload against the schema (camelCase aliases included) rather than only the keys the test reads. + ### Frontend Testing - When a `data-testid` identifies one of many rendered entities, interpolate that entity's stable identifier as the dynamic value, not its display label — prefer an ID (`item.itemId`, `record.sha`) whenever the entity has one, since labels collide and change. Where the identifier *is* human-readable and no ID exists, that name is the key. - In DOM-based unit tests, scope queries to the tightest relevant container. Only query `document` or `document.body` directly to find the top-level portal/popup element (e.g. a Reka UI dialog via `[role="dialog"][data-state="open"]`); all further queries should run on that element, not on `document.body` again. Browser automation (e.g. Playwright) fails an ambiguous single-target locator outright, so a unique `data-testid` looked up from the page is enough there. +## FastAPI +- Use `fastapi.status` constants (e.g., `status.HTTP_204_NO_CONTENT`) instead of raw integers for status codes +- All HTTP request and response payloads must use camelCase field names in JSON. Use Pydantic's `alias_generator=to_camel` with `populate_by_name=True` on models so that Python code uses snake_case internally while the wire format uses camelCase. +- When a route raises an `HTTPException` for a specific status code, document it in the route decorator's `responses` parameter using `fastapi.status` constants: `responses={status.HTTP_404_NOT_FOUND: {"description": "Resource not found", "content": {"application/problem+json": {"example": {"detail":"ID xyz was not found"},"schema": {"$ref": "#/components/schemas/ProblemDetails"}}}}}`. This ensures the OpenAPI schema reflects all possible responses, not just the success case. +- Do not apply the keyword-only parameter rule (`*`) to FastAPI route function parameters — FastAPI resolves them via dependency injection and the functions are never called directly from Python code, so keyword-only syntax has no effect. +- The prohibition of one-line docstrings does not apply to docstrings that become OpenAPI schema text (component-schema models and route handlers). + # Agent Implementations & Configurations ## Memory and Rules @@ -81,6 +94,7 @@ This project is a Python library. - Before hand-assembling a multi-step workflow, run `task --list` — it is probably already a task. Definitions live in `.config/taskfiles/`; the root `Taskfile.yaml` only includes them. - The bans on `pnpm --prefix`, `uv --directory` and direct tool invocation apply to commands you type, not to task definitions: a task's commands run with the repo root as their working directory by default. Prefer adding or extending a task over typing the long form. - Linting and type-checking stay with `pre-commit run ` rather than a task, so that what you run is exactly what CI runs. +- Regenerating any generated client is `task codegen` (or an individual `gen-*-client` task from `task --list`) — never hand-edit anything under a `generated/` folder. - `cd` into a subdirectory is auto-approved; navigating up (`cd ..`) or to an absolute path (`cd /some/path`) requires a user permission prompt. Minimize such navigation: run `pre-commit` from whichever subdirectory you're already in (it walks up to find `.pre-commit-config.yaml`). - ❌ Never use `python3` or `python` directly. ✅ Always use `uv run python` for Python commands. - ❌ Never use `python3`/`python` for one-off data tasks. ✅ Use `jq` for JSON parsing, standard shell builtins for string manipulation. Only reach for `uv run python` when no dedicated tool covers the need. diff --git a/README.md b/README.md index 91c2020..b46f3c4 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,67 @@ [![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white)](https://github.com/pre-commit/pre-commit) +[![Open in Dev Containers](https://img.shields.io/static/v1?label=Dev%20Containers&message=Open&color=blue)](https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://www.github.com/LabAutomationAndScreening/cloud-courier) [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) [![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv) -[![Checked with pyright](https://microsoft.github.io/pyright/img/pyright_badge.svg)](https://microsoft.github.io/pyright/) -[![Actions status](https://github.com/LabAutomationAndScreening/cloud-courier/actions/workflows/ci.yaml/badge.svg?branch=main)](https://github.com/LabAutomationAndScreening/cloud-courier/actions) -[![Open in Dev Containers](https://img.shields.io/static/v1?label=Dev%20Containers&message=Open&color=blue)](https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/LabAutomationAndScreening/cloud-courier) -[![Codecov](https://codecov.io/gh/LabAutomationAndScreening/cloud-courier/branch/main/graph/badge.svg)](https://codecov.io/gh/LabAutomationAndScreening/cloud-courier) -[![Documentation Status](https://readthedocs.org/projects/cloud-courier/badge/?version=latest)](https://cloud-courier.readthedocs.io/en/latest/?badge=latest) -[![OpenIssues](https://isitmaintained.com/badge/open/LabAutomationAndScreening/cloud-courier.svg)](https://isitmaintained.com/project/LabAutomationAndScreening/cloud-courier) +[![Checked with pyrefly](https://img.shields.io/endpoint?url=https://pyrefly.org/badge.json)](https://pyrefly.org/) +[![PNPM](https://img.shields.io/badge/pnpm-%234a4a4a.svg?style=flat&logo=pnpm&logoColor=f69220)](https://pnpm.io/) +[![Nuxtjs](https://img.shields.io/badge/Nuxt-002E3B?style=flat&logo=nuxtdotjs&logoColor=#00DC82)](https://nuxt.com/) +[![TypeScript](https://img.shields.io/badge/typescript-%23007ACC.svg?style=flat&logo=typescript&logoColor=white)](https://www.typescriptlang.org/) -# Usage -Documentation is hosted on [ReadTheDocs](https://cloud-courier.readthedocs.io/en/latest/?badge=latest). # Development -Multi-step workflows are defined as [Task](https://taskfile.dev) tasks. Run `task --list` to see them; the definitions live in `.config/taskfiles/`, and `Taskfile.yaml` in the repo root is only a shim that includes them. +Every multi-step workflow is defined as a [Task](https://taskfile.dev) task, so run `task --list` to see what is available. The definitions live in `.config/taskfiles/`; the root `Taskfile.yaml` only includes them. -This project has a dev container. If you already have VS Code and Docker installed, you can click the badge above or [here](https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/LabAutomationAndScreening/cloud-courier) to get started. Clicking these links will cause VS Code to automatically install the Dev Containers extension if needed, clone the source code into a container volume, and spin up a dev container for use. +To build and spin up the full stack (accessible on port 4000), run: +```bash +task run-stack +``` +That generates the frontend, copies it into the backend static directory, builds the executable, and runs it. -The `Release` workflow reuses the checks from CI instead of re-running them: it requires that the `CI` workflow has already completed successfully for the current commit (including the `workflow-summary` job) and reuses the artifacts CI built rather than rebuilding. Trigger it manually with `workflow_dispatch`; a real release must be run from `main`, while the `dry_run` option lets you exercise the workflow from any branch without tagging or releasing. A real run pushes the `v` git tag and creates a GitHub Release, attaching the executable that CI built for every OS and Python version. +To just rebuild the frontend and swap it into the already-built executable, run: +```bash +task reload-frontend-in-stack +``` +## Frontend +Start the development server on `http://localhost:3000`: +```bash +task dev-frontend +``` +Build the application for production: +```bash +task build-frontend +``` +Locally preview production build: +```bash +task preview-frontend +``` +## Backend +Start the API server on port 4000 (will not generate log files): +```bash +task dev-backend +``` + +Start the app using the entrypoint to be able to supply CLI options and log to file (running by default on port 4000): +```bash +task run-backend-entrypoint +``` + +## Codegen +To regenerate every generated client in this repo, run: + +```bash +task codegen +``` + +That covers both sides of the app and all schema sources, refreshing the openapi.json snapshot before any client is generated from it. Run `task --list` for the individual tasks if you only need one of them. This is the only sanctioned way to update generated code: nothing under a `generated/` folder should ever be hand-edited. ## Updating from the template -This repository uses a copier template. To pull in the latest updates from the template, run `task copier-update` +This repository uses a copier template. To pull in the latest updates from the template, run `task copier-update`. + diff --git a/frontend/app/pages/index.vue b/frontend/app/pages/index.vue new file mode 100644 index 0000000..df181c7 --- /dev/null +++ b/frontend/app/pages/index.vue @@ -0,0 +1,12 @@ + + diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs new file mode 100644 index 0000000..aae8e97 --- /dev/null +++ b/frontend/eslint.config.mjs @@ -0,0 +1,14 @@ +/* + * ============== WARNING ============================================================================== + * File is managed by copier template: gh:LabAutomationAndScreening/copier-nuxt-python-intranet-app.git + * See .config/.copier-managed-files.json for details. + * + * You are welcome to make changes to this file in your repo if they are custom to your project, + * but if the change should be shared with other projects, please backport it to the template repo. + * ===================================================================================================== + */ +// @ts-check + +import withNuxt from "./.nuxt/eslint.config.mjs"; +import { sharedConfigs } from "./eslint.shared.mjs"; +export default withNuxt(...sharedConfigs); diff --git a/frontend/eslint.shared.mjs b/frontend/eslint.shared.mjs new file mode 100644 index 0000000..fc2f219 --- /dev/null +++ b/frontend/eslint.shared.mjs @@ -0,0 +1,158 @@ +/* + * ============== WARNING ============================================================================== + * File is managed by copier template: gh:LabAutomationAndScreening/copier-nuxt-python-intranet-app.git + * See .config/.copier-managed-files.json for details. + * + * You are welcome to make changes to this file in your repo if they are custom to your project, + * but if the change should be shared with other projects, please backport it to the template repo. + * ===================================================================================================== + */ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import vitest from "@vitest/eslint-plugin"; + +// Consumers render this file at different depths below the repo root, so no fixed number of `../` +// reaches `.config/eslint-rules/` for all of them. +function findLocalRulesDir() { + let dir = path.dirname(fileURLToPath(import.meta.url)); + for (;;) { + const candidate = path.join(dir, ".config", "eslint-rules"); + if (fs.statSync(candidate, { throwIfNoEntry: false })?.isDirectory() === true) return candidate; + if (fs.existsSync(path.join(dir, ".git"))) { + throw new Error(`Could not find .config/eslint-rules under the project root ${dir}`); + } + const parent = path.dirname(dir); + if (parent === dir) { + throw new Error(`Could not find .config/eslint-rules above ${import.meta.url}`); + } + dir = parent; + } +} + +async function loadLocalRules() { + const rulesDir = findLocalRulesDir(); + const entries = fs.readdirSync(rulesDir).filter((entry) => entry.endsWith(".mjs")); + const rules = {}; + for (const entry of entries) { + const ruleName = entry.slice(0, -".mjs".length); + const module = await import(pathToFileURL(path.join(rulesDir, entry)).href); + if (module.default === undefined) { + throw new Error(`Local ESLint rule module ${entry} has no default export`); + } + rules[ruleName] = module.default; + } + return rules; +} + +const localRules = await loadLocalRules(); + +/** @type {import("eslint").Linter.Config} */ +export const istanbulIgnoreIfMustThrowConfig = { + files: ["**/*.{ts,vue}"], + plugins: { + local: { + rules: localRules, + }, + }, + rules: { + "local/istanbul-ignore-if-must-throw": "error", + }, +}; + +export const ignoresConfig = { + ignores: [ + "**/generated/**", // ignore linting autogenerated files + ".claude/**", // Claude folder often contains 3rd party scripts + ], +}; + +/** @type {import("eslint").Linter.Config} */ +export const vueScriptLangConfig = { + // Disallow