1: <?php
2: declare(strict_types=1);
3: /**
4: * +------------------------------------------------------------+
5: * | apnscp |
6: * +------------------------------------------------------------+
7: * | Copyright (c) Apis Networks |
8: * +------------------------------------------------------------+
9: * | Licensed under Artistic License 2.0 |
10: * +------------------------------------------------------------+
11: * | Author: Matt Saladna (msaladna@apisnetworks.com) |
12: * +------------------------------------------------------------+
13: */
14:
15: use Daphnie\Collector;
16: use Daphnie\Contracts\MetricProvider;
17: use Daphnie\Metric;
18: use Module\Skeleton\Contracts\Hookable;
19: use Module\Skeleton\Contracts\Tasking;
20: use Opcenter\Provisioning\Cgroup as CgroupProvisioning;
21: use Opcenter\SiteConfiguration;
22: use Opcenter\System\Cgroup;
23:
24: /**
25: * Control group interfacing
26: *
27: * @package core
28: */
29: class Cgroup_Module extends Module_Skeleton implements Hookable, Tasking
30: {
31: const CGROUP_LOCATION = Cgroup::CGROUP_HOME;
32: const DEPENDENCY_MAP = [
33: 'siteinfo'
34: ];
35: const DEFAULT_MEMORY = 512;
36: const DEFAULT_CPU = 10240;
37: /** in MB */
38: const MAX_PROCS = 25;
39:
40: const METRIC_ATTR_CPU_USAGE = [
41: 'c-cpuacct-usage',
42: 'c-cpuacct-system',
43: 'c-cpuacct-user'
44: ];
45:
46: protected $exportedFunctions = [
47: '*' => PRIVILEGE_SITE | PRIVILEGE_USER | PRIVILEGE_ADMIN,
48: 'reset_peak_memory' => PRIVILEGE_SITE | PRIVILEGE_ADMIN,
49: 'frozen' => PRIVILEGE_ADMIN,
50: 'thaw' => PRIVILEGE_ADMIN,
51: 'freeze' => PRIVILEGE_ADMIN
52: ];
53:
54: /**
55: * Get controller usage
56: *
57: * @param string $controller
58: * @return array|false usage or controller exposes no data
59: */
60: public function get_usage(string $controller)
61: {
62: if (!IS_CLI && posix_getuid()) {
63: return $this->query('cgroup_get_usage', $controller);
64: }
65: if (!in_array($controller, $this->get_controllers(), true)) {
66: return error("unknown controller `%s'", $controller);
67: }
68:
69: $method = '_get_' . $controller . '_usage';
70: if (!method_exists($this, $method)) {
71: // don't know how to handle cgroup collection
72: return [];
73: }
74:
75: return $this->$method();
76: }
77:
78: /**
79: * Get available system cgroup controllers
80: *
81: * @return string[]
82: */
83: public function get_controllers(): array
84: {
85: return CGROUP_CONTROLLERS;
86: }
87:
88: /**
89: * Get cgroup name
90: *
91: * @return string|null
92: */
93: public function get_cgroup(): ?string
94: {
95: if ($this->permission_level & (PRIVILEGE_SITE | PRIVILEGE_USER)) {
96: return (string)(new Cgroup\Group($this->site));
97: }
98:
99: return null;
100: }
101:
102: /**
103: * Get configured limits
104: *
105: * @return array
106: */
107: public function get_limits(): array
108: {
109: $limits = $this->getServiceValue('cgroup');
110: if (!$limits['enabled']) {
111: return [];
112: }
113:
114: return array_except($limits, ['version', 'enabled']);
115: }
116:
117: /**
118: * Reset max memory usage
119: */
120: public function reset_peak_memory(): void
121: {
122: if (!IS_CLI) {
123: $this->query('cgroup_reset_peak_memory');
124: return;
125: }
126:
127: $group = new \Opcenter\System\Cgroup\Group(
128: $this->site,
129: );
130:
131: $controller = Cgroup\Controllers\Memory::make($group, null);
132: $controller->reset();
133: }
134:
135: /**
136: * cgroups enabled for site
137: *
138: * @return bool
139: */
140: public function enabled(): bool
141: {
142: return (bool)$this->getServiceValue('cgroup', 'enabled');
143: }
144:
145: public function _verify_conf(\Opcenter\Service\ConfigurationContext $ctx): bool
146: {
147: return true;
148: }
149:
150: public function _create()
151: { }
152:
153: public function _delete()
154: { }
155:
156: public function _edit()
157: { }
158:
159: public function _create_user(string $user)
160: {
161: return true;
162: }
163:
164: public function _delete_user(string $user)
165: {
166: return true;
167: }
168:
169: public function _edit_user(string $userold, string $usernew, array $oldpwd)
170: {
171: return true;
172: }
173:
174: public function _housekeeping()
175: {
176: if (!($test = $this->get_controllers()[0] ?? null)) {
177: return;
178: }
179: if (!Cgroup::mounted($test)) {
180: return error("%s not mounted", FILESYSTEM_SHARED . "/cgroup");
181: }
182: $webuser = $this->web_get_sys_user();
183: foreach (\Opcenter\Account\Enumerate::sites() as $site) {
184: if (!Auth::get_admin_from_site_id((int)substr($site, 4))) {
185: continue;
186: }
187: $group = new \Opcenter\System\Cgroup\Group(
188: $site,
189: [
190: 'task' => [
191: 'uid' => 'root',
192: 'gid' => \Auth::get_group_from_site($site),
193: 'fperm' => 0660
194: ]
195: ]
196: );
197: $ctx = null;
198: foreach (Cgroup::getControllers() as $c) {
199: $controller = \Opcenter\System\Cgroup\Controller::make($group, $c, []);
200: if (Cgroup::exists($controller)) {
201: $controller->reset();
202: continue;
203: }
204:
205: if (null === $ctx && null === ($ctx = \Auth::nullableContext(null, $site))) {
206: continue 2;
207: }
208:
209: $controller->import($ctx);
210: if (!$controller->getAttributes()) {
211: continue;
212: }
213: $controller->create();
214: $group->add($controller);
215: }
216:
217: if ($group->getControllers()) {
218: report("Missed controller %(controller)s on %(group)s",
219: ['controller' => implode(', ', array_map(static fn($c) => $c->getName(), $group->getControllers())), 'group' => $group]);
220: // missed a controller, do a full import
221: CgroupProvisioning::createControllerConfiguration(SiteConfiguration::shallow($ctx));
222: }
223: }
224:
225: return true;
226: }
227:
228: /**
229: * Get controller memory usage
230: *
231: * @return array
232: */
233: private function _get_memory_usage(): array
234: {
235: $stats['limit'] = self::DEFAULT_MEMORY;
236: $stats = Cgroup::memory_usage($this->get_cgroup());
237: $sysMemory = \Opcenter\System\Memory::stats();
238: $maxMemory = $sysMemory['memtotal'] * 1024;
239: if ($this->permission_level & PRIVILEGE_ADMIN || $stats['limit'] === null) {
240: $stats['limit'] = $maxMemory;
241: $stats['free'] = $sysMemory['memavailable']*1024;
242: } else {
243: $stats['limit'] = min($stats['limit'], $maxMemory);
244: $stats['free'] = $stats['limit'] - $stats['used'];
245: }
246:
247: return $stats;
248: }
249:
250: /**
251: * Populate cgroup defaults on controller error
252: *
253: * @param array $usage
254: * @param array $defaults
255: * @return array
256: */
257: private function _fillUsage(array $usage, array $defaults): array
258: {
259: foreach ($defaults as $k => $v) {
260: if (!isset($usage[$k])) {
261: $usage[$k] = $v;
262: }
263: }
264:
265: return $usage;
266: }
267:
268: private function _get_cpuacct_usage(): array
269: {
270: return [];
271: }
272:
273: private function _get_pids_usage(): array
274: {
275: // @todo replace CPU maxproc with pids subsystem
276: $maxprocs = self::MAX_PROCS;
277: if ($this->permission_level & PRIVILEGE_ADMIN) {
278: $maxprocs = 999;
279: }
280:
281: return $this->_fillUsage(
282: Cgroup::pid_usage($this->get_cgroup()),
283: [
284: 'max' => $this->getServiceValue('cgroup', 'proclimit', $maxprocs)
285: ]
286: );
287: }
288:
289: private function _get_cpu_usage(): array
290: {
291: $maxcpu = self::DEFAULT_CPU;
292: $maxprocs = self::MAX_PROCS;
293: if ($this->permission_level & PRIVILEGE_ADMIN) {
294: $maxcpu = NPROC * 86400;
295: $maxprocs = 999;
296: }
297:
298: $usage = Cgroup::cpu_usage($this->get_cgroup());
299: if (($this->permission_level & PRIVILEGE_SITE) && TELEMETRY_ENABLED) {
300: $sum = $this->telemetry_range(self::METRIC_ATTR_CPU_USAGE, time()-86400, null, $this->site_id, true);
301: /**
302: * > .usage is measuring the wall clock nanoseconds whereas .stat is measuring the cpu cycles consumed.
303: * http://mail-archives.apache.org/mod_mbox/mesos-dev/201302.mbox/%3C20130214015558.21380.50889@reviews.apache.org%3E
304: */
305: // convert centiseconds to seconds
306: $cumusage = ($sum['c-cpuacct-usage'] ?? 0)/100;
307: $usage['cumusage'] = $cumusage ?: $usage['used'];
308: $usage['used'] = $cumusage;
309: $usage['cumuser'] = $usage['user'];
310: $usage['cumsystem'] = $usage['system'];
311: $usage['system'] = ($sum['c-cpuacct-system'] ?? 0)/100 ;
312: $usage['user'] = ($sum['c-cpuacct-user'] ?? 0)/ 100;
313: } else {
314: // note: poor approximation for uniform usage
315: debug("Telemetry disabled. Approximating CPU usage for %s", $this->site);
316: $ctime = filectime(Cgroup\Controllers\Cpuacct::make(
317: new Cgroup\Group($this->site), null
318: )->getPath());
319: $usage['used'] = $usage['used']/(microtime(true) - $ctime) * 86400;
320: }
321: $cpuLimit = $this->getServiceValue('cgroup', 'cpu', $maxcpu);
322: return $this->_fillUsage(
323: $usage,
324: [
325: 'limit' => $cpuLimit,
326: 'maxprocs' => $this->getServiceValue('cgroup', 'proclimit', $maxprocs),
327: 'cumusage' => $usage['used'],
328: 'free' => $cpuLimit - $usage['used']
329: ]
330: );
331: }
332:
333: private function _get_io_usage(): array
334: {
335: return $this->_get_blkio_usage();
336: }
337:
338: private function _get_blkio_usage(): array
339: {
340: return $this->_fillUsage(
341: Cgroup::io_usage($this->get_cgroup()),
342: [
343: 'iops-read' => $this->getServiceValue('cgroup', 'readiops', 100),
344: 'iops-write' => $this->getServiceValue('cgroup', 'writeiops', 100),
345: 'bw-read' => $this->getServiceValue('cgroup', 'readbw', 100),
346: 'bw-write' => $this->getServiceValue('cgroup', 'writebw', 100)
347: ]
348: );
349: }
350:
351: /**
352: * Convert site from thawed to frozen state
353: *
354: * @param string $spec site, site id, domain, invoice or any matchable identifier
355: * @return bool
356: */
357: public function freeze(string $spec): bool
358: {
359: if (!IS_CLI) {
360: return $this->query('cgroup_freeze', $spec);
361: }
362:
363: if (Cgroup::version() === 1 && !in_array('freezer', Cgroup::getControllers(), true)) {
364: return error("%s cgroup must be enabled", 'freezer');
365: }
366:
367: $sites = (array)Auth::get_site_id_from_anything($spec);
368: if (!$sites) {
369: return error("Unknown site spec `%s'", $spec);
370: }
371:
372: $frozen = true;
373: foreach ($sites as $siteid) {
374: $controller = Opcenter\System\Cgroup\Controllers\Freezer::make(
375: new \Opcenter\System\Cgroup\Group("site{$siteid}"),
376: null
377: );
378:
379: if (!$controller->exists()) {
380: warn("Controller `%(controller)s' missing for `%(site)s'", ['controller' => 'freezer', 'site' => "site{$siteid}"]);
381: $frozen &= 0;
382: }
383:
384: $frozen &= $controller->createAttribute('state', Cgroup\Attributes\Freezer\State::STATE_FROZEN)->activate();
385: }
386:
387: return (bool)$frozen;
388: }
389:
390: /**
391: * Convert site from frozen to thawed state
392: *
393: * @param string $spec site, site id, domain, invoice or any matchable identifier
394: * @return bool
395: */
396: public function thaw(string $spec): bool
397: {
398: if (!IS_CLI) {
399: return $this->query('cgroup_thaw', $spec);
400: }
401:
402: if (Cgroup::version() === 1 && !in_array('freezer', Cgroup::getControllers(), true)) {
403: return error("%s cgroup must be enabled", 'freezer');
404: }
405:
406: $sites = (array)Auth::get_site_id_from_anything($spec);
407: if (!$sites) {
408: return error("Unknown site spec `%s'", $spec);
409: }
410:
411: $thawed = true;
412: foreach ($sites as $siteid) {
413: $controller = Opcenter\System\Cgroup\Controllers\Freezer::make(
414: new \Opcenter\System\Cgroup\Group("site{$siteid}"),
415: null
416: );
417:
418: if (!$controller->exists()) {
419: continue;
420: }
421:
422: $thawed &= $controller->createAttribute('state',
423: Cgroup\Attributes\Freezer\State::STATE_THAWED)->activate();
424: }
425:
426: return (bool)$thawed;
427: }
428:
429: /**
430: * Site is in cgroup frozen state
431: *
432: * @param string $spec site, site id, domain, invoice or any matchable identifier
433: * @return bool
434: */
435: public function frozen(string $spec): bool
436: {
437: if (!IS_CLI) {
438: return $this->query('cgroup_frozen', $spec);
439: }
440:
441: if (Cgroup::version() === 1 && !in_array('freezer', Cgroup::getControllers(), true)) {
442: return error("%s cgroup must be enabled", 'freezer');
443: }
444:
445: $sites = (array)Auth::get_site_id_from_anything($spec);
446: if (!$sites) {
447: return error("Unknown site spec `%s'", $spec);
448: }
449: if (count($sites) > 1) {
450: warn("Multiple sites queried with site spec `%s'", $spec);
451: }
452:
453:
454: $frozen = true;
455: foreach ($sites as $siteid) {
456: $controller = Opcenter\System\Cgroup\Controllers\Freezer::make(
457: new \Opcenter\System\Cgroup\Group("site{$siteid}"),
458: null
459: );
460:
461: if (!$controller->exists()) {
462: $frozen = false;
463: continue;
464: }
465:
466: $frozen &= $controller->createAttribute('state', null)->frozen();
467: }
468:
469: return (bool)$frozen;
470: }
471:
472: public function version(): int
473: {
474: return (int)Cgroup::version();
475: }
476:
477: public function _cron(Cronus $cron) {
478:
479: if (CGROUP_RESET_PEAK > 0) {
480: $cron->schedule(CGROUP_RESET_PEAK, 'reset.max-memory', static function () {
481: $db = PostgreSQL::pdo();
482: $sites = (new \Opcenter\Database\PostgreSQL\Opcenter($db))->readSitesFromSiteinfo();
483: foreach (array_keys($sites) as $s) {
484: $s = "site{$s}";
485: $group = new Cgroup\Group($s);
486: $controller = Cgroup\Controller::make($group, 'memory');
487: if ($controller->exists()) {
488: $controller->reset();
489: }
490: }
491: });
492: }
493:
494: if (!TELEMETRY_ENABLED) {
495: return;
496: }
497:
498: $db = PostgreSQL::pdo();
499: $collector = new Collector($db);
500: // read from siteinfo table to guard protect against failed foreign key checks
501: $sites = (new \Opcenter\Database\PostgreSQL\Opcenter($db))->readSitesFromSiteinfo();
502: $sites[] = null; // system controller
503: $controllers = $this->get_controllers();
504: foreach (array_keys($sites) as $s) {
505: $s = "site{$s}";
506: $siteId = (int)substr($s, 4) ?: null;
507: $ts = time();
508: /**
509: * Approx 32k controllers/sec on testing VM (~5500 backend req/sec)
510: * This method should be fine with minimal performance degradation,
511: * may wish to switch to less OO approach in the future if bottlenecks appear
512: *
513: * Takes ~5ms to log all metrics for a site
514: */
515: $group = new Cgroup\Group($s);
516: $counters = [];
517: foreach ($controllers as $c) {
518: $controller = Cgroup\Controller::make($group, $c);
519: $attrs = (new Cgroup\MetricsLogging($controller))->getLoggableAttributes();
520: $counters[$c] = $controller->readMetrics(array_keys($attrs));
521: $reset = false;
522: foreach ($counters[$c] as $k => $v) {
523: if (!$reset && ((int)$v > Metric::FIELD_MAX_VALUE &&
524: $controller->getMetricDataType($k) === MetricProvider::TYPE_MONOTONIC)) {
525: // reconsitute metric type from "anonymous" metric via db. Registration method
526: // would hold onto a controller instance assigned to a specific site indefinitely
527: $reset = true;
528: }
529: $collector->add($attrs[$k], $siteId, (int)$v, $ts);
530: }
531:
532: if ($reset) {
533: $controller->reset();
534: }
535: }
536: }
537: $collector = null;
538:
539: return true;
540: }
541: }