Remove auto-scaling and the load-balancer builder

Replica counts could be changed, but nginx only proxied to one port
and the auto-scale sweep was never scheduled, so neither the
Auto-scale section nor its five routes could work; the nginx
upstream builder had no UI at all. With one live copy per app they
are gone, and the scale-policy table is dropped. Running containers
are not touched. The scaling spec now says replicas are not
pursued.
This commit is contained in:
Juan Denis committed 2026-09-28 09:03:34 -04:00
1 parent e3fb1410cd
commit acdf6b878d
37 files changed
+74 -1563

No files matched your search

-74
View File
@@ -26,7 +26,6 @@ from app.services.application_lifecycle_service import (
_agent_result_error, _assert_managed_app_path,
)
from app.services.container_sleep_service import ContainerSleepService
from app.services.container_scale_service import ContainerScaleService
from app.services.log_service import LogService
from app.services.process_service import ProcessService
from app.services.backup_policy_service import BackupPolicyService, BackupPolicyError
@@ -1733,79 +1732,6 @@ def purge_micro_cache(app_id):
'purged': result.get('purged', 0)}), 200
@apps_bp.route('/<int:app_id>/scale-policy', methods=['GET'])
@jwt_required()
def get_scale_policy(app_id):
user = get_current_user()
app = Application.query_active().filter_by(id=app_id).first()
if not app:
return jsonify({'error': 'Application not found'}), 404
if not _can_access_app(user, app):
return jsonify({'error': 'Access denied'}), 403
return jsonify(ContainerScaleService.get_or_create_policy(app_id).to_dict())
@apps_bp.route('/<int:app_id>/scale-policy', methods=['PUT'])
@jwt_required()
def update_scale_policy(app_id):
user = get_current_user()
app = Application.query_active().filter_by(id=app_id).first()
if not app:
return jsonify({'error': 'Application not found'}), 404
if not _can_edit_app(user, app):
return jsonify({'error': 'Access denied'}), 403
data = request.get_json() or {}
try:
policy = ContainerScaleService.set_policy(app_id, **data)
except ValueError as exc:
# The service rolls back its own dirty session before raising.
return jsonify({'error': str(exc)}), 400
return jsonify(policy.to_dict())
@apps_bp.route('/<int:app_id>/scale', methods=['POST'])
@jwt_required()
def scale_app(app_id):
user = get_current_user()
app = Application.query_active().filter_by(id=app_id).first()
if not app:
return jsonify({'error': 'Application not found'}), 404
if not _can_edit_app(user, app):
return jsonify({'error': 'Access denied'}), 403
data = request.get_json() or {}
if data.get('replicas') is None:
return jsonify({'error': 'replicas is required'}), 400
result = ContainerScaleService.scale_to(app_id, data['replicas'])
if not result.get('success'):
return jsonify({'error': result['error']}), 400
return jsonify(result)
@apps_bp.route('/<int:app_id>/scale/evaluate', methods=['POST'])
@jwt_required()
def evaluate_scale(app_id):
user = get_current_user()
app = Application.query_active().filter_by(id=app_id).first()
if not app:
return jsonify({'error': 'Application not found'}), 404
if not _can_edit_app(user, app):
return jsonify({'error': 'Access denied'}), 403
result = ContainerScaleService.evaluate(app_id)
if not result.get('success'):
return jsonify({'error': result.get('error', 'Evaluation failed')}), 400
return jsonify(result)
@apps_bp.route('/scale-sweep', methods=['POST'])
@jwt_required()
def scale_sweep():
"""Evaluate every enabled auto-scaling policy. Intended for cron/scheduler."""
user = get_current_user()
if not (user and user.is_admin):
return jsonify({'error': 'Admin access required'}), 403
return jsonify(ContainerScaleService.sweep())
@apps_bp.route('/<int:app_id>/stop', methods=['POST'])
@jwt_required()
def stop_app(app_id):
-23
View File
@@ -15,23 +15,6 @@ def get_proxy_rules(domain):
return jsonify(result)
@nginx_advanced_bp.route('/proxy', methods=['POST'])
@jwt_required()
def create_proxy():
user = get_current_user()
if not user or not user.is_admin:
return jsonify({'error': 'Admin access required'}), 403
data = request.get_json()
if not data or 'domain' not in data:
return jsonify({'error': 'domain required'}), 400
result = NginxAdvancedService.create_reverse_proxy(data)
if 'error' in result:
return jsonify(result), 400
return jsonify(result), 201
@nginx_advanced_bp.route('/test', methods=['POST'])
@jwt_required()
def test_config():
@@ -78,9 +61,3 @@ def get_logs(domain):
lines = request.args.get('lines', 100, type=int)
result = NginxAdvancedService.get_vhost_logs(domain, log_type, lines)
return jsonify(result)
@nginx_advanced_bp.route('/lb-methods', methods=['GET'])
@jwt_required()
def get_lb_methods():
return jsonify({'methods': NginxAdvancedService.get_load_balancing_methods()})
@@ -1,40 +0,0 @@
from datetime import datetime
from app import db
class ContainerScalePolicy(db.Model):
"""Per-application horizontal auto-scaling policy. When enabled, the named
compose service is scaled between min/max replicas based on average CPU,
with a cooldown between actions. Requires a scale-capable service (no fixed
host port or container_name). One row per application."""
__tablename__ = 'container_scale_policies'
id = db.Column(db.Integer, primary_key=True)
application_id = db.Column(db.Integer, db.ForeignKey('applications.id'),
nullable=False, unique=True, index=True)
enabled = db.Column(db.Boolean, default=False, nullable=False)
service_name = db.Column(db.String(100)) # compose service to scale
min_replicas = db.Column(db.Integer, default=1, nullable=False)
max_replicas = db.Column(db.Integer, default=3, nullable=False)
cpu_high_percent = db.Column(db.Integer, default=75, nullable=False)
cpu_low_percent = db.Column(db.Integer, default=25, nullable=False)
cooldown_seconds = db.Column(db.Integer, default=300, nullable=False)
current_replicas = db.Column(db.Integer, default=1, nullable=False)
last_scaled_at = db.Column(db.DateTime)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
application = db.relationship('Application', backref=db.backref('scale_policy', uselist=False))
def to_dict(self):
return {
'application_id': self.application_id,
'enabled': self.enabled,
'service_name': self.service_name,
'min_replicas': self.min_replicas,
'max_replicas': self.max_replicas,
'cpu_high_percent': self.cpu_high_percent,
'cpu_low_percent': self.cpu_low_percent,
'cooldown_seconds': self.cooldown_seconds,
'current_replicas': self.current_replicas,
'last_scaled_at': self.last_scaled_at.isoformat() if self.last_scaled_at else None,
}
@@ -1,199 +0,0 @@
"""Horizontal auto-scaling: adjust a compose service's replica count based on
average CPU, within min/max bounds and a cooldown. Local apps only for now;
the service must be scale-capable (no fixed host port or container_name)."""
import logging
import os
from datetime import datetime, timedelta
from app import db
from app.utils.system import run_checked
from app.models import Application
from app.models.container_scale_policy import ContainerScalePolicy
logger = logging.getLogger(__name__)
def _compose_path(app):
return os.path.join(app.root_path, app.compose_file or 'docker-compose.yml')
class ContainerScaleService:
@staticmethod
def get_or_create_policy(application_id):
policy = ContainerScalePolicy.query.filter_by(application_id=application_id).first()
if not policy:
policy = ContainerScalePolicy(application_id=application_id)
db.session.add(policy)
db.session.commit()
return policy
@classmethod
def set_policy(cls, application_id, **fields):
policy = cls.get_or_create_policy(application_id)
try:
cls._apply_policy_fields(policy, fields)
db.session.commit()
except Exception:
# Validation raises after fields were already assigned to the ORM
# row, and commit itself can raise; roll back either way so callers
# never inherit a dirty session.
db.session.rollback()
raise
return policy
@classmethod
def _apply_policy_fields(cls, policy, fields):
if fields.get('enabled') is not None:
enabled = fields['enabled']
if not isinstance(enabled, bool):
raise ValueError('enabled must be a boolean')
policy.enabled = enabled
if fields.get('service_name') is not None:
service_name = fields['service_name']
if service_name is not None and not isinstance(service_name, str):
raise ValueError('service_name must be a string')
policy.service_name = (service_name or '').strip() or None
for key in ('min_replicas', 'max_replicas', 'cpu_high_percent',
'cpu_low_percent', 'cooldown_seconds'):
if fields.get(key) is not None:
try:
value = int(fields[key])
except (TypeError, ValueError) as exc:
raise ValueError(f'{key} must be an integer') from exc
setattr(policy, key, max(0, value))
policy.min_replicas = max(1, policy.min_replicas)
policy.max_replicas = max(policy.min_replicas, policy.max_replicas)
policy.cpu_high_percent = min(100, policy.cpu_high_percent)
policy.cpu_low_percent = min(100, policy.cpu_low_percent)
if policy.cpu_low_percent >= policy.cpu_high_percent:
raise ValueError('cpu_low_percent must be less than cpu_high_percent')
if policy.enabled and not policy.service_name:
raise ValueError('service_name is required when auto-scale is enabled')
@classmethod
def _service_cpu(cls, app, policy):
"""Average CPU% across the service's running containers, or None."""
try:
ps_cmd = ['docker', 'compose', '-f', _compose_path(app), 'ps', '-q']
if policy.service_name:
ps_cmd.append(policy.service_name)
ps = run_checked(ps_cmd, cwd=app.root_path, timeout=15)
ids = [line.strip() for line in ps['output'].splitlines() if line.strip()]
if not ids:
return None
stats = run_checked(
['docker', 'stats', '--no-stream', '--format', '{{.CPUPerc}}'] + ids,
timeout=15)
values = []
for line in stats['output'].splitlines():
try:
values.append(float(line.strip().rstrip('%')))
except ValueError:
pass
return round(sum(values) / len(values), 1) if values else None
except Exception:
return None
@classmethod
def _apply_scale(cls, app, policy, replicas):
if app.server_id:
return {'success': False, 'error': 'Scaling is not yet supported for apps on remote servers'}
if not (app.app_type == 'docker' and app.root_path and policy.service_name):
return {'success': False, 'error': 'Scaling requires a docker-compose app with a service_name set'}
try:
result = run_checked(
['docker', 'compose', '-f', _compose_path(app), 'up', '-d', '--no-recreate',
'--scale', f'{policy.service_name}={replicas}'],
cwd=app.root_path, timeout=120)
if not result['success']:
return {'success': False, 'error': result['error'][:300]}
return {'success': True}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def scale_to(cls, application_id, replicas):
# query_active: this runs `docker compose up --scale` — never on a tombstone.
app = Application.query_active().filter_by(id=application_id).first()
if not app:
return {'success': False, 'error': 'Application not found'}
policy = cls.get_or_create_policy(application_id)
try:
replicas = max(1, int(replicas))
except (TypeError, ValueError):
return {'success': False, 'error': 'replicas must be an integer'}
result = cls._apply_scale(app, policy, replicas)
if not result.get('success'):
return result
policy.current_replicas = replicas
policy.last_scaled_at = datetime.utcnow()
db.session.commit()
return {'success': True, 'replicas': replicas, 'policy': policy.to_dict()}
@classmethod
def evaluate(cls, application_id):
app = Application.query_active().filter_by(id=application_id).first()
if not app:
return {'success': False, 'error': 'Application not found'}
policy = cls.get_or_create_policy(application_id)
if not policy.enabled:
return {'success': True, 'action': 'disabled', 'replicas': policy.current_replicas}
if not policy.service_name:
return {
'success': False,
'error': 'service_name is required when auto-scale is enabled',
'replicas': policy.current_replicas,
}
now = datetime.utcnow()
if policy.last_scaled_at and (now - policy.last_scaled_at) < timedelta(seconds=policy.cooldown_seconds):
return {'success': True, 'action': 'cooldown', 'replicas': policy.current_replicas}
current = policy.current_replicas
target = current
cpu = None
if current < policy.min_replicas:
target = policy.min_replicas
elif current > policy.max_replicas:
target = policy.max_replicas
else:
cpu = cls._service_cpu(app, policy)
if cpu is None:
return {'success': True, 'action': 'unknown', 'replicas': current}
if cpu > policy.cpu_high_percent and current < policy.max_replicas:
target = current + 1
elif cpu < policy.cpu_low_percent and current > policy.min_replicas:
target = current - 1
if target == current:
return {'success': True, 'action': 'hold', 'replicas': current, 'cpu': cpu}
result = cls._apply_scale(app, policy, target)
if not result.get('success'):
return {'success': False, 'error': result['error'], 'replicas': current}
policy.current_replicas = target
policy.last_scaled_at = now
db.session.commit()
action = 'scaled_up' if target > current else 'scaled_down'
logger.info('Auto-scale %s: %s -> %s replicas (cpu=%s)', app.name, current, target, cpu)
response = {'success': True, 'action': action, 'replicas': target}
if cpu is not None:
response['cpu'] = cpu
return response
@classmethod
def sweep(cls):
"""Evaluate every enabled policy. Meant to run periodically."""
scaled = []
# Scale policies outlive a soft-deleted app; skip them rather than
# spawning docker/compose probes for an app that no longer exists.
dead = Application.deleted_ids()
for policy in ContainerScalePolicy.query.filter_by(enabled=True).all():
if policy.application_id in dead:
continue
result = cls.evaluate(policy.application_id)
if result.get('action') in ('scaled_up', 'scaled_down'):
scaled.append({'application_id': policy.application_id,
'action': result['action'], 'replicas': result['replicas']})
return {'success': True, 'scaled': scaled}
+5 -207
View File
@@ -1,100 +1,16 @@
import os
import json
import logging
import re
from app.services.nginx_service import NginxService, _validate_domain
from app.services.nginx_service import NginxService
from app.utils.system import run_unprivileged
logger = logging.getLogger(__name__)
# Characters that would break out of the nginx directive being generated and
# inject arbitrary configuration. Everything interpolated into the generated
# config is checked against these (or a stricter pattern) before the build.
_CONFIG_BREAKOUT = ('{', '}', ';', '\n', '\r')
_UPSTREAM_ADDR_RE = re.compile(r'^(?:[a-zA-Z0-9._\-]+:\d{1,5}|unix:[a-zA-Z0-9/_.\-]+)$')
_HEADER_NAME_RE = re.compile(r'^[A-Za-z0-9\-]+$')
_SIZE_RE = re.compile(r'^\d+[kmgKMG]$')
_TTL_RE = re.compile(r'^\d+[smhdSMHD]$')
def _has_breakout(value):
return any(c in value for c in _CONFIG_BREAKOUT)
def _validate_proxy_input(data):
"""Validate reverse-proxy input before it becomes nginx config.
Every value the builder interpolates is a potential config-injection
vector — a ``;`` or ``}`` in a header value, upstream address or location
path closes the directive being generated and starts an attacker-chosen
one. Returns an error string, or ``None`` when the input is clean.
"""
domain = data.get('domain')
if not isinstance(domain, str) or not _validate_domain(domain):
return f'invalid domain: {domain}'
if data.get('lb_method', 'round_robin') not in (
'round_robin', 'least_conn', 'ip_hash'):
return f"invalid lb_method: {data.get('lb_method')}"
for u in data.get('upstreams', []):
address = u.get('address', '')
if not isinstance(address, str) or not _UPSTREAM_ADDR_RE.match(address):
return f'invalid upstream address: {address}'
weight = u.get('weight')
if weight is not None and (not isinstance(weight, int)
or isinstance(weight, bool)
or not 1 <= weight <= 100):
return f'invalid upstream weight: {weight}'
rate_limit = data.get('rate_limit', {})
if rate_limit.get('enabled'):
rps = rate_limit.get('requests_per_second', 10)
burst = rate_limit.get('burst', 20)
if not isinstance(rps, (int, float)) or isinstance(rps, bool) or rps <= 0:
return f'invalid requests_per_second: {rps}'
if not isinstance(burst, int) or isinstance(burst, bool) or burst <= 0:
return f'invalid burst: {burst}'
cache = data.get('cache', {})
if cache.get('enabled'):
if not _SIZE_RE.match(str(cache.get('size', '100m'))):
return f"invalid cache size: {cache.get('size')}"
if not _TTL_RE.match(str(cache.get('ttl', '60m'))):
return f"invalid cache ttl: {cache.get('ttl')}"
for bypass in cache.get('bypass_rules', []):
if not isinstance(bypass, str) or _has_breakout(bypass):
return f'invalid cache bypass rule: {bypass}'
headers = data.get('headers', {})
for name, value in headers.get('add', {}).items():
if not _HEADER_NAME_RE.match(str(name)):
return f'invalid header name: {name}'
# Header values are emitted inside double quotes — a quote or newline
# breaks out of them.
if any(c in str(value) for c in ('"', '\n', '\r')):
return f'invalid value for header {name}'
for name in headers.get('remove', []):
if not _HEADER_NAME_RE.match(str(name)):
return f'invalid header name: {name}'
for loc in data.get('locations', []):
path = loc.get('path', '')
if not isinstance(path, str) or not path.strip() or _has_breakout(path):
return f'invalid location path: {path}'
proxy_pass = loc.get('proxy_pass')
if proxy_pass is not None:
if (not isinstance(proxy_pass, str)
or not proxy_pass.startswith(('http://', 'https://'))
or any(c in proxy_pass for c in (' ', '\t', '{', '}', ';', '\n', '\r'))):
return f'invalid proxy_pass: {proxy_pass}'
return None
class NginxAdvancedService:
"""Advanced Nginx configuration: reverse proxy, load balancing, caching, rate limiting."""
"""Read-side nginx helpers: vhost config, config test, diff preview, logs.
The reverse-proxy / load-balancer builder was removed with the scaling
pieces (plan 86 §E2): one live copy per app, no upstream pools."""
# NginxService owns these (plan 75 §G4). Hardcoding them here meant an
# NGINX_CONF_DIR override — the env var NginxService honours, and what
@@ -116,116 +32,6 @@ class NginxAdvancedService:
except Exception as e:
return {'error': str(e)}
@staticmethod
def create_reverse_proxy(data):
"""Create a reverse proxy configuration."""
invalid = _validate_proxy_input(data)
if invalid:
return {'error': invalid}
domain = data['domain']
upstreams = data.get('upstreams', [])
lb_method = data.get('lb_method', 'round_robin')
cache = data.get('cache', {})
rate_limit = data.get('rate_limit', {})
headers = data.get('headers', {})
locations = data.get('locations', [])
upstream_name = domain.replace('.', '_')
lines = []
# Upstream block
if upstreams:
lines.append(f'upstream {upstream_name} {{')
if lb_method == 'least_conn':
lines.append(' least_conn;')
elif lb_method == 'ip_hash':
lines.append(' ip_hash;')
for u in upstreams:
weight = f' weight={u["weight"]}' if u.get('weight') else ''
lines.append(f' server {u["address"]}{weight};')
lines.append('}')
lines.append('')
# Rate limiting zone
if rate_limit.get('enabled'):
rps = rate_limit.get('requests_per_second', 10)
lines.append(f'limit_req_zone $binary_remote_addr zone={upstream_name}_limit:10m rate={rps}r/s;')
lines.append('')
# Cache zone
if cache.get('enabled'):
cache_size = cache.get('size', '100m')
cache_ttl = cache.get('ttl', '60m')
lines.append(f'proxy_cache_path /var/cache/nginx/{upstream_name} levels=1:2 keys_zone={upstream_name}_cache:10m max_size={cache_size} inactive={cache_ttl};')
lines.append('')
# Server block
lines.append('server {')
lines.append(f' listen 80;')
lines.append(f' server_name {domain};')
lines.append('')
# Custom headers
for header_name, header_value in headers.get('add', {}).items():
lines.append(f' add_header {header_name} "{header_value}";')
for header_name in headers.get('remove', []):
lines.append(f' proxy_hide_header {header_name};')
if rate_limit.get('enabled'):
burst = rate_limit.get('burst', 20)
lines.append(f' limit_req zone={upstream_name}_limit burst={burst} nodelay;')
lines.append('')
# Custom location blocks
for loc in locations:
lines.append(f' location {loc["path"]} {{')
if loc.get('proxy_pass'):
lines.append(f' proxy_pass {loc["proxy_pass"]};')
elif upstreams:
lines.append(f' proxy_pass http://{upstream_name};')
lines.append(' proxy_set_header Host $host;')
lines.append(' proxy_set_header X-Real-IP $remote_addr;')
lines.append(' proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;')
lines.append(' proxy_set_header X-Forwarded-Proto $scheme;')
if cache.get('enabled') and not loc.get('no_cache'):
lines.append(f' proxy_cache {upstream_name}_cache;')
for bypass in cache.get('bypass_rules', []):
lines.append(f' proxy_cache_bypass {bypass};')
lines.append(' }')
lines.append('')
# Default location if no custom locations
if not locations:
lines.append(' location / {')
if upstreams:
lines.append(f' proxy_pass http://{upstream_name};')
lines.append(' proxy_set_header Host $host;')
lines.append(' proxy_set_header X-Real-IP $remote_addr;')
lines.append(' proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;')
lines.append(' proxy_set_header X-Forwarded-Proto $scheme;')
if cache.get('enabled'):
lines.append(f' proxy_cache {upstream_name}_cache;')
lines.append(' }')
lines.append('}')
config = '\n'.join(lines)
# Write config through the shared vhost door (plan 75 §G4). This was a
# plain `open(conf_path, 'w')`: unprivileged, so it raises
# PermissionError against a root-owned /etc/nginx/sites-available, and
# it neither config-tested nor reloaded what it wrote.
result = NginxService.write_vhost(domain, config)
if not result['success']:
return {'error': result['error'], 'domain': domain}
return {'domain': domain, 'config': config, 'path': result['path']}
@staticmethod
def test_config():
"""Test nginx config syntax — NginxService owns the call (plan 75 §G4).
@@ -294,11 +100,3 @@ class NginxAdvancedService:
return {'lines': log_lines, 'file': log_file}
except Exception as e:
return {'lines': [], 'error': str(e)}
@staticmethod
def get_load_balancing_methods():
return {
'round_robin': 'Round Robin (default)',
'least_conn': 'Least Connections',
'ip_hash': 'IP Hash (sticky sessions)',
}
@@ -0,0 +1,48 @@
"""Drop container scaling policies (plan 86 §E2).
Auto-scaling adjusted a compose service's replica count, but nginx only ever
proxied to one port and the sweep was never scheduled, so it could not work;
the feature was removed in favour of one live copy per app. Running
containers are not touched: a service scaled up earlier keeps its replicas
until its next deploy.
"""
import sqlalchemy as sa
from alembic import op
revision = '105_drop_container_scale_policies'
down_revision = '104_immutable_assets'
branch_labels = None
depends_on = None
def _has_table(name):
return name in sa.inspect(op.get_bind()).get_table_names()
def upgrade():
if _has_table('container_scale_policies'):
op.drop_table('container_scale_policies')
def downgrade():
if _has_table('container_scale_policies'):
return
op.create_table(
'container_scale_policies',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('application_id', sa.Integer(), nullable=False),
sa.Column('enabled', sa.Boolean(), nullable=False, server_default=sa.text('0')),
sa.Column('service_name', sa.String(length=100), nullable=True),
sa.Column('min_replicas', sa.Integer(), nullable=False, server_default='1'),
sa.Column('max_replicas', sa.Integer(), nullable=False, server_default='3'),
sa.Column('cpu_high_percent', sa.Integer(), nullable=False, server_default='75'),
sa.Column('cpu_low_percent', sa.Integer(), nullable=False, server_default='25'),
sa.Column('cooldown_seconds', sa.Integer(), nullable=False, server_default='300'),
sa.Column('current_replicas', sa.Integer(), nullable=False, server_default='1'),
sa.Column('last_scaled_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['application_id'], ['applications.id']),
sa.PrimaryKeyConstraint('id'),
)
op.create_index('ix_container_scale_policies_application_id',
'container_scale_policies', ['application_id'], unique=True)
+1 -1
View File
@@ -1 +1 @@
608
601
@@ -3,7 +3,7 @@
"policy": "Exact reviewed inventory of direct persistence, subprocess, and filesystem mutation calls in Flask API controllers. New calls are forbidden; remove entries as controllers delegate to services.",
"counts": {
"filesystem": 8,
"persistence": 487
"persistence": 483
},
"owners": {
"api/admin.py": {
@@ -273,7 +273,6 @@
"api/apps.py::delete_app_db_snapshot::persistence::DatabaseSnapshot.query.filter_by(id=snapshot_id, site_id=site.id).first()::1",
"api/apps.py::delete_app_db_snapshot::persistence::db.session.commit()::1",
"api/apps.py::delete_app_db_snapshot::persistence::db.session.delete(snapshot)::1",
"api/apps.py::evaluate_scale::persistence::Application.query_active().filter_by(id=app_id).first()::1",
"api/apps.py::get_app::persistence::Application.query_active().filter_by(id=app_id).first()::1",
"api/apps.py::get_app_containers::persistence::Application.query_active().filter_by(id=app_id).first()::1",
"api/apps.py::get_app_containers::persistence::User.query.get(current_user_id)::1",
@@ -297,7 +296,6 @@
"api/apps.py::get_container_logs::persistence::User.query.get(current_user_id)::1",
"api/apps.py::get_linked_apps::persistence::Application.query_active().filter_by(id=app_id).first()::1",
"api/apps.py::get_linked_apps::persistence::User.query.get(current_user_id)::1",
"api/apps.py::get_scale_policy::persistence::Application.query_active().filter_by(id=app_id).first()::1",
"api/apps.py::get_sleep_policy::persistence::Application.query_active().filter_by(id=app_id).first()::1",
"api/apps.py::grant_app_access::persistence::Application.query_active().filter_by(id=app_id).first()::1",
"api/apps.py::grant_app_access::persistence::User.query.get(grantee_id)::1",
@@ -318,7 +316,6 @@
"api/apps.py::rollback_app_version::persistence::Application.query_active().filter_by(id=app_id).first()::1",
"api/apps.py::rollback_app_version::persistence::User.query.get(current_user_id)::1",
"api/apps.py::rollback_app_version::persistence::db.session.commit()::1",
"api/apps.py::scale_app::persistence::Application.query_active().filter_by(id=app_id).first()::1",
"api/apps.py::set_app_workspace::persistence::Application.query_active().filter_by(id=app_id).first()::1",
"api/apps.py::set_app_workspace::persistence::Workspace.query.get(target)::1",
"api/apps.py::set_app_workspace::persistence::db.session.commit()::1",
@@ -341,7 +338,6 @@
"api/apps.py::update_environment::persistence::Application.query_active().filter_by(id=app_id).first()::1",
"api/apps.py::update_environment::persistence::User.query.get(current_user_id)::1",
"api/apps.py::update_environment::persistence::db.session.commit()::1",
"api/apps.py::update_scale_policy::persistence::Application.query_active().filter_by(id=app_id).first()::1",
"api/apps.py::update_sleep_policy::persistence::Application.query_active().filter_by(id=app_id).first()::1",
"api/apps.py::upload_app_archive::filesystem::os.makedirs(temp_dir, exist_ok=True)::1",
"api/apps.py::upload_app_archive::filesystem::os.remove(zippath)::1",
@@ -158,9 +158,9 @@ def test_purge_is_what_removes_the_volumes(app, dead_app, monkeypatch):
def test_purge_takes_the_one_to_one_policy_rows_with_it(app, dead_app, monkeypatch):
"""Field report: purging an app that had an auto-scale policy died with
`IntegrityError: NOT NULL constraint failed: container_scale_policies
.application_id`. Without a delete cascade SQLAlchemy's default on parent
"""Field report: purging an app that had a one-to-one policy row died with
`IntegrityError: NOT NULL constraint failed: <policy table>
.application_id` (auto-scale policies then; that feature is gone). Without a delete cascade SQLAlchemy's default on parent
delete is to NULL the child FK — and these one-to-one rows declare the FK
NOT NULL, so the purge itself crashed. The policy rows must ride along."""
from app.services import docker_service
@@ -169,12 +169,10 @@ def test_purge_takes_the_one_to_one_policy_rows_with_it(app, dead_app, monkeypat
monkeypatch.setattr(application_restore.CronService, 'clear_application',
classmethod(lambda cls, _id: 0))
from app.models.container_scale_policy import ContainerScalePolicy
from app.models.container_sleep_policy import ContainerSleepPolicy
from app.models.deployment import Deployment
with app.app_context():
db.session.add(ContainerScalePolicy(application_id=dead_app.id))
db.session.add(ContainerSleepPolicy(application_id=dead_app.id))
db.session.add(Deployment(app_id=dead_app.id, version=1))
db.session.commit()
@@ -183,8 +181,6 @@ def test_purge_takes_the_one_to_one_policy_rows_with_it(app, dead_app, monkeypat
assert ok
assert warning is None, warning
assert Application.query.get(dead_app.id) is None
assert ContainerScalePolicy.query.filter_by(
application_id=dead_app.id).first() is None
assert ContainerSleepPolicy.query.filter_by(
application_id=dead_app.id).first() is None
assert Deployment.query.filter_by(app_id=dead_app.id).first() is None
-238
View File
@@ -1,238 +0,0 @@
"""Tests for container horizontal auto-scaling (decision logic and API flow)."""
from datetime import datetime
from app import db
from app.models import Application
from factories import make_application
from app.models.container_scale_policy import ContainerScalePolicy
from app.services.container_scale_service import ContainerScaleService
def _seed_app(**kw):
return make_application(db, **kw)
def _set_current(app_id, n):
p = ContainerScalePolicy.query.filter_by(application_id=app_id).first()
p.current_replicas = n
db.session.commit()
return p
class TestScalePolicy:
def test_set_policy_clamps_bounds(self, app):
a = _seed_app()
p = ContainerScaleService.set_policy(a.id, enabled=True, service_name='web',
min_replicas=0, max_replicas=2)
assert p.enabled and p.service_name == 'web'
assert p.min_replicas == 1 and p.max_replicas == 2
def test_max_never_below_min(self, app):
a = _seed_app()
p = ContainerScaleService.set_policy(a.id, min_replicas=5, max_replicas=2)
assert p.max_replicas == 5
def test_rejects_an_enabled_policy_without_a_service(self, app):
a = _seed_app()
try:
ContainerScaleService.set_policy(a.id, enabled=True)
except ValueError as exc:
assert str(exc) == 'service_name is required when auto-scale is enabled'
else:
raise AssertionError('enabled policy without a service was accepted')
def test_rejects_overlapping_cpu_thresholds(self, app):
a = _seed_app()
try:
ContainerScaleService.set_policy(
a.id, service_name='web', cpu_low_percent=80,
cpu_high_percent=70)
except ValueError as exc:
assert str(exc) == 'cpu_low_percent must be less than cpu_high_percent'
else:
raise AssertionError('overlapping CPU thresholds were accepted')
def test_a_failed_commit_never_leaves_a_dirty_session(self, app, monkeypatch):
from sqlalchemy.exc import OperationalError
a = _seed_app()
ContainerScaleService.set_policy(a.id, service_name='web')
def failing_commit():
raise OperationalError('UPDATE container_scale_policies', {},
RuntimeError('db went away'))
monkeypatch.setattr(db.session, 'commit', failing_commit)
try:
ContainerScaleService.set_policy(a.id, min_replicas=4)
except OperationalError:
pass
else:
raise AssertionError('commit failure was swallowed')
monkeypatch.undo()
p = ContainerScalePolicy.query.filter_by(application_id=a.id).first()
assert p.min_replicas == 1, 'failed commit left the assigned value in the session'
class TestEvaluate:
def test_scales_up_on_high_cpu(self, app, monkeypatch):
a = _seed_app()
ContainerScaleService.set_policy(a.id, enabled=True, service_name='web',
min_replicas=1, max_replicas=3, cpu_high_percent=75, cooldown_seconds=0)
monkeypatch.setattr(ContainerScaleService, '_service_cpu', lambda app_, policy: 90.0)
monkeypatch.setattr(ContainerScaleService, '_apply_scale', lambda app_, policy, n: {'success': True})
r = ContainerScaleService.evaluate(a.id)
assert r['action'] == 'scaled_up' and r['replicas'] == 2
def test_scales_down_on_low_cpu(self, app, monkeypatch):
a = _seed_app()
ContainerScaleService.set_policy(a.id, enabled=True, service_name='web',
min_replicas=1, max_replicas=3, cpu_low_percent=25, cooldown_seconds=0)
_set_current(a.id, 2)
monkeypatch.setattr(ContainerScaleService, '_service_cpu', lambda app_, policy: 5.0)
monkeypatch.setattr(ContainerScaleService, '_apply_scale', lambda app_, policy, n: {'success': True})
r = ContainerScaleService.evaluate(a.id)
assert r['action'] == 'scaled_down' and r['replicas'] == 1
def test_holds_at_max(self, app, monkeypatch):
a = _seed_app()
ContainerScaleService.set_policy(a.id, enabled=True, service_name='web',
max_replicas=2, cpu_high_percent=75, cooldown_seconds=0)
_set_current(a.id, 2)
monkeypatch.setattr(ContainerScaleService, '_service_cpu', lambda app_, policy: 99.0)
r = ContainerScaleService.evaluate(a.id)
assert r['action'] == 'hold' and r['replicas'] == 2
def test_cooldown_blocks_action(self, app, monkeypatch):
a = _seed_app()
ContainerScaleService.set_policy(a.id, enabled=True, service_name='web', cooldown_seconds=300)
p = ContainerScalePolicy.query.filter_by(application_id=a.id).first()
p.last_scaled_at = datetime.utcnow()
db.session.commit()
monkeypatch.setattr(ContainerScaleService, '_service_cpu', lambda app_, policy: 99.0)
assert ContainerScaleService.evaluate(a.id)['action'] == 'cooldown'
def test_disabled_policy_is_noop(self, app):
a = _seed_app()
ContainerScaleService.set_policy(a.id, enabled=False)
assert ContainerScaleService.evaluate(a.id)['action'] == 'disabled'
def test_unknown_cpu_holds(self, app, monkeypatch):
a = _seed_app()
ContainerScaleService.set_policy(a.id, enabled=True, service_name='web', cooldown_seconds=0)
monkeypatch.setattr(ContainerScaleService, '_service_cpu', lambda app_, policy: None)
assert ContainerScaleService.evaluate(a.id)['action'] == 'unknown'
def test_enforces_a_raised_minimum_without_waiting_for_high_cpu(self, app, monkeypatch):
a = _seed_app()
ContainerScaleService.set_policy(
a.id, enabled=True, service_name='web', min_replicas=2,
max_replicas=3, cooldown_seconds=0)
applied = []
monkeypatch.setattr(
ContainerScaleService, '_service_cpu',
lambda app_, policy: (_ for _ in ()).throw(
AssertionError('CPU should not gate the replica floor')))
monkeypatch.setattr(
ContainerScaleService, '_apply_scale',
lambda app_, policy, n: applied.append(n) or {'success': True})
result = ContainerScaleService.evaluate(a.id)
assert result['action'] == 'scaled_up'
assert result['replicas'] == 2
assert applied == [2]
class TestScaleApi:
def test_manual_scale_endpoint(self, client, auth_headers, app, monkeypatch):
from app.models import User
admin = User.query.filter_by(username='testadmin').first()
a = _seed_app(user_id=admin.id)
ContainerScaleService.set_policy(a.id, service_name='web')
monkeypatch.setattr(ContainerScaleService, '_apply_scale', lambda app_, policy, n: {'success': True})
resp = client.post(f'/api/v1/apps/{a.id}/scale', json={'replicas': 3}, headers=auth_headers)
assert resp.status_code == 200 and resp.get_json()['replicas'] == 3
def test_scale_sweep_requires_admin(self, client, app):
assert client.post('/api/v1/apps/scale-sweep').status_code == 401
def test_rejects_non_numeric_manual_replica_count(self, client, auth_headers, app):
from app.models import User
admin = User.query.filter_by(username='testadmin').first()
a = _seed_app(user_id=admin.id)
ContainerScaleService.set_policy(a.id, service_name='web')
response = client.post(
f'/api/v1/apps/{a.id}/scale', json={'replicas': 'many'},
headers=auth_headers)
assert response.status_code == 400
assert response.get_json() == {'error': 'replicas must be an integer'}
def test_rejects_invalid_policy_without_persisting_it(
self, client, auth_headers, app):
from app.models import User
admin = User.query.filter_by(username='testadmin').first()
a = _seed_app(user_id=admin.id)
response = client.put(
f'/api/v1/apps/{a.id}/scale-policy',
json={'enabled': True, 'service_name': ''},
headers=auth_headers)
assert response.status_code == 400
assert response.get_json() == {
'error': 'service_name is required when auto-scale is enabled'}
persisted = client.get(
f'/api/v1/apps/{a.id}/scale-policy', headers=auth_headers)
assert persisted.get_json()['enabled'] is False
def test_policy_to_evaluation_to_manual_scale_workflow(
self, client, auth_headers, app, monkeypatch):
from app.models import User
admin = User.query.filter_by(username='testadmin').first()
a = _seed_app(user_id=admin.id)
applied = []
monkeypatch.setattr(ContainerScaleService, '_service_cpu', lambda app_, policy: 92.0)
monkeypatch.setattr(
ContainerScaleService, '_apply_scale',
lambda app_, policy, n: applied.append(n) or {'success': True})
initial = client.get(
f'/api/v1/apps/{a.id}/scale-policy', headers=auth_headers)
assert initial.status_code == 200
assert initial.get_json()['current_replicas'] == 1
configured = client.put(
f'/api/v1/apps/{a.id}/scale-policy',
json={
'enabled': True,
'service_name': 'web',
'min_replicas': 1,
'max_replicas': 4,
'cpu_low_percent': 20,
'cpu_high_percent': 75,
'cooldown_seconds': 0,
},
headers=auth_headers)
assert configured.status_code == 200
assert configured.get_json()['enabled'] is True
evaluated = client.post(
f'/api/v1/apps/{a.id}/scale/evaluate', headers=auth_headers)
assert evaluated.status_code == 200
assert evaluated.get_json()['action'] == 'scaled_up'
assert evaluated.get_json()['replicas'] == 2
manual = client.post(
f'/api/v1/apps/{a.id}/scale', json={'replicas': 3},
headers=auth_headers)
assert manual.status_code == 200
assert manual.get_json()['replicas'] == 3
persisted = client.get(
f'/api/v1/apps/{a.id}/scale-policy', headers=auth_headers)
assert persisted.get_json()['current_replicas'] == 3
assert applied == [2, 3]
@@ -1,110 +0,0 @@
"""Config-injection validation in the advanced nginx builder.
``create_reverse_proxy`` interpolates domains, upstreams, headers and
locations straight into a generated vhost. A ``;`` or ``}`` in any of them
closes the directive being written and starts an attacker-chosen one — and
the builder writes through ``write_vhost``, which config-tests and reloads,
so an injected-but-valid fragment would go live. Every interpolated value is
now validated first; these tests pin both the rejections and the clean path.
"""
import pytest
from app.services.nginx_advanced_service import NginxAdvancedService
from app.services.nginx_service import NginxService
@pytest.fixture
def written(monkeypatch):
"""write_vhost seam: captures what the builder tried to write."""
captured = {}
def fake_write(name, content, *, enable=True):
captured['name'] = name
captured['content'] = content
return {'success': True, 'path': f'/etc/nginx/sites-available/{name}'}
monkeypatch.setattr(NginxService, 'write_vhost', staticmethod(fake_write))
return captured
def _clean_payload():
return {
'domain': 'shop.example.com',
'upstreams': [{'address': '127.0.0.1:8001', 'weight': 2}],
'lb_method': 'least_conn',
'rate_limit': {'enabled': True, 'requests_per_second': 5, 'burst': 10},
'cache': {'enabled': True, 'size': '100m', 'ttl': '60m',
'bypass_rules': ['$http_authorization']},
'headers': {'add': {'X-Frame-Options': 'DENY'},
'remove': ['X-Powered-By']},
'locations': [{'path': '/api', 'proxy_pass': 'http://127.0.0.1:9000'}],
}
class TestCleanPayload:
def test_a_clean_payload_builds_and_writes(self, written):
res = NginxAdvancedService.create_reverse_proxy(_clean_payload())
assert 'error' not in res
assert written['name'] == 'shop.example.com'
config = written['content']
assert 'upstream shop_example_com {' in config
assert 'least_conn;' in config
assert 'server 127.0.0.1:8001 weight=2;' in config
assert 'server_name shop.example.com;' in config
assert 'add_header X-Frame-Options "DENY";' in config
assert 'proxy_hide_header X-Powered-By;' in config
assert 'location /api {' in config
assert 'proxy_pass http://127.0.0.1:9000;' in config
def test_a_unix_socket_upstream_is_accepted(self, written):
payload = _clean_payload()
payload['upstreams'] = [{'address': 'unix:/run/app.sock'}]
res = NginxAdvancedService.create_reverse_proxy(payload)
assert 'error' not in res
assert 'server unix:/run/app.sock;' in written['content']
def test_a_regex_location_is_accepted(self, written):
payload = _clean_payload()
payload['locations'] = [{'path': '~ \\.php$', 'proxy_pass': 'http://127.0.0.1:9000'}]
res = NginxAdvancedService.create_reverse_proxy(payload)
assert 'error' not in res
class TestInjectionIsRefused:
"""Each payload has exactly one poisoned value; the builder must refuse
it and write nothing."""
@pytest.mark.parametrize('mutate,fragment', [
(lambda p: p.update(domain='example.com; } server {'), 'domain'),
(lambda p: p.update(domain='--bogus'), 'domain'),
(lambda p: p.update(lb_method='least_conn; }'), 'lb_method'),
(lambda p: p['upstreams'][0].update(address='127.0.0.1:8001; } server {'), 'upstream'),
(lambda p: p['upstreams'][0].update(address='not-a-socket'), 'upstream'),
(lambda p: p['upstreams'][0].update(weight='2; drop'), 'weight'),
(lambda p: p['upstreams'][0].update(weight=0), 'weight'),
(lambda p: p['rate_limit'].update(requests_per_second='5; x'), 'requests_per_second'),
(lambda p: p['rate_limit'].update(burst=-1), 'burst'),
(lambda p: p['cache'].update(size='100m; }'), 'size'),
(lambda p: p['cache'].update(ttl='60m 0'), 'ttl'),
(lambda p: p['cache'].update(bypass_rules=['$http_x; }']), 'bypass'),
(lambda p: p['headers']['add'].update({'X-Test"; add_header X-Evil "1': 'v'}), 'header name'),
(lambda p: p['headers']['add'].update(
{'X-Frame-Options': 'DENY"; add_header X-Evil "1'}), 'header value'),
(lambda p: p['headers']['add'].update({'X-Frame-Options': 'line1\nline2'}), 'header newline'),
(lambda p: p['headers'].update(remove=['X-Powered-By;']), 'remove header'),
(lambda p: p['locations'][0].update(path='/api; return 200;'), 'location path'),
(lambda p: p['locations'][0].update(path='/api { deny all; }'), 'location brace'),
(lambda p: p['locations'][0].update(proxy_pass='http://127.0.0.1:9000; return 200'), 'proxy_pass'),
(lambda p: p['locations'][0].update(proxy_pass='file:///etc/passwd'), 'proxy_pass scheme'),
])
def test_a_poisoned_value_is_refused_and_nothing_is_written(
self, written, mutate, fragment):
payload = _clean_payload()
mutate(payload)
res = NginxAdvancedService.create_reverse_proxy(payload)
assert 'error' in res, f'{fragment} injection was accepted'
assert written == {}, f'{fragment} injection still reached write_vhost'
-12
View File
@@ -185,7 +185,6 @@ Regenerate (backend/):
- `GET /apps/{app_id}/private-url`
- `GET /apps/{app_id}/related`
- `GET /apps/{app_id}/resources`
- `GET /apps/{app_id}/scale-policy`
- `GET /apps/{app_id}/sleep-policy`
- `GET /apps/{app_id}/snapshots`
- `GET /apps/{app_id}/snapshots/{snap_id}`
@@ -412,7 +411,6 @@ Regenerate (backend/):
- `GET /monitors/{monitor_id}`
- `GET /monitors/{monitor_id}/history`
- `GET /monitors/{monitor_id}/uptime`
- `GET /nginx/advanced/lb-methods`
- `GET /nginx/advanced/logs/{domain}`
- `GET /nginx/advanced/proxy/{domain}`
- `GET /nginx/sites`
@@ -609,7 +607,6 @@ Regenerate (backend/):
- `GET /services/{app_id}/logs`
- `GET /services/{app_id}/related`
- `GET /services/{app_id}/resources`
- `GET /services/{app_id}/scale-policy`
- `GET /services/{app_id}/sleep-policy`
- `GET /services/{app_id}/status`
- `GET /services/{app_id}/versions`
@@ -773,7 +770,6 @@ Regenerate (backend/):
- `POST /apps/htaccess-convert`
- `POST /apps/manual`
- `POST /apps/move-to-project`
- `POST /apps/scale-sweep`
- `POST /apps/sweep-idle`
- `POST /apps/upload`
- `POST /apps/{app_id}/attachments/{kind}`
@@ -795,8 +791,6 @@ Regenerate (backend/):
- `POST /apps/{app_id}/private-url/regenerate`
- `POST /apps/{app_id}/restart`
- `POST /apps/{app_id}/rollback`
- `POST /apps/{app_id}/scale`
- `POST /apps/{app_id}/scale/evaluate`
- `POST /apps/{app_id}/sleep`
- `POST /apps/{app_id}/snapshots/{snap_id}/restore`
- `POST /apps/{app_id}/start`
@@ -1021,7 +1015,6 @@ Regenerate (backend/):
- `POST /monitors/{monitor_id}/check`
- `POST /monitors/{monitor_id}/pause`
- `POST /nginx/advanced/diff`
- `POST /nginx/advanced/proxy`
- `POST /nginx/advanced/reload`
- `POST /nginx/advanced/test`
- `POST /nginx/reload`
@@ -1187,7 +1180,6 @@ Regenerate (backend/):
- `POST /services/from-repository`
- `POST /services/manual`
- `POST /services/move-to-project`
- `POST /services/scale-sweep`
- `POST /services/sweep-idle`
- `POST /services/upload`
- `POST /services/{app_id}/backups`
@@ -1200,8 +1192,6 @@ Regenerate (backend/):
- `POST /services/{app_id}/micro-cache/purge`
- `POST /services/{app_id}/restart`
- `POST /services/{app_id}/rollback`
- `POST /services/{app_id}/scale`
- `POST /services/{app_id}/scale/evaluate`
- `POST /services/{app_id}/sleep`
- `POST /services/{app_id}/start`
- `POST /services/{app_id}/stop`
@@ -1295,7 +1285,6 @@ Regenerate (backend/):
- `PUT /apps/{app_id}/previews/settings`
- `PUT /apps/{app_id}/private-url`
- `PUT /apps/{app_id}/resources`
- `PUT /apps/{app_id}/scale-policy`
- `PUT /apps/{app_id}/sleep-policy`
- `PUT /apps/{app_id}/workspace`
- `PUT /auth/me`
@@ -1340,7 +1329,6 @@ Regenerate (backend/):
- `PUT /services/{app_id}/environment`
- `PUT /services/{app_id}/micro-cache`
- `PUT /services/{app_id}/resources`
- `PUT /services/{app_id}/scale-policy`
- `PUT /services/{app_id}/sleep-policy`
- `PUT /services/{app_id}/workspace`
- `PUT /shared/variable-groups/{group_id}`
+7 -5
View File
@@ -2,11 +2,13 @@
*Implementation record and remaining design for WordPress horizontal scaling.*
> **Status (2026-08-27):** ServerKit ships the per-site edge and generic local
> Docker Compose replica controls. WordPress high availability remains
> incomplete. The supported single-host stack now has shared WordPress files,
> but WordPress replica orchestration, scaler-to-nginx upstream updates,
> health-based draining, and live failover proof have not shipped.
> **Status (2026-09-28): replicas and load balancing are not pursued.**
> ServerKit runs one live copy per app. The per-site edge (vhosts, micro-cache,
> the timed access log) is shipped and stays. The generic Compose replica
> controls, the auto-scaler and the nginx upstream/load-balancer builder were
> removed: nginx only ever proxied to one port and the scaler was never
> scheduled, so they could not work. Safe deploys move to A/B slot deploys
> instead of replicas. The sections below are kept as the design record.
---
+3 -3
View File
@@ -13,12 +13,12 @@ debt. Regenerate with `python scripts/generate-migration-inventory.py`.
| Concern | Door | Remaining | Ceiling | Policy |
|---|---|---:|---:|---|
| Identity lookups outside rbac.get_current_user() | rbac.get_current_user() | 69 | 69 | JWT-only routes; the API-key-capable population is held at 0 by a companion assertion |
| Routes on bare @jwt_required() | auth_required() / role decorators | 608 | 608 | REGISTERED EXCEPTION: JWT-only is the deliberate default; conversion grants API-key access and happens per route, on decision |
| Routes on bare @jwt_required() | auth_required() / role decorators | 601 | 601 | REGISTERED EXCEPTION: JWT-only is the deliberate default; conversion grants API-key access and happens per route, on decision |
| HTTP statuses chosen by sniffing error text | typed errors from app.exceptions | 0 | 0 | INVARIANT at 0 - migration completed 2026-08-19 |
| API crashes swallowed without recording | app.error_reporting | 0 | 0 | INVARIANT at 0 |
| Hand-shaped {'error': ...} bodies in app/api | typed errors + the global handler | 1147 | 1150 | migrate when touched; new endpoints raise |
| Hand-shaped {'error': ...} bodies in app/api | typed errors + the global handler | 1132 | 1150 | migrate when touched; new endpoints raise |
| Raw subprocess calls outside the runners | app/utils/system.py runners | 24 | 24 | migrate when touched |
| Controller-boundary violations (routes doing service work) | service layer extraction | 495 | 495 | migrate when touched (first-wave ratchet) |
| Controller-boundary violations (routes doing service work) | service layer extraction | 491 | 491 | migrate when touched (first-wave ratchet) |
| raw api.* calls in pages/ | E1: useServerQuery/useServerMutation | 398 | 398 | migrate when touched |
| per-page toast.error extractions in pages/ | E1: query-layer error presentation | 206 | 206 | migrate when touched |
| hand-rolled form-group blocks | F2: FormField/useForm | 317 | 321 | migrate when touched |
+4 -23
View File
@@ -14,7 +14,7 @@ are under `/api/v1`. "admin" requires an admin user; "edit" uses `_can_edit_app`
| **Layout switcher** | User menu (sidebar footer) → "Layout": Sidebar / Compact / Top bar | No route; persisted to `localStorage['layout']` (`sidebar`\|`rail`\|`topbar`), applied as `data-layout` on `<html>`. Desktop only. |
| **GPU Monitor** | Route `/gpu` · sidebar nav "GPU Monitor" (System) | Per-GPU cards + compute-process table; empty state when no GPU. |
| **Dynamic DNS** | Route `/dynamic-dns` · sidebar nav "Dynamic DNS" (Infrastructure) | Host CRUD; shows the one-time token + ready update URL on create. |
| **Container Ops tab** | App detail page (Docker apps) | Image-update check/apply, auto-sleep, auto-scale. |
| **Container Ops tab** | App detail page (Docker apps) | Image-update check/apply, auto-sleep. |
| **WAF tab** | App detail page (nginx-served apps: Docker + Python) | Install banner, mode/paranoia/anomaly, disabled-rule editor, apply, events. |
---
@@ -52,28 +52,9 @@ App badge: `app.sleep = {enabled, asleep, idle_timeout_minutes}`.
Idle is measured from `last_activity_at` (bumped on wake / `record_activity`); a
no-activity-baseline policy is never slept blind.
### Container auto-scale — `models: container_scale_policies` · `services/container_scale_service.py`
| Method | Path | Auth | Notes |
|---|---|---|---|
| GET | `/apps/<id>/scale-policy` | auth | |
| PUT | `/apps/<id>/scale-policy` | edit | Body `{enabled?, service_name?, min_replicas?, max_replicas?, cpu_high_percent?, cpu_low_percent?, cooldown_seconds?}`. Enabling requires a service name; low CPU must remain below high CPU. |
| POST | `/apps/<id>/scale` | edit | Body `{replicas}`; manual scale through `docker compose --scale`, with a minimum of one replica. |
| POST | `/apps/<id>/scale/evaluate` | edit | One auto decision (returns `action`: scaled_up/down/hold/cooldown/disabled/unknown). |
| POST | `/apps/scale-sweep` | admin | Evaluate every enabled policy. **Cron-drivable.** |
Requires a scale-capable Compose service with no fixed host port or `container_name`.
Local apps only. The configured minimum acts as a floor on the next evaluation,
even when CPU metrics are unavailable. The feature does not configure a load
balancer, shared storage, health checks, or failover, so it must not be presented
as end-to-end high availability. `current_replicas` records the last successful
ServerKit scale command; it does not reconcile changes made through Docker outside
ServerKit.
Verification lives in `frontend/src/components/apps/__tests__/autoScalePolicy.test.mjs`,
`frontend/src/services/api/__tests__/containerOps.test.mjs`, and
`backend/tests/test_container_scale.py`. The backend workflow test covers policy
read/write, evaluation, manual scale, and persistence. Docker execution and traffic
continuity still require the real-host checklist in `docs/HORIZONTAL_SCALING_SPEC.md`.
### Container auto-scale (removed 2026-09-28)
Removed with the other replica pieces: nginx only proxied to one port and
the auto-scale sweep was never scheduled. One live copy per app.
### GPU monitoring — `services/gpu_service.py` (no model)
| Method | Path | Auth | Notes |
@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from 'react';
import { statusKind } from '@/components/ds/status';
import { RefreshCw, ArrowUpCircle, Moon, Sun, Gauge as GaugeIcon, Boxes } from 'lucide-react';
import { RefreshCw, ArrowUpCircle, Moon, Sun, Boxes } from 'lucide-react';
import { Link } from 'react-router-dom';
import api from '../../services/api';
import { useToast } from '../../contexts/useToast.js';
@@ -11,13 +11,6 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { useTranslation } from 'react-i18next';
import {
DEFAULT_SCALE_POLICY,
normalizeScalePolicy,
replicaTarget,
resolvedReplicaCount,
scalePolicyPayload,
} from './autoScalePolicy';
// Short SHA helper for image digests (handles "sha256:abcdef..." or bare hashes)
function shortDigest(digest) {
@@ -382,200 +375,6 @@ const AutoSleepSection = ({ app, onChanged }) => {
);
};
// ============================================================
// Auto-scale section
// ============================================================
const AutoScaleSection = ({ app, onChanged }) => {
const { t } = useTranslation();
const toast = useToast();
const [form, setForm] = useState(DEFAULT_SCALE_POLICY);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [scaling, setScaling] = useState(false);
const [manualReplicas, setManualReplicas] = useState(1);
const load = useCallback(async () => {
setLoading(true);
try {
const data = await api.getScalePolicy(app.id);
const merged = normalizeScalePolicy(data);
setForm(merged);
setManualReplicas(merged.current_replicas ?? merged.min_replicas ?? 1);
} catch (err) {
console.error('Failed to load scale policy:', err);
} finally {
setLoading(false);
}
}, [app.id]);
useEffect(() => { load(); }, [load]);
function setField(key, value) {
setForm((prev) => ({ ...prev, [key]: value }));
}
async function handleSave() {
setSaving(true);
try {
const payload = scalePolicyPayload(form);
const data = await api.updateScalePolicy(app.id, payload);
setForm((prev) => ({ ...prev, ...(data || payload) }));
toast.success(t('app.containerOpsPanel.autoScalePolicySaved', 'Auto-scale policy saved.'));
onChanged?.();
} catch (err) {
toast.error(err.message || t('app.containerOpsPanel.failedToSavePolicy', 'Failed to save policy'));
} finally {
setSaving(false);
}
}
async function handleManualScale() {
const replicas = replicaTarget(manualReplicas);
setScaling(true);
try {
const result = await api.scaleApp(app.id, replicas);
const appliedReplicas = resolvedReplicaCount(result, replicas);
toast.success(t('app.containerOpsPanel.scaledToReplica', 'Scaled to {{replicas}} replica{{value}}.', { replicas: appliedReplicas, value: appliedReplicas === 1 ? '' : 's' }));
await load();
onChanged?.();
} catch (err) {
toast.error(err.message || t('app.containerOpsPanel.failedToScale', 'Failed to scale'));
} finally {
setScaling(false);
}
}
return (
<div className="app-panel container-ops__section">
<div className="app-panel-header">
<GaugeIcon />
<span>{t('app.containerOpsPanel.autoScale', 'Auto-Scale')}</span>
<span className="app-panel-header-actions">
{!loading && (
<Pill kind={form.enabled ? 'green' : 'gray'}>
{form.enabled ? 'Enabled' : 'Disabled'}
</Pill>
)}
</span>
</div>
<div className="app-panel-body">
<p className="app-panel-hint">
{t('app.containerOpsPanel.adjustReplicaCountBasedOnCpu', 'Adjust replica count based on CPU load. Requires a scale-capable Docker Compose service (one that can run multiple replicas).')}
</p>
<div className="container-ops__field">
<div className="container-ops__field-text">
<Label htmlFor={`scale-enabled-${app.id}`}>{t('app.containerOpsPanel.enableAutoScale', 'Enable auto-scale')}</Label>
<span className="container-ops__field-hint">
{t('app.containerOpsPanel.currentlyRunning', 'Currently running')} {form.current_replicas ?? '—'} replica(s).
</span>
</div>
<Switch
id={`scale-enabled-${app.id}`}
checked={!!form.enabled}
onCheckedChange={(v) => setField('enabled', v)}
disabled={loading}
/>
</div>
<div className="container-ops__grid">
<div className="container-ops__input">
<Label htmlFor={`scale-service-${app.id}`}>{t('app.containerOpsPanel.serviceName', 'Service name')}</Label>
<Input
id={`scale-service-${app.id}`}
type="text"
value={form.service_name || ''}
onChange={(e) => setField('service_name', e.target.value)}
placeholder="web"
disabled={loading}
/>
</div>
<div className="container-ops__input">
<Label htmlFor={`scale-cooldown-${app.id}`}>{t('app.containerOpsPanel.cooldownSeconds', 'Cooldown (seconds)')}</Label>
<Input
id={`scale-cooldown-${app.id}`}
type="number"
min={0}
value={form.cooldown_seconds}
onChange={(e) => setField('cooldown_seconds', e.target.value)}
disabled={loading}
/>
</div>
<div className="container-ops__input">
<Label htmlFor={`scale-min-${app.id}`}>{t('app.containerOpsPanel.minReplicas', 'Min replicas')}</Label>
<Input
id={`scale-min-${app.id}`}
type="number"
min={1}
value={form.min_replicas}
onChange={(e) => setField('min_replicas', e.target.value)}
disabled={loading}
/>
</div>
<div className="container-ops__input">
<Label htmlFor={`scale-max-${app.id}`}>{t('app.containerOpsPanel.maxReplicas', 'Max replicas')}</Label>
<Input
id={`scale-max-${app.id}`}
type="number"
min={1}
value={form.max_replicas}
onChange={(e) => setField('max_replicas', e.target.value)}
disabled={loading}
/>
</div>
<div className="container-ops__input">
<Label htmlFor={`scale-cpu-high-${app.id}`}>{t('app.containerOpsPanel.cpuHigh', 'CPU high (%)')}</Label>
<Input
id={`scale-cpu-high-${app.id}`}
type="number"
min={0}
max={100}
value={form.cpu_high_percent}
onChange={(e) => setField('cpu_high_percent', e.target.value)}
disabled={loading}
/>
</div>
<div className="container-ops__input">
<Label htmlFor={`scale-cpu-low-${app.id}`}>{t('app.containerOpsPanel.cpuLow', 'CPU low (%)')}</Label>
<Input
id={`scale-cpu-low-${app.id}`}
type="number"
min={0}
max={100}
value={form.cpu_low_percent}
onChange={(e) => setField('cpu_low_percent', e.target.value)}
disabled={loading}
/>
</div>
</div>
<div className="app-detail-actions container-ops__actions">
<Button size="sm" onClick={handleSave} disabled={saving || loading}>
{saving ? 'Saving…' : 'Save policy'}
</Button>
</div>
<div className="container-ops__manual">
<div className="container-ops__input">
<Label htmlFor={`scale-manual-${app.id}`}>{t('app.containerOpsPanel.manualReplicas', 'Manual replicas')}</Label>
<Input
id={`scale-manual-${app.id}`}
type="number"
min={1}
value={manualReplicas}
onChange={(e) => setManualReplicas(e.target.value)}
disabled={scaling}
/>
</div>
<Button variant="outline" size="sm" onClick={handleManualScale} disabled={scaling || loading}>
{scaling ? 'Scaling…' : 'Apply'}
</Button>
</div>
</div>
</div>
);
};
// ============================================================
// Panel shell
// ============================================================
@@ -587,7 +386,6 @@ const ContainerOpsPanel = ({ app, onChanged }) => {
<ImageUpdateSection app={app} onChanged={onChanged} />
<RegistrySection app={app} onChanged={onChanged} />
<AutoSleepSection app={app} onChanged={onChanged} />
<AutoScaleSection app={app} onChanged={onChanged} />
</div>
);
};
@@ -1,58 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
DEFAULT_SCALE_POLICY,
normalizeScalePolicy,
replicaTarget,
resolvedReplicaCount,
scalePolicyPayload,
} from '../autoScalePolicy.js';
test('normalizes a missing policy to the UI defaults', () => {
assert.deepEqual(normalizeScalePolicy(null), DEFAULT_SCALE_POLICY);
assert.deepEqual(normalizeScalePolicy({ enabled: true, service_name: 'web' }), {
...DEFAULT_SCALE_POLICY,
enabled: true,
service_name: 'web',
});
});
test('coerces backend nulls back to form-safe values', () => {
// The backend stores an unset service_name as null; the form must never
// receive it or the inputs flip from controlled to uncontrolled.
assert.deepEqual(normalizeScalePolicy({
enabled: null,
service_name: null,
min_replicas: null,
max_replicas: 'not-a-number',
current_replicas: null,
}), DEFAULT_SCALE_POLICY);
});
test('serializes the policy using the same bounds as the API', () => {
assert.deepEqual(scalePolicyPayload({
enabled: true,
service_name: ' web ',
min_replicas: '0',
max_replicas: '-2',
cpu_high_percent: '120',
cpu_low_percent: '-4',
cooldown_seconds: '-30',
}), {
enabled: true,
service_name: 'web',
min_replicas: 1,
max_replicas: 1,
cpu_high_percent: 100,
cpu_low_percent: 0,
cooldown_seconds: 0,
});
});
test('manual scale never asks for zero replicas and reports the API result', () => {
assert.equal(replicaTarget('0'), 1);
assert.equal(replicaTarget('4'), 4);
assert.equal(resolvedReplicaCount({ replicas: 2 }, 5), 2);
assert.equal(resolvedReplicaCount(null, 5), 5);
});
@@ -1,59 +0,0 @@
export const DEFAULT_SCALE_POLICY = Object.freeze({
enabled: false,
service_name: '',
min_replicas: 1,
max_replicas: 3,
cpu_high_percent: 75,
cpu_low_percent: 25,
cooldown_seconds: 300,
current_replicas: 1,
});
function integerOr(value, fallback) {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : fallback;
}
function percentage(value, fallback) {
return Math.min(100, Math.max(0, integerOr(value, fallback)));
}
export function normalizeScalePolicy(data) {
// The backend stores an unset service_name as null; coerce it (and any
// non-numeric counters) back to form-safe values so inputs stay controlled.
const merged = { ...DEFAULT_SCALE_POLICY, ...(data || {}) };
return {
...merged,
enabled: !!merged.enabled,
service_name: typeof merged.service_name === 'string' ? merged.service_name : '',
min_replicas: integerOr(merged.min_replicas, DEFAULT_SCALE_POLICY.min_replicas),
max_replicas: integerOr(merged.max_replicas, DEFAULT_SCALE_POLICY.max_replicas),
cpu_high_percent: integerOr(merged.cpu_high_percent, DEFAULT_SCALE_POLICY.cpu_high_percent),
cpu_low_percent: integerOr(merged.cpu_low_percent, DEFAULT_SCALE_POLICY.cpu_low_percent),
cooldown_seconds: integerOr(merged.cooldown_seconds, DEFAULT_SCALE_POLICY.cooldown_seconds),
current_replicas: integerOr(merged.current_replicas, DEFAULT_SCALE_POLICY.current_replicas),
};
}
export function scalePolicyPayload(form) {
const minReplicas = Math.max(1, integerOr(form.min_replicas, 1));
const maxReplicas = Math.max(minReplicas, integerOr(form.max_replicas, minReplicas));
return {
enabled: !!form.enabled,
service_name: String(form.service_name || '').trim(),
min_replicas: minReplicas,
max_replicas: maxReplicas,
cpu_high_percent: percentage(form.cpu_high_percent, 75),
cpu_low_percent: percentage(form.cpu_low_percent, 25),
cooldown_seconds: Math.max(0, integerOr(form.cooldown_seconds, 0)),
};
}
export function replicaTarget(value) {
return Math.max(1, integerOr(value, 1));
}
export function resolvedReplicaCount(result, requested) {
return replicaTarget(result?.replicas ?? requested);
}
-14
View File
@@ -986,26 +986,17 @@
},
"containerOpsPanel": {
"actionFailed": "فشل الإجراء",
"adjustReplicaCountBasedOnCpu": "ضبط عدد النسخ المتماثلة بناءً على حمل وحدة المعالجة المركزية. يتطلب خدمة Docker Compose قادرة على التوسع (خدمة يمكنها تشغيل نسخ متماثلة متعددة).",
"anImageUpdateIsAvailable": "تحديث الصورة متاح.",
"appPutToSleep": "وضع التطبيق في وضع السكون.",
"appWoken": "استيقظ التطبيق.",
"authenticateWithStoredCredentialsBeforePulling": "قم بالمصادقة باستخدام بيانات الاعتماد المخزنة قبل سحب صورة هذا التطبيق. أضف السجلات تحت",
"autoScale": "مقياس تلقائي",
"autoScalePolicySaved": "تم حفظ سياسة القياس التلقائي.",
"autoSleep": "النوم التلقائي",
"autoSleepPolicySaved": "تم حفظ سياسة النوم التلقائي.",
"cooldownSeconds": "فترة التهدئة (ثواني)",
"cpuHigh": "ارتفاع وحدة المعالجة المركزية (%)",
"cpuLow": "وحدة المعالجة المركزية منخفضة (%)",
"currentDigest": "الملخص الحالي",
"currentlyRunning": "قيد التشغيل حاليا",
"enableAutoScale": "تمكين القياس التلقائي",
"enableAutoSleep": "تمكين النوم التلقائي",
"failedToApplyUpdate": "فشل في تطبيق التحديث",
"failedToCheckForUpdates": "فشل التحقق من وجود تحديثات",
"failedToSavePolicy": "فشل في حفظ السياسة",
"failedToScale": "فشل في القياس",
"failedToUpdateRegistry": "فشل في تحديث التسجيل",
"idleAppsAreSuspendedAfterThe": "يتم تعليق التطبيقات الخاملة بعد انتهاء المهلة الموضحة أدناه.",
"idleTimeoutMinutes": "مهلة الخمول (بالدقائق)",
@@ -1015,9 +1006,6 @@
"imageUpdatesApplyToDockerCompose": "تنطبق تحديثات الصور على تطبيقات Docker Compose. لا يزال بإمكانك التحقق من ملخص هذا التطبيق، ولكن \"التحديث الآن\" متاح فقط لتطبيقات إنشاء عامل الإرساء.",
"lastChecked": "آخر فحص",
"latestDigest": "أحدث الملخص",
"manualReplicas": "النسخ المتماثلة اليدوية",
"maxReplicas": "ماكس النسخ المتماثلة",
"minReplicas": "الحد الأدنى من النسخ المتماثلة",
"privateRegistry": "السجل الخاص",
"publicImagesPullAnonymouslyPickA": "سحب الصور العامة بشكل مجهول؛ اختر سجلاً للصور الخاصة.",
"publicNoAuth": "عام (بدون مصادقة)",
@@ -1025,8 +1013,6 @@
"registry": "التسجيل",
"registryAttached": "التسجيل مرفق.",
"registryDetachedPullsAreAnonymous": "التسجيل منفصل - عمليات السحب مجهولة المصدر.",
"scaledToReplica": "تم تغيير الحجم إلى نسخة {{replicas}} المتماثلة {{value}}.",
"serviceName": "اسم الخدمة",
"settingsConnections": "الإعدادات → الاتصالات",
"stopTheContainerAfterAPeriod": "قم بإيقاف الحاوية بعد فترة من عدم النشاط لتحرير الموارد. يتم تنشيطه تلقائيًا عند الطلب التالي.",
"updateImage": "تحديث الصورة",
-14
View File
@@ -986,26 +986,17 @@
},
"containerOpsPanel": {
"actionFailed": "অ্যাকশন ব্যর্থ হয়েছে",
"adjustReplicaCountBasedOnCpu": "CPU লোডের উপর ভিত্তি করে প্রতিরূপ গণনা সামঞ্জস্য করুন। একটি স্কেল-সক্ষম Docker Compose পরিষেবা প্রয়োজন (যা একাধিক প্রতিলিপি চালাতে পারে)।",
"anImageUpdateIsAvailable": "একটি ইমেজ আপডেট উপলব্ধ.",
"appPutToSleep": "অ্যাপটি ঘুমাতে দেয়।",
"appWoken": "অ্যাপ জেগে উঠেছে।",
"authenticateWithStoredCredentialsBeforePulling": "এই অ্যাপের ছবি তোলার আগে সঞ্চিত শংসাপত্র দিয়ে প্রমাণীকরণ করুন। অধীনে রেজিস্ট্রি যোগ করুন",
"autoScale": "অটো-স্কেল",
"autoScalePolicySaved": "স্বয়ংক্রিয়-স্কেল নীতি সংরক্ষিত।",
"autoSleep": "অটো-স্লিপ",
"autoSleepPolicySaved": "অটো-স্লিপ নীতি সংরক্ষিত।",
"cooldownSeconds": "কুলডাউন (সেকেন্ড)",
"cpuHigh": "CPU উচ্চ (%)",
"cpuLow": "CPU কম (%)",
"currentDigest": "বর্তমান ডাইজেস্ট",
"currentlyRunning": "বর্তমানে চলছে",
"enableAutoScale": "স্বয়ংক্রিয়-স্কেল সক্ষম করুন",
"enableAutoSleep": "স্বয়ংক্রিয় ঘুম চালু করুন",
"failedToApplyUpdate": "আপডেট প্রয়োগ করতে ব্যর্থ হয়েছে৷",
"failedToCheckForUpdates": "আপডেটের জন্য চেক করতে ব্যর্থ",
"failedToSavePolicy": "নীতি সংরক্ষণ করতে ব্যর্থ হয়েছে",
"failedToScale": "স্কেল করতে ব্যর্থ",
"failedToUpdateRegistry": "রেজিস্ট্রি আপডেট করতে ব্যর্থ হয়েছে",
"idleAppsAreSuspendedAfterThe": "নিষ্ক্রিয় অ্যাপগুলি নীচের সময় শেষ হওয়ার পরে স্থগিত করা হয়েছে৷",
"idleTimeoutMinutes": "নিষ্ক্রিয় সময়সীমা (মিনিট)",
@@ -1015,9 +1006,6 @@
"imageUpdatesApplyToDockerCompose": "চিত্র আপডেটগুলি Docker Compose অ্যাপগুলিতে প্রযোজ্য৷ আপনি এখনও এই অ্যাপের ডাইজেস্ট পরীক্ষা করতে পারেন, কিন্তু \"এখনই আপডেট করুন\" শুধুমাত্র ডকার-কম্পোজ অ্যাপগুলির জন্য উপলব্ধ৷",
"lastChecked": "শেষ চেক করা হয়েছে",
"latestDigest": "সর্বশেষ ডাইজেস্ট",
"manualReplicas": "ম্যানুয়াল প্রতিলিপি",
"maxReplicas": "সর্বোচ্চ প্রতিলিপি",
"minReplicas": "সর্বনিম্ন প্রতিলিপি",
"privateRegistry": "প্রাইভেট রেজিস্ট্রি",
"publicImagesPullAnonymouslyPickA": "পাবলিক ইমেজ বেনামে টান; ব্যক্তিগত ছবির জন্য একটি রেজিস্ট্রি চয়ন করুন.",
"publicNoAuth": "সর্বজনীন (কোন প্রমাণ নেই)",
@@ -1025,8 +1013,6 @@
"registry": "রেজিস্ট্রি",
"registryAttached": "রেজিস্ট্রি সংযুক্ত।",
"registryDetachedPullsAreAnonymous": "রেজিস্ট্রি বিচ্ছিন্ন — টান বেনামী।",
"scaledToReplica": "{{replicas}} রেপ্লিকা{{value}} এ স্কেল করা হয়েছে।",
"serviceName": "পরিষেবার নাম",
"settingsConnections": "সেটিংস → সংযোগ",
"stopTheContainerAfterAPeriod": "মুক্ত সম্পদে নিষ্ক্রিয়তার সময়কালের পরে ধারকটি বন্ধ করুন। পরবর্তী অনুরোধে এটি স্বয়ংক্রিয়ভাবে জেগে ওঠে।",
"updateImage": "ইমেজ আপডেট করুন",
-14
View File
@@ -986,26 +986,17 @@
},
"containerOpsPanel": {
"actionFailed": "Aktion fehlgeschlagen",
"adjustReplicaCountBasedOnCpu": "Passen Sie die Anzahl der Replikate basierend auf der CPU-Auslastung an. Erfordert einen skalierbaren Docker Compose-Dienst (einen, der mehrere Replikate ausführen kann).",
"anImageUpdateIsAvailable": "Ein Bild-Update ist verfügbar.",
"appPutToSleep": "App in den Ruhezustand versetzt.",
"appWoken": "App aufgeweckt.",
"authenticateWithStoredCredentialsBeforePulling": "Authentifizieren Sie sich mit gespeicherten Anmeldeinformationen, bevor Sie das Bild dieser App abrufen. Fügen Sie unten Register hinzu",
"autoScale": "Automatische Skalierung",
"autoScalePolicySaved": "Richtlinie zur automatischen Skalierung gespeichert.",
"autoSleep": "Automatischer Schlafmodus",
"autoSleepPolicySaved": "Richtlinie für den automatischen Ruhezustand gespeichert.",
"cooldownSeconds": "Abklingzeit (Sekunden)",
"cpuHigh": "CPU hoch (%)",
"cpuLow": "CPU niedrig (%)",
"currentDigest": "Aktuelle Zusammenfassung",
"currentlyRunning": "Läuft derzeit",
"enableAutoScale": "Aktivieren Sie die automatische Skalierung",
"enableAutoSleep": "Aktivieren Sie den automatischen Ruhezustand",
"failedToApplyUpdate": "Update konnte nicht angewendet werden",
"failedToCheckForUpdates": "Die Suche nach Updates ist fehlgeschlagen",
"failedToSavePolicy": "Die Richtlinie konnte nicht gespeichert werden",
"failedToScale": "Skalierung fehlgeschlagen",
"failedToUpdateRegistry": "Registrierung konnte nicht aktualisiert werden",
"idleAppsAreSuspendedAfterThe": "Inaktive Apps werden nach dem unten angegebenen Zeitlimit angehalten.",
"idleTimeoutMinutes": "Leerlauf-Timeout (Minuten)",
@@ -1015,9 +1006,6 @@
"imageUpdatesApplyToDockerCompose": "Bildaktualisierungen gelten für Docker Compose-Apps. Sie können den Digest dieser App weiterhin überprüfen, aber „Jetzt aktualisieren“ ist nur für Docker-Compose-Apps verfügbar.",
"lastChecked": "Zuletzt überprüft",
"latestDigest": "Neueste Zusammenfassung",
"manualReplicas": "Manuelle Nachbildungen",
"maxReplicas": "Max-Repliken",
"minReplicas": "Min. Repliken",
"privateRegistry": "Privates Register",
"publicImagesPullAnonymouslyPickA": "Öffentliche Bilder werden anonym abgerufen; Wählen Sie eine Registrierung für private Bilder.",
"publicNoAuth": "Öffentlich (keine Authentifizierung)",
@@ -1025,8 +1013,6 @@
"registry": "Registrierung",
"registryAttached": "Register beigefügt.",
"registryDetachedPullsAreAnonymous": "Registrierung getrennt – Pulls sind anonym.",
"scaledToReplica": "Skaliert auf {{replicas}} Replikat{{value}}.",
"serviceName": "Dienstname",
"settingsConnections": "Einstellungen → Verbindungen",
"stopTheContainerAfterAPeriod": "Stoppen Sie den Container nach einer Zeit der Inaktivität, um Ressourcen freizugeben. Bei der nächsten Anfrage wird es automatisch aktiviert.",
"updateImage": "Bild aktualisieren",
-14
View File
@@ -1210,26 +1210,17 @@
},
"containerOpsPanel": {
"actionFailed": "Action failed",
"adjustReplicaCountBasedOnCpu": "Adjust replica count based on CPU load. Requires a scale-capable Docker Compose service (one that can run multiple replicas).",
"anImageUpdateIsAvailable": "An image update is available.",
"appPutToSleep": "App put to sleep.",
"appWoken": "App woken.",
"authenticateWithStoredCredentialsBeforePulling": "Authenticate with stored credentials before pulling this app's image. Add registries under",
"autoScale": "Auto-Scale",
"autoScalePolicySaved": "Auto-scale policy saved.",
"autoSleep": "Auto-Sleep",
"autoSleepPolicySaved": "Auto-sleep policy saved.",
"cooldownSeconds": "Cooldown (seconds)",
"cpuHigh": "CPU high (%)",
"cpuLow": "CPU low (%)",
"currentDigest": "Current digest",
"currentlyRunning": "Currently running",
"enableAutoScale": "Enable auto-scale",
"enableAutoSleep": "Enable auto-sleep",
"failedToApplyUpdate": "Failed to apply update",
"failedToCheckForUpdates": "Failed to check for updates",
"failedToSavePolicy": "Failed to save policy",
"failedToScale": "Failed to scale",
"failedToUpdateRegistry": "Failed to update registry",
"idleAppsAreSuspendedAfterThe": "Idle apps are suspended after the timeout below.",
"idleTimeoutMinutes": "Idle timeout (minutes)",
@@ -1239,9 +1230,6 @@
"imageUpdatesApplyToDockerCompose": "Image updates apply to Docker Compose apps. You can still check this app's digest, but \"Update now\" is only available for docker-compose apps.",
"lastChecked": "Last checked",
"latestDigest": "Latest digest",
"manualReplicas": "Manual replicas",
"maxReplicas": "Max replicas",
"minReplicas": "Min replicas",
"privateRegistry": "Private Registry",
"publicImagesPullAnonymouslyPickA": "Public images pull anonymously; pick a registry for private images.",
"publicNoAuth": "Public (no auth)",
@@ -1249,8 +1237,6 @@
"registry": "Registry",
"registryAttached": "Registry attached.",
"registryDetachedPullsAreAnonymous": "Registry detached — pulls are anonymous.",
"scaledToReplica": "Scaled to {{replicas}} replica{{value}}.",
"serviceName": "Service name",
"settingsConnections": "Settings → Connections",
"stopTheContainerAfterAPeriod": "Stop the container after a period of inactivity to free resources. It wakes automatically on the next request.",
"updateImage": "Update image",
-14
View File
@@ -986,26 +986,17 @@
},
"containerOpsPanel": {
"actionFailed": "La acción falló",
"adjustReplicaCountBasedOnCpu": "Ajusta el número de réplicas según la carga de CPU. Requiere un servicio de Docker Compose que admita escalado (que pueda ejecutar varias réplicas).",
"anImageUpdateIsAvailable": "Hay una actualización de imagen disponible.",
"appPutToSleep": "App suspendida.",
"appWoken": "App reactivada.",
"authenticateWithStoredCredentialsBeforePulling": "Autentícate con las credenciales guardadas antes de descargar la imagen de esta app. Añade registros en",
"autoScale": "Autoescalado",
"autoScalePolicySaved": "Política de autoescalado guardada.",
"autoSleep": "Suspensión automática",
"autoSleepPolicySaved": "Política de suspensión automática guardada.",
"cooldownSeconds": "Enfriamiento (segundos)",
"cpuHigh": "CPU alta (%)",
"cpuLow": "CPU baja (%)",
"currentDigest": "Digest actual",
"currentlyRunning": "En ejecución",
"enableAutoScale": "Activar autoescalado",
"enableAutoSleep": "Activar suspensión automática",
"failedToApplyUpdate": "No se pudo aplicar la actualización",
"failedToCheckForUpdates": "No se pudieron buscar actualizaciones",
"failedToSavePolicy": "No se pudo guardar la política",
"failedToScale": "No se pudo escalar",
"failedToUpdateRegistry": "No se pudo actualizar el registro",
"idleAppsAreSuspendedAfterThe": "Las apps inactivas se suspenden tras el tiempo de espera indicado abajo.",
"idleTimeoutMinutes": "Tiempo de inactividad (minutos)",
@@ -1015,9 +1006,6 @@
"imageUpdatesApplyToDockerCompose": "Las actualizaciones de imagen se aplican a las apps de Docker Compose. Puedes comprobar el digest de esta app, pero \"Actualizar ahora\" solo está disponible para las apps de docker-compose.",
"lastChecked": "Última comprobación",
"latestDigest": "Digest más reciente",
"manualReplicas": "Réplicas manuales",
"maxReplicas": "Réplicas máx.",
"minReplicas": "Réplicas mín.",
"privateRegistry": "Registro privado",
"publicImagesPullAnonymouslyPickA": "Las imágenes públicas se descargan de forma anónima; elige un registro para las imágenes privadas.",
"publicNoAuth": "Público (sin autenticación)",
@@ -1025,8 +1013,6 @@
"registry": "Registro",
"registryAttached": "Registro vinculado.",
"registryDetachedPullsAreAnonymous": "Registro desvinculado: las descargas serán anónimas.",
"scaledToReplica": "Escalado a {{replicas}} réplica{{value}}.",
"serviceName": "Nombre del servicio",
"settingsConnections": "Configuración → Conexiones",
"stopTheContainerAfterAPeriod": "Detiene el contenedor tras un período de inactividad para liberar recursos. Se reactiva automáticamente con la siguiente solicitud.",
"updateImage": "Actualizar imagen",
-14
View File
@@ -986,26 +986,17 @@
},
"containerOpsPanel": {
"actionFailed": "L'action a échoué",
"adjustReplicaCountBasedOnCpu": "Ajustez le nombre de répliques en fonction de la charge du processeur. Nécessite un service Docker Compose évolutif (pouvant exécuter plusieurs réplicas).",
"anImageUpdateIsAvailable": "Une mise à jour d'image est disponible.",
"appPutToSleep": "Application mise en veille.",
"appWoken": "Application réveillée.",
"authenticateWithStoredCredentialsBeforePulling": "Authentifiez-vous avec les informations d'identification stockées avant d'extraire l'image de cette application. Ajouter des registres sous",
"autoScale": "Mise à l'échelle automatique",
"autoScalePolicySaved": "Stratégie de mise à l'échelle automatique enregistrée.",
"autoSleep": "Mise en veille automatique",
"autoSleepPolicySaved": "Politique de veille automatique enregistrée.",
"cooldownSeconds": "Temps de recharge (secondes)",
"cpuHigh": "Processeur élevé (%)",
"cpuLow": "Processeur faible (%)",
"currentDigest": "Résumé actuel",
"currentlyRunning": "Actuellement en cours d'exécution",
"enableAutoScale": "Activer la mise à l'échelle automatique",
"enableAutoSleep": "Activer la veille automatique",
"failedToApplyUpdate": "Échec de l'application de la mise à jour",
"failedToCheckForUpdates": "Échec de la vérification des mises à jour",
"failedToSavePolicy": "Échec de l'enregistrement de la stratégie",
"failedToScale": "Échec de la mise à l'échelle",
"failedToUpdateRegistry": "Échec de la mise à jour du registre",
"idleAppsAreSuspendedAfterThe": "Les applications inactives sont suspendues après le délai d'attente ci-dessous.",
"idleTimeoutMinutes": "Délai d'inactivité (minutes)",
@@ -1015,9 +1006,6 @@
"imageUpdatesApplyToDockerCompose": "Les mises à jour d'images s'appliquent aux applications Docker Compose. Vous pouvez toujours consulter le résumé de cette application, mais « Mettre à jour maintenant » n'est disponible que pour les applications Docker-compose.",
"lastChecked": "Dernière vérification",
"latestDigest": "Dernier résumé",
"manualReplicas": "Répliques manuelles",
"maxReplicas": "Nombre maximal de répliques",
"minReplicas": "Réplicas minimum",
"privateRegistry": "Registre privé",
"publicImagesPullAnonymouslyPickA": "Les images publiques sont extraites de manière anonyme ; choisissez un registre pour les images privées.",
"publicNoAuth": "Public (pas d'autorisation)",
@@ -1025,8 +1013,6 @@
"registry": "Enregistrement",
"registryAttached": "Registre ci-joint.",
"registryDetachedPullsAreAnonymous": "Registre détaché – les extractions sont anonymes.",
"scaledToReplica": "À l'échelle de la réplique {{replicas}}{{value}}.",
"serviceName": "Nom du service",
"settingsConnections": "Paramètres → Connexions",
"stopTheContainerAfterAPeriod": "Arrêtez le conteneur après une période d'inactivité pour libérer des ressources. Il se réveille automatiquement à la prochaine demande.",
"updateImage": "Mettre à jour l'image",
-14
View File
@@ -986,26 +986,17 @@
},
"containerOpsPanel": {
"actionFailed": "Tindakan gagal",
"adjustReplicaCountBasedOnCpu": "Sesuaikan jumlah replika berdasarkan beban CPU. Memerlukan layanan Docker Compose berkemampuan skala (yang dapat menjalankan banyak replika).",
"anImageUpdateIsAvailable": "Pembaruan gambar tersedia.",
"appPutToSleep": "Aplikasi tertidur.",
"appWoken": "Aplikasi terbangun.",
"authenticateWithStoredCredentialsBeforePulling": "Otentikasi dengan kredensial yang disimpan sebelum menarik gambar aplikasi ini. Tambahkan registri di bawah",
"autoScale": "Skala Otomatis",
"autoScalePolicySaved": "Kebijakan penskalaan otomatis disimpan.",
"autoSleep": "Tidur Otomatis",
"autoSleepPolicySaved": "Kebijakan tidur otomatis disimpan.",
"cooldownSeconds": "Waktu tunggu (detik)",
"cpuHigh": "CPU tinggi (%)",
"cpuLow": "CPU rendah (%)",
"currentDigest": "Intisari saat ini",
"currentlyRunning": "Sedang berjalan",
"enableAutoScale": "Aktifkan penskalaan otomatis",
"enableAutoSleep": "Aktifkan tidur otomatis",
"failedToApplyUpdate": "Gagal menerapkan pembaruan",
"failedToCheckForUpdates": "Gagal memeriksa pembaruan",
"failedToSavePolicy": "Gagal menyimpan kebijakan",
"failedToScale": "Gagal menskalakan",
"failedToUpdateRegistry": "Gagal memperbarui registri",
"idleAppsAreSuspendedAfterThe": "Aplikasi yang menganggur akan ditangguhkan setelah waktu habis di bawah.",
"idleTimeoutMinutes": "Batas waktu menganggur (menit)",
@@ -1015,9 +1006,6 @@
"imageUpdatesApplyToDockerCompose": "Pembaruan gambar berlaku untuk aplikasi Docker Compose. Anda masih dapat memeriksa intisari aplikasi ini, namun \"Perbarui sekarang\" hanya tersedia untuk aplikasi pembuat buruh pelabuhan.",
"lastChecked": "Terakhir diperiksa",
"latestDigest": "Intisari terbaru",
"manualReplicas": "Replika manual",
"maxReplicas": "Replika maksimal",
"minReplicas": "Minimal replika",
"privateRegistry": "Registri Pribadi",
"publicImagesPullAnonymouslyPickA": "Gambar publik diambil secara anonim; pilih registri untuk gambar pribadi.",
"publicNoAuth": "Publik (tanpa autentikasi)",
@@ -1025,8 +1013,6 @@
"registry": "Registri",
"registryAttached": "Registri terlampir.",
"registryDetachedPullsAreAnonymous": "Registri terlepas — penarikan bersifat anonim.",
"scaledToReplica": "Diskalakan ke replika {{replicas}}{{value}}.",
"serviceName": "Nama layanan",
"settingsConnections": "Pengaturan → Koneksi",
"stopTheContainerAfterAPeriod": "Hentikan penampung setelah beberapa saat tidak aktif untuk mengosongkan sumber daya. Itu bangun secara otomatis pada permintaan berikutnya.",
"updateImage": "Perbarui gambar",
-14
View File
@@ -986,26 +986,17 @@
},
"containerOpsPanel": {
"actionFailed": "Azione fallita",
"adjustReplicaCountBasedOnCpu": "Regola il conteggio delle repliche in base al carico della CPU. Richiede un servizio Docker Compose con funzionalità di scalabilità (in grado di eseguire più repliche).",
"anImageUpdateIsAvailable": "È disponibile un aggiornamento dell'immagine.",
"appPutToSleep": "App messa in stop.",
"appWoken": "App attivata.",
"authenticateWithStoredCredentialsBeforePulling": "Autenticati con le credenziali archiviate prima di estrarre l'immagine di questa app. Aggiungi registri sotto",
"autoScale": "Scalabilità automatica",
"autoScalePolicySaved": "Criterio di scalabilità automatica salvato.",
"autoSleep": "Sospensione automatica",
"autoSleepPolicySaved": "Criterio di sospensione automatica salvato.",
"cooldownSeconds": "Recupero (secondi)",
"cpuHigh": "CPU alta (%)",
"cpuLow": "CPU bassa (%)",
"currentDigest": "Digest attuale",
"currentlyRunning": "Attualmente in esecuzione",
"enableAutoScale": "Abilita la scalabilità automatica",
"enableAutoSleep": "Abilita la sospensione automatica",
"failedToApplyUpdate": "Impossibile applicare l'aggiornamento",
"failedToCheckForUpdates": "Impossibile controllare gli aggiornamenti",
"failedToSavePolicy": "Impossibile salvare la policy",
"failedToScale": "Impossibile ridimensionare",
"failedToUpdateRegistry": "Impossibile aggiornare il registro",
"idleAppsAreSuspendedAfterThe": "Le app inattive vengono sospese dopo il timeout indicato di seguito.",
"idleTimeoutMinutes": "Timeout di inattività (minuti)",
@@ -1015,9 +1006,6 @@
"imageUpdatesApplyToDockerCompose": "Gli aggiornamenti delle immagini si applicano alle app Docker Compose. Puoi comunque controllare il digest di questa app, ma \"Aggiorna ora\" è disponibile solo per le app di composizione docker.",
"lastChecked": "Ultimo controllo",
"latestDigest": "Ultimo riassunto",
"manualReplicas": "Repliche manuali",
"maxReplicas": "Massime repliche",
"minReplicas": "Repliche minime",
"privateRegistry": "Registro privato",
"publicImagesPullAnonymouslyPickA": "Le immagini pubbliche vengono visualizzate in modo anonimo; scegli un registro per le immagini private.",
"publicNoAuth": "Pubblico (nessuna autenticazione)",
@@ -1025,8 +1013,6 @@
"registry": "Registro",
"registryAttached": "Registro allegato.",
"registryDetachedPullsAreAnonymous": "Registro scollegato: i pull sono anonimi.",
"scaledToReplica": "In scala per la replica {{replicas}}{{value}}.",
"serviceName": "Nome del servizio",
"settingsConnections": "Impostazioni → Connessioni",
"stopTheContainerAfterAPeriod": "Arrestare il contenitore dopo un periodo di inattività per liberare risorse. Si riattiva automaticamente alla richiesta successiva.",
"updateImage": "Aggiorna immagine",
-14
View File
@@ -986,26 +986,17 @@
},
"containerOpsPanel": {
"actionFailed": "작업 실패",
"adjustReplicaCountBasedOnCpu": "CPU 로드에 따라 복제본 수를 조정합니다. 확장 가능한 Docker Compose 서비스(여러 복제본을 실행할 수 있는 서비스)가 필요합니다.",
"anImageUpdateIsAvailable": "이미지 업데이트가 가능합니다.",
"appPutToSleep": "앱이 절전 모드로 전환되었습니다.",
"appWoken": "앱이 깨어났습니다.",
"authenticateWithStoredCredentialsBeforePulling": "이 앱의 이미지를 가져오기 전에 저장된 자격 증명으로 인증하세요. 아래에 레지스트리 추가",
"autoScale": "자동 크기 조정",
"autoScalePolicySaved": "자동 크기 조정 정책이 저장되었습니다.",
"autoSleep": "자동 수면",
"autoSleepPolicySaved": "자동 절전 정책이 저장되었습니다.",
"cooldownSeconds": "쿨다운(초)",
"cpuHigh": "CPU 높음(%)",
"cpuLow": "CPU 부족(%)",
"currentDigest": "현재 다이제스트",
"currentlyRunning": "현재 실행 중",
"enableAutoScale": "자동 크기 조정 활성화",
"enableAutoSleep": "자동 절전 모드 활성화",
"failedToApplyUpdate": "업데이트를 적용하지 못했습니다.",
"failedToCheckForUpdates": "업데이트를 확인하지 못했습니다.",
"failedToSavePolicy": "정책을 저장하지 못했습니다.",
"failedToScale": "확장 실패",
"failedToUpdateRegistry": "레지스트리를 업데이트하지 못했습니다.",
"idleAppsAreSuspendedAfterThe": "유휴 앱은 아래 시간 초과 후 일시 중지됩니다.",
"idleTimeoutMinutes": "유휴 시간 초과(분)",
@@ -1015,9 +1006,6 @@
"imageUpdatesApplyToDockerCompose": "이미지 업데이트는 Docker Compose 앱에 적용됩니다. 이 앱의 다이제스트를 계속 확인할 수 있지만 '지금 업데이트'는 docker-compose 앱에만 사용할 수 있습니다.",
"lastChecked": "마지막 확인",
"latestDigest": "최신 다이제스트",
"manualReplicas": "수동 복제본",
"maxReplicas": "최대 복제본",
"minReplicas": "최소 복제본",
"privateRegistry": "개인 레지스트리",
"publicImagesPullAnonymouslyPickA": "공개 이미지는 익명으로 가져옵니다. 비공개 이미지에 대한 레지스트리를 선택하세요.",
"publicNoAuth": "공개(인증 없음)",
@@ -1025,8 +1013,6 @@
"registry": "레지스트리",
"registryAttached": "레지스트리가 첨부되었습니다.",
"registryDetachedPullsAreAnonymous": "레지스트리 분리됨 - 가져오기는 익명으로 이루어집니다.",
"scaledToReplica": "{{replicas}}개의 복제본{{value}}으로 확장되었습니다.",
"serviceName": "서비스 이름",
"settingsConnections": "설정 → 연결",
"stopTheContainerAfterAPeriod": "리소스를 확보하려면 일정 기간 동안 활동이 없으면 컨테이너를 중지하세요. 다음 요청 시 자동으로 깨어납니다.",
"updateImage": "이미지 업데이트",
-14
View File
@@ -986,26 +986,17 @@
},
"containerOpsPanel": {
"actionFailed": "Akcja nie powiodła się",
"adjustReplicaCountBasedOnCpu": "Dostosuj liczbę replik na podstawie obciążenia procesora. Wymaga skalowalnej usługi Docker Compose (takiej, która może uruchamiać wiele replik).",
"anImageUpdateIsAvailable": "Dostępna jest aktualizacja obrazu.",
"appPutToSleep": "Aplikacja uśpiona.",
"appWoken": "Aplikacja obudzona.",
"authenticateWithStoredCredentialsBeforePulling": "Uwierzytelnij się przy użyciu zapisanych danych uwierzytelniających przed pobraniem obrazu tej aplikacji. Dodaj rejestry poniżej",
"autoScale": "Automatyczne skalowanie",
"autoScalePolicySaved": "Zapisano zasadę automatycznego skalowania.",
"autoSleep": "Automatyczne uśpienie",
"autoSleepPolicySaved": "Zapisano zasady automatycznego uśpienia.",
"cooldownSeconds": "Czas odnowienia (sekundy)",
"cpuHigh": "Wysoki poziom procesora (%)",
"cpuLow": "Niski poziom procesora (%)",
"currentDigest": "Aktualne podsumowanie",
"currentlyRunning": "Obecnie działa",
"enableAutoScale": "Włącz automatyczne skalowanie",
"enableAutoSleep": "Włącz automatyczne uśpienie",
"failedToApplyUpdate": "Nie udało się zastosować aktualizacji",
"failedToCheckForUpdates": "Nie udało się sprawdzić dostępności aktualizacji",
"failedToSavePolicy": "Nie udało się zapisać zasad",
"failedToScale": "Nie udało się skalować",
"failedToUpdateRegistry": "Nie udało się zaktualizować rejestru",
"idleAppsAreSuspendedAfterThe": "Bezczynne aplikacje są zawieszane po upływie poniższego limitu czasu.",
"idleTimeoutMinutes": "Limit czasu bezczynności (minuty)",
@@ -1015,9 +1006,6 @@
"imageUpdatesApplyToDockerCompose": "Aktualizacje obrazów dotyczą aplikacji Docker Compose. Nadal możesz sprawdzić podsumowanie tej aplikacji, ale opcja „Aktualizuj teraz” jest dostępna tylko w przypadku aplikacji do tworzenia plików w oknie dokowanym.",
"lastChecked": "Ostatnio sprawdzane",
"latestDigest": "Najnowsze podsumowanie",
"manualReplicas": "Ręczne repliki",
"maxReplicas": "Maksymalna liczba replik",
"minReplicas": "Min. repliki",
"privateRegistry": "Rejestr prywatny",
"publicImagesPullAnonymouslyPickA": "Obrazy publiczne są pobierane anonimowo; wybierz rejestr dla prywatnych zdjęć.",
"publicNoAuth": "Publiczne (bez autoryzacji)",
@@ -1025,8 +1013,6 @@
"registry": "Rejestr",
"registryAttached": "Rejestr w załączeniu.",
"registryDetachedPullsAreAnonymous": "Rejestr odłączony — ściągania są anonimowe.",
"scaledToReplica": "Przeskalowano do repliki {{replicas}}{{value}}.",
"serviceName": "Nazwa usługi",
"settingsConnections": "Ustawienia → Połączenia",
"stopTheContainerAfterAPeriod": "Zatrzymaj kontener po okresie bezczynności, aby zwolnić zasoby. Budzi się automatycznie przy następnym żądaniu.",
"updateImage": "Zaktualizuj obraz",
-14
View File
@@ -986,26 +986,17 @@
},
"containerOpsPanel": {
"actionFailed": "Falha na ação",
"adjustReplicaCountBasedOnCpu": "Ajuste a contagem de réplicas com base na carga da CPU. Requer um serviço Docker Compose com capacidade de escala (que possa executar várias réplicas).",
"anImageUpdateIsAvailable": "Uma atualização de imagem está disponível.",
"appPutToSleep": "Aplicativo colocado em suspensão.",
"appWoken": "Aplicativo acordado.",
"authenticateWithStoredCredentialsBeforePulling": "Autentique com credenciais armazenadas antes de extrair a imagem deste aplicativo. Adicione registros em",
"autoScale": "Escala automática",
"autoScalePolicySaved": "Política de escalonamento automático salva.",
"autoSleep": "Suspensão automática",
"autoSleepPolicySaved": "Política de suspensão automática salva.",
"cooldownSeconds": "Tempo de espera (segundos)",
"cpuHigh": "CPU alta (%)",
"cpuLow": "CPU baixa (%)",
"currentDigest": "Resumo atual",
"currentlyRunning": "Atualmente em execução",
"enableAutoScale": "Ativar dimensionamento automático",
"enableAutoSleep": "Ativar suspensão automática",
"failedToApplyUpdate": "Falha ao aplicar a atualização",
"failedToCheckForUpdates": "Falha ao verificar atualizações",
"failedToSavePolicy": "Falha ao salvar a política",
"failedToScale": "Falha ao dimensionar",
"failedToUpdateRegistry": "Falha ao atualizar o registro",
"idleAppsAreSuspendedAfterThe": "Os aplicativos inativos são suspensos após o tempo limite abaixo.",
"idleTimeoutMinutes": "Tempo limite de inatividade (minutos)",
@@ -1015,9 +1006,6 @@
"imageUpdatesApplyToDockerCompose": "As atualizações de imagem se aplicam aos aplicativos Docker Compose. Você ainda pode verificar o resumo deste aplicativo, mas \"Atualizar agora\" está disponível apenas para aplicativos docker-compose.",
"lastChecked": "Última verificação",
"latestDigest": "Resumo mais recente",
"manualReplicas": "Réplicas manuais",
"maxReplicas": "Máximo de réplicas",
"minReplicas": "Réplicas mínimas",
"privateRegistry": "Registro Privado",
"publicImagesPullAnonymouslyPickA": "Imagens públicas são extraídas anonimamente; escolha um registro para imagens privadas.",
"publicNoAuth": "Público (sem autenticação)",
@@ -1025,8 +1013,6 @@
"registry": "Registro",
"registryAttached": "Registro anexado.",
"registryDetachedPullsAreAnonymous": "Registro desanexado – pulls são anônimos.",
"scaledToReplica": "Dimensionado para réplica {{replicas}}{{value}}.",
"serviceName": "Nome do serviço",
"settingsConnections": "Configurações → Conexões",
"stopTheContainerAfterAPeriod": "Pare o contêiner após um período de inatividade para liberar recursos. Ele desperta automaticamente na próxima solicitação.",
"updateImage": "Atualizar imagem",
-14
View File
@@ -986,26 +986,17 @@
},
"containerOpsPanel": {
"actionFailed": "Действие не выполнено",
"adjustReplicaCountBasedOnCpu": "Настройте количество реплик в зависимости от загрузки процессора. Требуется масштабируемая служба Docker Compose (которая может запускать несколько реплик).",
"anImageUpdateIsAvailable": "Доступно обновление изображения.",
"appPutToSleep": "Приложение усыплено.",
"appWoken": "Приложение проснулось.",
"authenticateWithStoredCredentialsBeforePulling": "Прежде чем извлекать образ этого приложения, выполните аутентификацию с помощью сохраненных учетных данных. Добавьте реестры в",
"autoScale": "Автомасштабирование",
"autoScalePolicySaved": "Политика автоматического масштабирования сохранена.",
"autoSleep": "Автоматический сон",
"autoSleepPolicySaved": "Политика автоматического перехода в режим сна сохранена.",
"cooldownSeconds": "Время восстановления (секунды)",
"cpuHigh": "Загрузка процессора (%)",
"cpuLow": "Низкий уровень процессора (%)",
"currentDigest": "Текущий дайджест",
"currentlyRunning": "В настоящее время работает",
"enableAutoScale": "Включить автоматическое масштабирование",
"enableAutoSleep": "Включить автоматический сон",
"failedToApplyUpdate": "Не удалось применить обновление",
"failedToCheckForUpdates": "Не удалось проверить наличие обновлений",
"failedToSavePolicy": "Не удалось сохранить политику.",
"failedToScale": "Не удалось масштабировать",
"failedToUpdateRegistry": "Не удалось обновить реестр",
"idleAppsAreSuspendedAfterThe": "Неактивные приложения приостанавливаются по истечении указанного ниже времени ожидания.",
"idleTimeoutMinutes": "Тайм-аут простоя (минуты)",
@@ -1015,9 +1006,6 @@
"imageUpdatesApplyToDockerCompose": "Обновления изображений применяются к приложениям Docker Compose. Вы по-прежнему можете проверить дайджест этого приложения, но функция «Обновить сейчас» доступна только для приложений, созданных через Docker.",
"lastChecked": "Последняя проверка",
"latestDigest": "Последний дайджест",
"manualReplicas": "Ручные реплики",
"maxReplicas": "Макс реплик",
"minReplicas": "Мин реплик",
"privateRegistry": "Частный реестр",
"publicImagesPullAnonymouslyPickA": "Публичные изображения извлекаются анонимно; выберите реестр для личных изображений.",
"publicNoAuth": "Публичный (без аутентификации)",
@@ -1025,8 +1013,6 @@
"registry": "Реестр",
"registryAttached": "Реестр прилагается.",
"registryDetachedPullsAreAnonymous": "Реестр отключен — запросы анонимны.",
"scaledToReplica": "Масштабируется до {{replicas}} реплики{{value}}.",
"serviceName": "Название службы",
"settingsConnections": "Настройки → Подключения",
"stopTheContainerAfterAPeriod": "Остановите контейнер после периода бездействия, чтобы освободить ресурсы. Он просыпается автоматически при следующем запросе.",
"updateImage": "Обновить изображение",
-14
View File
@@ -986,26 +986,17 @@
},
"containerOpsPanel": {
"actionFailed": "การดำเนินการล้มเหลว",
"adjustReplicaCountBasedOnCpu": "ปรับจำนวนเรพลิกาตามโหลดของ CPU ต้องการบริการ Docker Compose ที่รองรับการปรับขนาดได้ (บริการที่สามารถเรียกใช้หลายเรพลิกาได้)",
"anImageUpdateIsAvailable": "มีการอัปเดตรูปภาพ",
"appPutToSleep": "แอพเข้าสู่โหมดสลีป",
"appWoken": "แอพตื่นแล้ว",
"authenticateWithStoredCredentialsBeforePulling": "ตรวจสอบสิทธิ์ด้วยข้อมูลรับรองที่เก็บไว้ก่อนที่จะดึงรูปภาพของแอปนี้ เพิ่มรีจิสทรีภายใต้",
"autoScale": "ปรับขนาดอัตโนมัติ",
"autoScalePolicySaved": "บันทึกนโยบายการปรับขนาดอัตโนมัติแล้ว",
"autoSleep": "นอนหลับอัตโนมัติ",
"autoSleepPolicySaved": "บันทึกนโยบายการนอนหลับอัตโนมัติแล้ว",
"cooldownSeconds": "คูลดาวน์ (วินาที)",
"cpuHigh": "ซีพียูสูง (%)",
"cpuLow": "ซีพียูต่ำ (%)",
"currentDigest": "สรุปปัจจุบัน",
"currentlyRunning": "กำลังทำงานอยู่",
"enableAutoScale": "เปิดใช้งานการปรับขนาดอัตโนมัติ",
"enableAutoSleep": "เปิดใช้งานการนอนหลับอัตโนมัติ",
"failedToApplyUpdate": "ล้มเหลวในการใช้การอัปเดต",
"failedToCheckForUpdates": "ไม่สามารถตรวจสอบการอัปเดตได้",
"failedToSavePolicy": "ไม่สามารถบันทึกนโยบาย",
"failedToScale": "ไม่สามารถปรับขนาดได้",
"failedToUpdateRegistry": "ไม่สามารถอัปเดตรีจิสทรีได้",
"idleAppsAreSuspendedAfterThe": "แอปที่ไม่ได้ใช้งานจะถูกระงับหลังจากหมดเวลาด้านล่าง",
"idleTimeoutMinutes": "ระยะหมดเวลาที่ไม่ได้ใช้งาน (นาที)",
@@ -1015,9 +1006,6 @@
"imageUpdatesApplyToDockerCompose": "การอัปเดตรูปภาพใช้กับแอป Docker Compose คุณยังคงตรวจสอบสรุปข้อมูลของแอปนี้ได้ แต่ \"อัปเดตทันที\" ใช้ได้กับแอปเขียนนักเทียบท่าเท่านั้น",
"lastChecked": "ตรวจสอบครั้งล่าสุด",
"latestDigest": "สรุปล่าสุด",
"manualReplicas": "การจำลองด้วยตนเอง",
"maxReplicas": "การจำลองสูงสุด",
"minReplicas": "การจำลองขั้นต่ำ",
"privateRegistry": "รีจิสทรีส่วนตัว",
"publicImagesPullAnonymouslyPickA": "ภาพสาธารณะดึงโดยไม่ระบุชื่อ เลือกรีจิสตรีสำหรับรูปภาพส่วนตัว",
"publicNoAuth": "สาธารณะ (ไม่มีการตรวจสอบสิทธิ์)",
@@ -1025,8 +1013,6 @@
"registry": "ทะเบียน",
"registryAttached": "แนบรีจิสทรีแล้ว",
"registryDetachedPullsAreAnonymous": "รีจิสทรีถูกแยกออก — การดึงข้อมูลไม่ระบุชื่อ",
"scaledToReplica": "ปรับขนาดเป็น {{replicas}} แบบจำลอง{{value}}",
"serviceName": "ชื่อบริการ",
"settingsConnections": "การตั้งค่า → การเชื่อมต่อ",
"stopTheContainerAfterAPeriod": "หยุดคอนเทนเนอร์หลังจากไม่มีการใช้งานเป็นระยะเวลาหนึ่งเพื่อเพิ่มทรัพยากร มันจะปลุกโดยอัตโนมัติในคำขอถัดไป",
"updateImage": "อัปเดตรูปภาพ",
-14
View File
@@ -986,26 +986,17 @@
},
"containerOpsPanel": {
"actionFailed": "İşlem başarısız oldu",
"adjustReplicaCountBasedOnCpu": "Çoğaltma sayısını CPU yüküne göre ayarlayın. Ölçeklenebilir bir Docker Compose hizmeti (birden fazla kopya çalıştırabilen) gerektirir.",
"anImageUpdateIsAvailable": "Bir resim güncellemesi mevcut.",
"appPutToSleep": "Uygulama uyku moduna alındı.",
"appWoken": "Uygulama uyandı.",
"authenticateWithStoredCredentialsBeforePulling": "Bu uygulamanın görüntüsünü çekmeden önce saklanan kimlik bilgileriyle kimlik doğrulaması yapın. Altına kayıt ekleyin",
"autoScale": "Otomatik Ölçeklendirme",
"autoScalePolicySaved": "Otomatik ölçeklendirme politikası kaydedildi.",
"autoSleep": "Otomatik Uyku",
"autoSleepPolicySaved": "Otomatik uyku politikası kaydedildi.",
"cooldownSeconds": "Bekleme süresi (saniye)",
"cpuHigh": "CPU yüksek (%)",
"cpuLow": "CPU düşük (%)",
"currentDigest": "Güncel özet",
"currentlyRunning": "Şu anda çalışıyor",
"enableAutoScale": "Otomatik ölçeklendirmeyi etkinleştir",
"enableAutoSleep": "Otomatik uykuyu etkinleştir",
"failedToApplyUpdate": "Güncelleme uygulanamadı",
"failedToCheckForUpdates": "Güncellemeler kontrol edilemedi",
"failedToSavePolicy": "Politika kaydedilemedi",
"failedToScale": "Ölçeklenemedi",
"failedToUpdateRegistry": "Kayıt defteri güncellenemedi",
"idleAppsAreSuspendedAfterThe": "Boşta kalan uygulamalar aşağıdaki zaman aşımı süresinden sonra askıya alınır.",
"idleTimeoutMinutes": "Boşta kalma zaman aşımı (dakika)",
@@ -1015,9 +1006,6 @@
"imageUpdatesApplyToDockerCompose": "Görüntü güncellemeleri Docker Compose uygulamaları için geçerlidir. Bu uygulamanın özetini hâlâ kontrol edebilirsiniz, ancak \"Şimdi güncelle\" yalnızca liman işçisi tarafından oluşturulan uygulamalar için kullanılabilir.",
"lastChecked": "Son kontrol edildi",
"latestDigest": "En son özet",
"manualReplicas": "Manuel kopyalar",
"maxReplicas": "Maksimum kopya",
"minReplicas": "Minimum kopya",
"privateRegistry": "Özel Kayıt",
"publicImagesPullAnonymouslyPickA": "Herkese açık görseller anonim olarak çekilir; özel görüntüler için bir kayıt defteri seçin.",
"publicNoAuth": "Herkese açık (kimlik yok)",
@@ -1025,8 +1013,6 @@
"registry": "Kayıt defteri",
"registryAttached": "Kayıt ektedir.",
"registryDetachedPullsAreAnonymous": "Kayıt defteri ayrıldı — çekmeler anonimdir.",
"scaledToReplica": "{{replicas}} replika{{value}}'ye ölçeklendirilmiştir.",
"serviceName": "Hizmet adı",
"settingsConnections": "Ayarlar → Bağlantılar",
"stopTheContainerAfterAPeriod": "Kaynakları serbest bırakmak için belirli bir süre işlem yapılmadığında kapsayıcıyı durdurun. Bir sonraki istekte otomatik olarak uyanır.",
"updateImage": "Resmi güncelle",
-14
View File
@@ -986,26 +986,17 @@
},
"containerOpsPanel": {
"actionFailed": "Hành động không thành công",
"adjustReplicaCountBasedOnCpu": "Điều chỉnh số lượng bản sao dựa trên tải CPU. Yêu cầu dịch vụ Docker Compose có khả năng mở rộng quy mô (một dịch vụ có thể chạy nhiều bản sao).",
"anImageUpdateIsAvailable": "Đã có bản cập nhật hình ảnh.",
"appPutToSleep": "Ứng dụng được đưa vào chế độ ngủ.",
"appWoken": "Ứng dụng đã thức dậy.",
"authenticateWithStoredCredentialsBeforePulling": "Xác thực bằng thông tin đăng nhập được lưu trữ trước khi lấy hình ảnh của ứng dụng này. Thêm đăng ký dưới",
"autoScale": "Tự động chia tỷ lệ",
"autoScalePolicySaved": "Đã lưu chính sách tự động chia tỷ lệ.",
"autoSleep": "Tự động ngủ",
"autoSleepPolicySaved": "Đã lưu chính sách tự động ngủ.",
"cooldownSeconds": "Thời gian hồi chiêu (giây)",
"cpuHigh": "CPU cao (%)",
"cpuLow": "CPU thấp (%)",
"currentDigest": "thông báo hiện tại",
"currentlyRunning": "Hiện đang chạy",
"enableAutoScale": "Bật tự động chia tỷ lệ",
"enableAutoSleep": "Bật chế độ tự động ngủ",
"failedToApplyUpdate": "Không thể áp dụng bản cập nhật",
"failedToCheckForUpdates": "Không thể kiểm tra cập nhật",
"failedToSavePolicy": "Không lưu được chính sách",
"failedToScale": "Không thể mở rộng quy mô",
"failedToUpdateRegistry": "Không thể cập nhật sổ đăng ký",
"idleAppsAreSuspendedAfterThe": "Các ứng dụng không hoạt động sẽ bị tạm dừng sau thời gian chờ bên dưới.",
"idleTimeoutMinutes": "Thời gian chờ không hoạt động (phút)",
@@ -1015,9 +1006,6 @@
"imageUpdatesApplyToDockerCompose": "Cập nhật hình ảnh áp dụng cho ứng dụng Docker Compose. Bạn vẫn có thể kiểm tra thông báo của ứng dụng này, nhưng \"Cập nhật ngay\" chỉ khả dụng cho các ứng dụng docker-compose.",
"lastChecked": "Đã kiểm tra lần cuối",
"latestDigest": "Thông báo mới nhất",
"manualReplicas": "Bản sao thủ công",
"maxReplicas": "Bản sao tối đa",
"minReplicas": "Bản sao tối thiểu",
"privateRegistry": "Sổ đăng ký riêng",
"publicImagesPullAnonymouslyPickA": "Hình ảnh công khai kéo ẩn danh; chọn một sổ đăng ký cho hình ảnh riêng tư.",
"publicNoAuth": "Công khai (không có xác thực)",
@@ -1025,8 +1013,6 @@
"registry": "Đăng ký",
"registryAttached": "Sổ đăng ký đính kèm.",
"registryDetachedPullsAreAnonymous": "Đã tách sổ đăng ký - các thao tác kéo đều ẩn danh.",
"scaledToReplica": "Được chia tỷ lệ thành bản sao {{replicas}}{{value}}.",
"serviceName": "Tên dịch vụ",
"settingsConnections": "Cài đặt → Kết nối",
"stopTheContainerAfterAPeriod": "Dừng container sau một thời gian không hoạt động để giải phóng tài nguyên. Nó tự động thức dậy theo yêu cầu tiếp theo.",
"updateImage": "Cập nhật hình ảnh",
-14
View File
@@ -986,26 +986,17 @@
},
"containerOpsPanel": {
"actionFailed": "操作失败",
"adjustReplicaCountBasedOnCpu": "根据 CPU 负载调整副本数量。需要可扩展的 Docker Compose 服务(可以运行多个副本的服务)。",
"anImageUpdateIsAvailable": "图像更新可用。",
"appPutToSleep": "应用程序进入睡眠状态。",
"appWoken": "应用程序已唤醒。",
"authenticateWithStoredCredentialsBeforePulling": "在拉取此应用程序的映像之前,请使用存储的凭据进行身份验证。在下面添加注册表",
"autoScale": "自动缩放",
"autoScalePolicySaved": "自动缩放策略已保存。",
"autoSleep": "自动睡眠",
"autoSleepPolicySaved": "自动睡眠策略已保存。",
"cooldownSeconds": "冷却时间(秒)",
"cpuHigh": "CPU 高 (%)",
"cpuLow": "CPU 低 (%)",
"currentDigest": "当前摘要",
"currentlyRunning": "目前正在运行",
"enableAutoScale": "启用自动缩放",
"enableAutoSleep": "启用自动睡眠",
"failedToApplyUpdate": "应用更新失败",
"failedToCheckForUpdates": "无法检查更新",
"failedToSavePolicy": "保存策略失败",
"failedToScale": "无法扩展",
"failedToUpdateRegistry": "更新注册表失败",
"idleAppsAreSuspendedAfterThe": "空闲应用程序将在以下超时后暂停。",
"idleTimeoutMinutes": "空闲超时(分钟)",
@@ -1015,9 +1006,6 @@
"imageUpdatesApplyToDockerCompose": "镜像更新适用于 Docker Compose 应用程序。您仍然可以检查此应用程序的摘要,但“立即更新”仅适用于 docker-compose 应用程序。",
"lastChecked": "最后检查",
"latestDigest": "最新摘要",
"manualReplicas": "手动副本",
"maxReplicas": "最大副本数",
"minReplicas": "最小副本数",
"privateRegistry": "私人登记处",
"publicImagesPullAnonymouslyPickA": "公共图像以匿名方式拉取;选择私有镜像的注册表。",
"publicNoAuth": "公开(无授权)",
@@ -1025,8 +1013,6 @@
"registry": "注册表",
"registryAttached": "附注册表。",
"registryDetachedPullsAreAnonymous": "注册表分离——拉取是匿名的。",
"scaledToReplica": "缩放至 {{replicas}} 副本{{value}}。",
"serviceName": "服务名称",
"settingsConnections": "设置 → 连接",
"stopTheContainerAfterAPeriod": "一段时间不活动后停止容器以释放资源。它会在下一个请求时自动唤醒。",
"updateImage": "更新图片",
-14
View File
@@ -1174,26 +1174,17 @@
},
"containerOpsPanel": {
"actionFailed": "操作失敗",
"adjustReplicaCountBasedOnCpu": "根據 CPU 負載調整副本數量。需要具有擴充能力的 Docker Compose 服務(可以執行多個副本的服務)。",
"anImageUpdateIsAvailable": "有可用的映像檔更新。",
"appPutToSleep": "應用程式進入睡眠狀態。",
"appWoken": "應用程式已喚醒。",
"authenticateWithStoredCredentialsBeforePulling": "在下載此應用程式的映像檔之前,請使用儲存的憑證進行驗證。在下面新增映像檔儲存庫",
"autoScale": "自動擴縮",
"autoScalePolicySaved": "自動縮放策略已儲存。",
"autoSleep": "自動休眠",
"autoSleepPolicySaved": "自動睡眠策略已儲存。",
"cooldownSeconds": "冷卻時間(秒)",
"cpuHigh": "CPU 高 (%)",
"cpuLow": "CPU 低 (%)",
"currentDigest": "目前摘要",
"currentlyRunning": "目前正在執行",
"enableAutoScale": "啟用自動縮放",
"enableAutoSleep": "啟用自動睡眠",
"failedToApplyUpdate": "套用更新失敗",
"failedToCheckForUpdates": "無法檢查更新",
"failedToSavePolicy": "儲存策略失敗",
"failedToScale": "無法擴充",
"failedToUpdateRegistry": "無法更新映像檔儲存庫",
"idleAppsAreSuspendedAfterThe": "閒置應用程式將在以下逾時後暫停。",
"idleTimeoutMinutes": "閒置逾時(分鐘)",
@@ -1203,9 +1194,6 @@
"imageUpdatesApplyToDockerCompose": "映像檔更新適用於 Docker Compose 應用程式。您仍可檢查此應用程式的映像檔摘要,但只有 Docker Compose 應用程式能使用 [立即更新]。",
"lastChecked": "最後檢查",
"latestDigest": "最新摘要",
"manualReplicas": "手動副本",
"maxReplicas": "最大副本數",
"minReplicas": "最小副本數",
"privateRegistry": "私人映像檔儲存庫",
"publicImagesPullAnonymouslyPickA": "公開映像檔可匿名下載;私有映像檔請選擇映像檔儲存庫。",
"publicNoAuth": "公開(無須驗證)",
@@ -1213,8 +1201,6 @@
"registry": "映像檔儲存庫",
"registryAttached": "已連結映像檔儲存庫。",
"registryDetachedPullsAreAnonymous": "已取消連結映像檔儲存庫,後續將以匿名方式下載映像檔。",
"scaledToReplica": "縮放至 {{replicas}} 副本 {{value}}。",
"serviceName": "服務名稱",
"settingsConnections": "設定 → 連線",
"stopTheContainerAfterAPeriod": "一段時間不活動後停止容器以釋放資源。它會在下一個請求時自動喚醒。",
"updateImage": "更新映像檔",
@@ -1,40 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
evaluateScale,
getScalePolicy,
scaleApp,
updateScalePolicy,
} from '../containerOps.js';
function recorder(response = { ok: true }) {
const calls = [];
return {
calls,
context: {
request(path, options) {
calls.push({ path, options });
return Promise.resolve(response);
},
},
};
}
test('scale-policy frontend methods preserve the backend route contract', async () => {
const { calls, context } = recorder({ replicas: 3 });
const policy = { enabled: true, service_name: 'web', min_replicas: 2 };
await getScalePolicy.call(context, 42);
await updateScalePolicy.call(context, 42, policy);
const result = await scaleApp.call(context, 42, 3);
await evaluateScale.call(context, 42);
assert.deepEqual(calls, [
{ path: '/apps/42/scale-policy', options: undefined },
{ path: '/apps/42/scale-policy', options: { method: 'PUT', body: policy } },
{ path: '/apps/42/scale', options: { method: 'POST', body: { replicas: 3 } } },
{ path: '/apps/42/scale/evaluate', options: { method: 'POST' } },
]);
assert.deepEqual(result, { replicas: 3 });
});
+1 -18
View File
@@ -1,4 +1,4 @@
// Per-app container operations (image-update, auto-sleep, auto-scale) + GPU.
// Per-app container operations (image-update, auto-sleep) + GPU.
// --- Image updates ---
export async function checkImageUpdate(appId) {
@@ -30,23 +30,6 @@ export async function wakeApp(appId) {
return this.request(`/apps/${appId}/wake`, { method: 'POST' });
}
// --- Auto-scale ---
export async function getScalePolicy(appId) {
return this.request(`/apps/${appId}/scale-policy`);
}
export async function updateScalePolicy(appId, data) {
return this.request(`/apps/${appId}/scale-policy`, { method: 'PUT', body: data });
}
export async function scaleApp(appId, replicas) {
return this.request(`/apps/${appId}/scale`, { method: 'POST', body: { replicas } });
}
export async function evaluateScale(appId) {
return this.request(`/apps/${appId}/scale/evaluate`, { method: 'POST' });
}
// --- GPU ---
export async function getGpuInfo() {
return this.request('/gpu/');