Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
version: 2.1

# CircleCI for TinyPilot Dashboard. Jobs mirror Cloud/Pro gates that apply
# to this Flask app (tests, lint, ShellCheck, JS syntax, SAST, pip-audit).
# Appliance-only jobs (Debian/Packer/HCL/device e2e) are omitted.

executors:
ubuntu:
docker:
- image: cimg/base:2024.02
python:
docker:
- image: cimg/python:3.12
node:
docker:
- image: cimg/node:24.13.1

jobs:
check_whitespace:
executor: ubuntu
steps:
- checkout
- run:
name: Check for trailing whitespace
command: ./dev-scripts/check-trailing-whitespace
- run:
name: Check that all text files end in a trailing newline
command: ./dev-scripts/check-trailing-newline

check_bash:
executor: ubuntu
steps:
- checkout
- run:
name: Install ShellCheck
command: |
set -eux
readonly SHELLCHECK_VERSION='v0.9.0'
wget --quiet --output-document - \
"https://github.com/koalaman/shellcheck/releases/download/${SHELLCHECK_VERSION}/shellcheck-${SHELLCHECK_VERSION}.linux.x86_64.tar.xz" |
tar --extract --xz --verbose
sudo cp "./shellcheck-${SHELLCHECK_VERSION}/shellcheck" /usr/bin
shellcheck --version
- run:
name: Run ShellCheck
command: ./dev-scripts/check-bash

check_python:
executor: python
steps:
- checkout
- run:
name: Install Python requirements
command: |
python3 -m venv venv
. venv/bin/activate
pip install --upgrade pip
pip install \
--requirement requirements.txt \
--requirement requirements_dev.txt
- run:
name: Run check script
command: |
. venv/bin/activate
./dev-scripts/check-python

check_javascript:
executor: node
steps:
- checkout
- run:
name: Syntax-check JavaScript
command: ./dev-scripts/check-javascript

check_security:
executor: python
steps:
- checkout
- run:
name: Create virtual environment
command: python3 -m venv venv
- run:
name: Install requirements
command: |
. venv/bin/activate
pip install --upgrade pip
pip install \
--requirement requirements.txt \
--requirement requirements_dev.txt
pip install 'semgrep==1.163.0' 'pip-audit>=2.7,<3.0'
- run:
name: Run Static Application Security Testing (SAST)
command: |
. venv/bin/activate
./dev-scripts/check-security

workflows:
test:
jobs:
- check_whitespace
- check_bash
- check_python
- check_javascript
- check_security
34 changes: 34 additions & 0 deletions .ruff.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
src = ["app", "tests"]
target-version = "py312"

[lint]
select = [
"F", # pyflakes
"I", # isort
"Q", # flake8-quotes
"S", # flake8-bandit (security)
]
ignore = [
"D100",
"D101",
"D102",
"D103",
"D104",
"D105",
"D106",
"D107",
]

[lint.flake8-quotes]
avoid-escape = false
docstring-quotes = "double"
inline-quotes = "single"
multiline-quotes = "double"

[lint.isort]
force-single-line = true

[lint.per-file-ignores]
"tests/**/*.py" = ["S101", "S105", "S106"]
"run.py" = ["S104"]
"app/__init__.py" = ["S106"]
9 changes: 9 additions & 0 deletions .semgrepignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Generated / local
node_modules/
venv/
.venv/
.pytest_cache/
data/

# Tests use fixtures and static source greps, not production request paths.
tests/
1 change: 1 addition & 0 deletions THIRD_PARTY_NOTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ This project depends on the following third-party Python packages. Each is distr
## Runtime dependencies

- **Flask** — BSD-3-Clause — <https://github.com/pallets/flask>
- **waitress** — ZPL-2.1 — <https://github.com/Pylons/waitress>
- **requests** — Apache-2.0 — <https://github.com/psf/requests>
- **urllib3** — MIT — <https://github.com/urllib3/urllib3>
- **cryptography** — Apache-2.0 OR BSD-3-Clause — <https://github.com/pyca/cryptography>
Expand Down
7 changes: 4 additions & 3 deletions app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,15 @@
from flask import send_from_directory

from app.api import api_blueprint
from app.db import close_db, init_db
from app.db import close_db
from app.db import init_db

# Public product version. Version bands signal lifecycle stage to customers:
# 0.1.x / 0.2.x -> alpha
# 0.1.x / 0.2.x / 0.3.x -> alpha
# 0.5.x -> beta
# 1.0.0 -> first stable release
# Bump this on every customer-visible release.
__version__ = '0.2.1'
__version__ = '0.3.0'


def create_app(test_config=None):
Expand Down
119 changes: 81 additions & 38 deletions app/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,15 @@
from pathlib import Path
from typing import Optional

import requests
from flask import Blueprint
from flask import current_app
from flask import jsonify
from flask import request
from flask import send_file

from app.auth_store import encrypt_secret
from app.auth_store import decrypt_secret
from app.auth_store import encrypt_secret
from app.db import get_db
from app.device_update import can_start_update
from app.device_update import update_available
Expand Down Expand Up @@ -224,7 +225,9 @@ def refresh_screenshot(device_id: int):
try:
screenshot = client.get_screenshot()
except Exception as err: # pylint: disable=broad-exception-caught
return jsonify({'error': f'failed to refresh screenshot: {err}'}), 502
return jsonify(
{'error': f'failed to refresh screenshot: {_customer_error(err)}'}
), 502

db = get_db()
data_root = Path(current_app.config['DATABASE_PATH']).resolve().parent
Expand Down Expand Up @@ -324,7 +327,25 @@ def _safe_fetch(fetcher):
try:
return fetcher(), None
except Exception as err: # pylint: disable=broad-exception-caught
return None, str(err)
return None, err


