mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-31 09:06:52 +09:00
242 lines
11 KiB
Python
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
|