Files
ServerKit/backend/tests/test_connect_inventory.py

242 lines
11 KiB
Python

"""The panel half of the Cloud inventory.
What matters: the key is the row id and survives a rename; a collector that
fails leaves its section out of `capabilities` and names the reason; nothing
that could carry a secret is in the document; the frame is the existing
`facts` stream kind so an older Cloud is not confused by it.
"""
import json
from datetime import datetime, timedelta
import pytest
from types import SimpleNamespace
from app.services import connect_inventory
from factories import make_application, make_user
@pytest.fixture()
def apps(app):
from app import db
from app.models.deployment import Deployment
from app.models.domain import Domain
from app.models.user import User
with app.app_context():
user = User.query.first()
if user is None:
user = make_user(db)
shop = make_application(db, name='shop', app_type='wordpress', status='running',
port=8080, user_id=user.id, managed_by=None,
compose_file=None, docker_image=None)
worker = make_application(db, name='worker', app_type='docker', status='error',
docker_image='registry.test/worker:1', user_id=user.id,
managed_by=None, compose_file=None)
db.session.add(Domain(name='shop.example.test', is_primary=True, ssl_enabled=True,
ssl_expires_at=datetime.utcnow() + timedelta(days=30),
application_id=shop.id))
started = datetime.utcnow() - timedelta(minutes=10)
db.session.add(Deployment(app_id=shop.id, version=3, status='live',
commit_hash='a41f9c2b1d5e', image_tag='registry.test/shop:3',
build_started_at=started,
build_completed_at=started + timedelta(seconds=32),
deploy_started_at=started + timedelta(seconds=32),
deploy_completed_at=started + timedelta(seconds=50)))
db.session.add(Deployment(app_id=shop.id, version=2, status='failed',
build_started_at=started - timedelta(hours=1)))
db.session.commit()
yield {'shop': shop.id, 'worker': worker.id}
def _by_key(doc, key):
return next(a for a in doc['applications'] if a['key'] == str(key))
def test_applications_are_keyed_on_the_row_id_and_carry_only_safe_fields(app, apps):
doc = connect_inventory.build_inventory(app, generation=4)
assert doc['schema'] == 'inventory/1'
assert doc['generation'] == 4
assert 'applications' in doc['capabilities']
assert doc['sections']['applications'] == {'complete': True}
shop = _by_key(doc, apps['shop'])
assert shop['name'] == 'shop'
assert shop['runtime'] == 'native'
assert shop['state'] == 'running'
assert shop['domains'][0]['host'] == 'shop.example.test'
assert shop['domains'][0]['tls_expires_at']
assert shop['ports'] == [8080]
assert shop['deployment']['key'] == f'{apps["shop"]}:3'
assert shop['deployment']['status'] == 'succeeded'
assert shop['source'] == {'commit': 'a41f9c2b1d5e'}
# The panel installed this WordPress, so its dashboard is a fact.
assert shop['admin_url'] == 'https://shop.example.test/wp-admin/'
worker = _by_key(doc, apps['worker'])
assert worker['runtime'] == 'docker'
assert worker['state'] == 'failed'
assert 'admin_url' not in worker
text = json.dumps(doc)
for forbidden in ('root_path', 'admin_secret', 'container_id', 'env', 'password'):
assert forbidden not in text, forbidden
def test_a_rename_keeps_the_key(app, apps):
from app import db
from app.models.application import Application
before = _by_key(connect_inventory.build_inventory(app), apps['shop'])['key']
with app.app_context():
row = db.session.get(Application, apps['shop'])
row.name = 'shop-v2'
db.session.commit()
after = _by_key(connect_inventory.build_inventory(app), apps['shop'])
assert after['key'] == before and after['name'] == 'shop-v2'
def test_deployments_are_history_with_stages_only_where_recorded(app, apps):
doc = connect_inventory.build_inventory(app)
assert 'deployments' in doc['capabilities']
mine = [d for d in doc['deployments'] if d['app_key'] == str(apps['shop'])]
assert [d['key'] for d in mine] == [f'{apps["shop"]}:3', f'{apps["shop"]}:2']
live = mine[0]
assert live['status'] == 'succeeded'
assert live['artifact'] == {'image': 'registry.test/shop:3', 'commit': 'a41f9c2b1d5e'}
assert [s['name'] for s in live['stages']] == ['build', 'deploy']
assert round(live['stages'][0]['duration_s']) == 32
assert round(live['duration_s']) == 50
failed = mine[1]
assert failed['status'] == 'failed' and 'stages' not in failed
def test_a_collector_that_raises_leaves_its_section_out_and_says_why(app, apps, monkeypatch):
def boom():
raise RuntimeError('docker did not answer')
monkeypatch.setattr(connect_inventory, '_databases', boom)
doc = connect_inventory.build_inventory(app)
assert 'databases' not in doc
assert 'databases' not in doc['capabilities']
assert doc['sections']['databases']['complete'] is False
assert doc['sections']['databases']['error'] == 'RuntimeError'
# The other sections are still there.
assert 'applications' in doc['capabilities']
def test_the_repo_slug_never_carries_a_host_or_a_token():
slug = connect_inventory._repo_slug
assert slug('https://oauth2:ghp_secret@github.com/acme/api.git') == 'acme/api'
assert slug('git@github.com:acme/api.git') == 'acme/api'
assert slug('https://gitlab.example.test/group/sub/api') == 'sub/api'
assert slug('') is None and slug(None) is None
def test_the_frame_is_the_facts_stream_kind():
frame = connect_inventory.inventory_frame('inv1', {'schema': 'inventory/1'})
assert frame == {'s': 'inv1', 't': 'open', 'k': 'facts', 'p': {'schema': 'inventory/1'}}
def test_diagnostics_never_include_a_command_line(monkeypatch):
doc = connect_inventory.build_diagnostics(60)
assert doc['schema'] == 'diagnostics/1'
assert doc['interval_s'] == 60
assert len(doc['processes']) <= connect_inventory.MAX_PROCESSES
for proc in doc['processes']:
assert set(proc) <= {'pid', 'name', 'user', 'cpu_pct', 'rss_bytes', 'started_at'}
assert 'processes' in doc['completeness'] and 'mounts' in doc['completeness']
def test_the_client_backs_off_when_cloud_refuses_diagnostics():
from app.services import connect_client
client = connect_client.RelayClient(None)
client._apply_diagnostics_ack({'ok': False, 'reason': 'scope_not_granted', 'collect': False})
import time
assert client._diagnostics_next_at > time.monotonic() + connect_client.DIAGNOSTICS_INTERVAL_S
client._apply_inventory_ack({'ok': True, 'next_interval_s': 300})
assert client._inventory_next_at > time.monotonic() + 200
@pytest.mark.parametrize('url', [
'https://oauth2:secret@github.com/acme/api.git?token=secret#private',
'git@github.com:acme/api.git?token=secret#private',
'https://github.com/acme/api.git#token=secret',
])
def test_repo_slugs_strip_queries_and_fragments(url):
assert connect_inventory._repo_slug(url) == 'acme/api'
def test_admin_link_uses_primary_domains_own_tls_state():
secondary = SimpleNamespace(name='secondary.test', is_primary=False, ssl_enabled=True,
ssl_expires_at=datetime.utcnow() + timedelta(days=30))
primary = SimpleNamespace(name='primary.test', is_primary=True, ssl_enabled=False)
row = SimpleNamespace(app_type='wordpress', live_domains=[secondary, primary])
domains = connect_inventory._app_domains(row)
assert connect_inventory._admin_url(row, domains) == 'http://primary.test/wp-admin/'
primary.ssl_enabled = True
domains = connect_inventory._app_domains(row)
assert connect_inventory._admin_url(row, domains) == 'https://primary.test/wp-admin/'
def test_deleted_resources_and_their_logs_are_excluded(app, apps):
from app import db
from app.models.application import Application
from app.models.managed_database import ManagedDatabase
from app.services.connect_logs import AppOutput, DeployOutput
with app.app_context():
shop = db.session.get(Application, apps['shop'])
shop.soft_delete()
active = ManagedDatabase(name='active-db', engine='postgresql')
deleted = ManagedDatabase(name='deleted-db', engine='postgresql')
deleted.soft_delete()
db.session.add_all([active, deleted])
db.session.commit()
doc = connect_inventory.build_inventory(app)
assert str(shop.id) not in {r['key'] for r in doc['applications']}
assert str(shop.id) not in {r['app_key'] for r in doc['deployments']}
assert [r['name'] for r in doc['databases']] == ['active-db']
assert shop.id not in {r.id for r in AppOutput(app)._applications()}
assert shop.id not in {r.app_id for r in DeployOutput(app)._recent()}
@pytest.mark.parametrize('consent', [None, False, 1, 'true'])
def test_diagnostics_probes_never_collect_without_explicit_consent(monkeypatch, consent):
from app.services import connect_client
client = connect_client.RelayClient()
def forbidden(*args):
pytest.fail('processes and mounts must not be collected without consent')
monkeypatch.setattr(connect_inventory, 'build_diagnostics', forbidden)
frames = []
ws = SimpleNamespace(send=lambda raw: frames.append(json.loads(raw)))
assert client._publish_diagnostics(ws)
assert frames[-1]['p']['processes'] == []
assert frames[-1]['p']['mounts'] == []
payload = {'ok': True}
if consent is not None:
payload['collect'] = consent
client._apply_diagnostics_ack(payload)
client._diagnostics_next_at = 0.0 # the refusal retry window has elapsed
assert client._publish_diagnostics(ws)
assert frames[-1]['p']['processes'] == []
assert client._diagnostics_collect is False
def test_explicit_diagnostics_consent_enables_next_collection(monkeypatch):
from app.services import connect_client
client = connect_client.RelayClient()
frames = []
ws = SimpleNamespace(send=lambda raw: frames.append(json.loads(raw)))
client._publish_diagnostics(ws)
client._handle_frame(ws, json.dumps({
's': frames[-1]['s'], 't': 'close', 'p': {'ok': True, 'collect': True},
}))
monkeypatch.setattr(connect_inventory, 'build_diagnostics',
lambda *args: {'schema': 'diagnostics/1', 'processes': [{'pid': 7}]})
assert client._publish_diagnostics(ws)
assert frames[-1]['p']['processes'] == [{'pid': 7}]
client._handle_frame(ws, json.dumps({
's': frames[-1]['s'], 't': 'close', 'p': {'ok': False, 'collect': False},
}))
assert client._diagnostics_collect is False