def _is_unreachable(err) -> bool:
return isinstance(err, (requests.Timeout, requests.ConnectionError))


def _customer_error(err):
if err is None:
return None
current_app.logger.warning('TinyPilot request failed: %s', err)
if _is_unreachable(err):
return 'Could not reach this TinyPilot.'
if isinstance(err, requests.HTTPError):
status = err.response.status_code if err.response is not None else None
if status:
return f'TinyPilot returned an error (HTTP {status}).'
return 'TinyPilot returned an error.'
return 'Could not complete this request.'


@api_blueprint.get('/devices/<int:device_id>/device/snapshot')
Expand Down Expand Up @@ -360,32 +381,48 @@ def get_device_snapshot(device_id: int):
client = TinyPilotClient(row['base_url'], api_key=api_key)

# API-key allowlist (Pro 3.2.0+): version, network, video, /state, screenshot,
# latestRelease, update status/start.
status, status_error = _safe_fetch(client.get_status)
version, version_error = _safe_fetch(client.get_version)
network, network_error = _safe_fetch(client.get_network_status)
video, video_error = _safe_fetch(client.get_video_settings)
automation_state, automation_state_error = _safe_fetch(client.get_automation_state)
latest_release, latest_release_error = _safe_fetch(client.get_latest_release)
update_job, update_job_error = _safe_fetch(client.get_update_status)

last_error = (
status_error
or version_error
or network_error
or video_error
or automation_state_error
# latestRelease, update status/start. Stop after the first unreachable call.
fetched = {
'status': (None, None),
'version': (None, None),
'network': (None, None),
'video': (None, None),
'automation_state': (None, None),
'latest_release': (None, None),
'update_job': (None, None),
}
fetchers = (
('status', client.get_status),
('version', client.get_version),
('network', client.get_network_status),
('video', client.get_video_settings),
('automation_state', client.get_automation_state),
('latest_release', client.get_latest_release),
('update_job', client.get_update_status),
)

online = any(
error is None
for error in (
status_error,
version_error,
network_error,
video_error,
automation_state_error,
)
for name, fetcher in fetchers:
value, err = _safe_fetch(fetcher)
fetched[name] = (value, err)
if _is_unreachable(err):
break

status, status_error = fetched['status']
version, version_error = fetched['version']
network, network_error = fetched['network']
video, video_error = fetched['video']
automation_state, automation_state_error = fetched['automation_state']
latest_release, latest_release_error = fetched['latest_release']
update_job, update_job_error = fetched['update_job']

probe_errors = (
status_error,
version_error,
network_error,
video_error,
automation_state_error,
)
online = any(error is None for error in probe_errors) and not any(
_is_unreachable(error) for error in probe_errors
)

connected_resolution = (
Expand All @@ -407,18 +444,20 @@ def get_device_snapshot(device_id: int):
}

expanded = {
'reachability': {'status': status, 'error': status_error},
'version': {'status': version, 'error': version_error},
'network': {'data': network or {}, 'error': network_error},
'video_settings': {'status': video, 'error': video_error},
'automation_state': {'status': automation_state, 'error': automation_state_error},
'reachability': {'status': status, 'error': _customer_error(status_error)},
'version': {'status': version, 'error': _customer_error(version_error)},
'network': {'data': network or {}, 'error': _customer_error(network_error)},
'video_settings': {'status': video, 'error': _customer_error(video_error)},
'automation_state': {
'status': automation_state,
'error': _customer_error(automation_state_error),
},
'connected_device_resolution': connected_resolution,
'last_management_error': last_error,
'software_update': {
'latest': latest_release,
'latest_error': latest_release_error,
'latest_error': _customer_error(latest_release_error),
'job': update_job,
'job_error': update_job_error,
'job_error': _customer_error(update_job_error),
'update_available': update_available(current_version, latest_version),
'can_start': can_start_update(
current_version, latest_version, license_check_status
Expand All @@ -444,7 +483,9 @@ def get_device_update_status(device_id: int):
try:
result = client.get_update_status()
except Exception as exc: # pylint: disable=broad-exception-caught
return jsonify({'error': f'failed to fetch update status: {exc}'}), 502
return jsonify(
{'error': f'failed to fetch update status: {_customer_error(exc)}'}
), 502
return jsonify(result)


Expand All @@ -460,5 +501,7 @@ def start_device_update(device_id: int):
try:
client.start_update(version)
except Exception as exc: # pylint: disable=broad-exception-caught
return jsonify({'error': f'failed to start update: {exc}'}), 502
return jsonify(
{'error': f'failed to start update: {_customer_error(exc)}'}
), 502
return jsonify({'device_id': device_id, 'update_started': True, 'version': version})
7 changes: 4 additions & 3 deletions app/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
import sqlite3
from pathlib import Path

from flask import current_app, g
from flask import current_app
from flask import g


def get_db() -> sqlite3.Connection:
Expand Down Expand Up @@ -95,8 +96,8 @@ def _ensure_column(
``table``, ``column``, and ``column_def`` come from constants in this
module, never from user input, so the f-string ``ALTER TABLE`` is safe.
"""
existing = db.execute(f"PRAGMA table_info({table})").fetchall()
existing = db.execute(f'PRAGMA table_info({table})').fetchall()
existing_names = {row['name'] for row in existing}
if column in existing_names:
return
db.execute(f"ALTER TABLE {table} ADD COLUMN {column} {column_def}")
db.execute(f'ALTER TABLE {table} ADD COLUMN {column} {column_def}')
Loading
Loading