feature/cierres (#25)

Varios cambios

Co-authored-by: Juan Pablo Vial <jpvialb@incoviba.cl>
Reviewed-on: #25
This commit is contained in:
2025-07-22 13:18:00 +00:00
parent ba57cad514
commit 307f2ac7d7
418 changed files with 20045 additions and 984 deletions

View File

@ -1,2 +1,15 @@
#ENVIRONMENT=
TZ=America/Santiago
#ENVIRONMENT=
APP_NAME=incoviba_cli
API_URL=http://proxy/api
#API_USERNAME=
#API_PASSWORD=
#REDIS_HOST=redis
#REDIS_PORT=6379
#SOCKET_HOST=web
#SOCKET_PORT=9000
#SOCKET_ROOT=/code/public/index.php

View File

@ -2,4 +2,4 @@
. /etc/profile
/usr/local/bin/php /code/bin/index.php "$@"
/usr/local/bin/php /code/bin/index.php "$@"

View File

@ -11,3 +11,12 @@ try {
} catch (Exception $exception) {
$app->getContainer()->get(Psr\Log\LoggerInterface::class)->notice($exception);
}
register_shutdown_function(function() {
$error = error_get_last();
$fatal_errors = [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR];
if ($error !== null and in_array($error['type'], $fatal_errors, true)) {
error_log(json_encode($error).PHP_EOL,3, '/logs/fatal.log');
}
error_clear_last();
});

View File

@ -10,6 +10,7 @@ class Application extends Console\Application
{
public function __construct(protected ContainerInterface $container, string $name = 'UNKNOWN', string $version = 'UNKNOWN')
{
$name = 'incoviba_cli';
if ($this->container->has('APP_NAME')) {
$name = $this->container->get('APP_NAME');
}

View File

@ -7,7 +7,7 @@ use Symfony\Component\Console;
class Command extends Console\Command\Command
{
public function __construct(protected ClientInterface $client, protected LoggerInterface $logger, string $name = null)
public function __construct(protected ClientInterface $client, protected LoggerInterface $logger, ?string $name = null)
{
parent::__construct($name);
}

View File

@ -2,9 +2,14 @@
"name": "incoviba/cli",
"type": "project",
"require": {
"ext-sockets": "*",
"dragonmantank/cron-expression": "^3.4",
"guzzlehttp/guzzle": "^7.8",
"hollodotme/fast-cgi-client": "^3.1",
"monolog/monolog": "^3.5",
"pda/pheanstalk": "^7.0",
"php-di/php-di": "^7.0",
"predis/predis": "^3.0",
"symfony/console": "^6.3"
},
"require-dev": {

View File

@ -2,8 +2,10 @@
0 2 * * * /code/bin/incoviba ventas:cuotas:pendientes >> /logs/commands 2>&1
0 2 * * * /code/bin/incoviba ventas:cuotas:vencer >> /logs/commands 2>&1
0 2 * * * /code/bin/incoviba ventas:cierres:vigentes >> /logs/commands 2>&1
* */3 * * * /code/bin/incoviba proyectos:activos >> /logs/commands 2>&1
0 */3 * * * /code/bin/incoviba proyectos:activos >> /logs/commands 2>&1
0 2 * * * /code/bin/incoviba comunas >> /logs/commands 2>&1
0 2 * * * /code/bin/incoviba money:uf >> /logs/commands 2>&1
0 2 * * * /code/bin/incoviba money:uf:update >> /logs/commands 2>&1
0 2 1 * * /code/bin/incoviba money:ipc >> /logs/commands 2>&1
*/1 * * * * /code/bin/incoviba queue >> /logs/commands 2>&1
0 3 * * * /code/bin/incoviba external:services >> /logs/commands 2>&1

19
cli/entrypoint Executable file
View File

@ -0,0 +1,19 @@
#!/bin/bash
if [[ $# -gt 0 ]]
then
if [[ "$1" = "bash" || "$1" = "sh" || "$1" = "zsh" || "$1" = "/bin/bash" ]]
then
CMD=$1
shift
if [[ $# -gt 0 ]]
then
$CMD -c "$@"
exit 0
fi
$CMD
exit 0
fi
fi
/code/bin/incoviba "$@"

View File

@ -1,2 +0,0 @@
<?php
$app->add($app->getContainer()->get(Incoviba\Command\Full::class));

View File

@ -1,2 +0,0 @@
<?php
$app->add($app->getContainer()->get(Incoviba\Command\Comunas::class));

View File

@ -1,4 +0,0 @@
<?php
$app->add($app->getContainer()->get(Incoviba\Command\Money\UF::class));
$app->add($app->getContainer()->get(Incoviba\Command\Money\IPC::class));
$app->add($app->getContainer()->get(Incoviba\Command\Money\UF\Update::class));

View File

@ -1,9 +0,0 @@
<?php
$folder = implode(DIRECTORY_SEPARATOR, [__DIR__, 'proyectos']);
$files = new FilesystemIterator($folder);
foreach ($files as $file) {
if ($file->isDir()) {
continue;
}
include_once $file->getRealPath();
}

View File

@ -1,2 +0,0 @@
<?php
$app->add($app->getContainer()->get(Incoviba\Command\Proyectos\Activos::class));

View File

@ -1,9 +0,0 @@
<?php
$folder = implode(DIRECTORY_SEPARATOR, [__DIR__, 'ventas']);
$files = new FilesystemIterator($folder);
foreach ($files as $file) {
if ($file->isDir()) {
continue;
}
include_once $file->getRealPath();
}

View File

@ -1,2 +0,0 @@
<?php
$app->add($app->getContainer()->get(Incoviba\Command\Ventas\Cierres\Vigentes::class));

View File

@ -1,4 +0,0 @@
<?php
$app->add($app->getContainer()->get(Incoviba\Command\Ventas\Cuotas\Hoy::class));
$app->add($app->getContainer()->get(Incoviba\Command\Ventas\Cuotas\Pendientes::class));
$app->add($app->getContainer()->get(Incoviba\Command\Ventas\Cuotas\PorVencer::class));

View File

@ -1,13 +1,3 @@
<?php
/*function loadCommands(&$app): void {
$files = new FilesystemIterator($app->getContainer()->get('folders')->commands);
foreach ($files as $file) {
if ($file->isDir()) {
continue;
}
include_once $file->getRealPath();
}
}
loadCommands($app);*/
$app->setCommandLoader($app->getContainer()->get(Symfony\Component\Console\CommandLoader\CommandLoaderInterface::class));
$app->setDefaultCommand('run:full');
$app->setDefaultCommand('loop');

View File

@ -1,18 +1,26 @@
<?php
use Psr\Container\ContainerInterface;
return [
'commands' => function() {
return [
'comunas' => Incoviba\Command\Comunas::class,
'contabilidad:cartolas:update' => Incoviba\Command\Contabilidad\Cartolas\Update::class,
'money:ipc' => Incoviba\Command\Money\IPC::class,
'money:uf' => Incoviba\Command\Money\UF::class,
'money:uf:update' => Incoviba\Command\Money\UF\Update::class,
'proyectos:activos' => Incoviba\Command\Proyectos\Activos::class,
'run:full' => Incoviba\Command\Full::class,
'ventas:cierres:vigentes' => Incoviba\Command\Ventas\Cierres\Vigentes::class,
'ventas:cuotas:hoy' => Incoviba\Command\Ventas\Cuotas\Hoy::class,
'ventas:cuotas:pendientes' => Incoviba\Command\Ventas\Cuotas\Pendientes::class,
'ventas:cuotas:vencer' => Incoviba\Command\Ventas\Cuotas\PorVencer::class,
];
'commands' => function(ContainerInterface $container) {
$service = $container->get(Incoviba\Service\Commands::class);
if ($container->has('folders')) {
$folders = $container->get('folders');
if (is_array($folders)) {
if (array_key_exists('commands', $folders)) {
$service->baseCommandsPath = $folders['commands'];
}
} elseif (isset($folders->commands)) {
$service->baseCommandsPath = $folders->commands;
}
}
if ($container->has('skip_commands')) {
$service->skipCommands = $container->get('skip_commands');
}
if ($container->has('skipCommands')) {
$service->skipCommands = $container->get('skipCommands');
}
return $service->getCommandsList();
}
];

View File

@ -6,10 +6,6 @@ return [
$arr['base'],
'resources'
]);
$arr['commands'] = implode(DIRECTORY_SEPARATOR, [
$arr['resources'],
'commands'
]);
$arr['cache'] = implode(DIRECTORY_SEPARATOR, [
$arr['base'],
'cache'

View File

@ -0,0 +1,9 @@
<?php
use Psr\Container\ContainerInterface;
return [
DateTimeZone::class => function(ContainerInterface $container) {
return new DateTimeZone($container->get('TZ') ?? 'America/Santiago');
},
'loopFrequency' => 60
];

View File

@ -3,8 +3,9 @@ use Psr\Container\ContainerInterface;
return [
Incoviba\Service\Login::class => function(ContainerInterface $container) {
$uri = $container->has('API_URL') ? $container->get('API_URL') : 'http://proxy/api';
$client = new GuzzleHttp\Client([
'base_uri' => $container->get('API_URL'),
'base_uri' => $uri,
'headers' => [
'Authorization' => [
'Bearer ' . md5($container->get('API_KEY'))
@ -16,18 +17,39 @@ return [
$container->get(Psr\Log\LoggerInterface::class),
implode(DIRECTORY_SEPARATOR, [$container->get('folders')->cache, 'token']),
$container->get('API_USERNAME'),
$container->get('API_PASSWORD')
$container->get('API_PASSWORD'),
$container->get('API_KEY')
);
},
GuzzleHttp\HandlerStack::class => function(ContainerInterface $container) {
$stack = new GuzzleHttp\HandlerStack();
$stack->setHandler($container->get(GuzzleHttp\Handler\CurlHandler::class));
$stack->push(GuzzleHttp\Middleware::mapRequest(function(Psr\Http\Message\RequestInterface $request) use ($container) {
$login = $container->get(Incoviba\Service\Login::class);
return $request->withHeader('Authorization', "Bearer {$login->getKey()}");
}));
$stack->push(GuzzleHttp\Middleware::mapRequest(function(Psr\Http\Message\RequestInterface $request) use ($container) {
if (!$request->hasHeader('Authorization')) {
return false;
}
return $request;
}));
return $stack;
},
Psr\Http\Client\ClientInterface::class => function(ContainerInterface $container) {
$login = $container->get(Incoviba\Service\Login::class);
return new GuzzleHttp\Client([
'base_uri' => $container->get('API_URL'),
'headers' => [
'Authorization' => [
"Bearer {$login->getKey($container->get('API_KEY'))}"
]
]
'base_uri' => $container->has('API_URL') ? $container->get('API_URL') : 'http://proxy/api',
'handler' => $container->get(GuzzleHttp\HandlerStack::class),
]);
}
},
Incoviba\Service\FastCGI::class => function(ContainerInterface $container) {
$fcgi = new Incoviba\Service\FastCGI(
$container->get(Incoviba\Service\Login::class),
$container->has('SOCKET_HOST') ? $container->get('SOCKET_HOST') : 'web',
$container->has('SOCKET_PORT') ? $container->get('SOCKET_PORT') : 9090,
$container->has('SOCKET_ROOT') ? $container->get('SOCKET_ROOT') : '/code/public/index.php'
);
$fcgi->setLogger($container->get(Psr\Log\LoggerInterface::class));
return $fcgi;
},
];

View File

@ -11,5 +11,21 @@ return [
$container->get(Psr\Log\LoggerInterface::class),
$container->get('commands')
);
},
Incoviba\Command\BaseLoop::class => function(ContainerInterface $container) {
return new Incoviba\Command\BaseLoop(
$container->get('LoopLogger'),
$container->get(Incoviba\Service\Schedule::class),
$container->get(DateTimeZone::class),
$container->get('loopFrequency'),
);
},
Incoviba\Command\Queue::class => function(ContainerInterface $container) {
return new Incoviba\Command\Queue(
$container->get(Psr\Http\Client\ClientInterface::class),
$container->get('QueueLogger'),
$container->get(Incoviba\Service\Job::class),
$container->get(DateTimeZone::class)
);
}
];

View File

@ -3,35 +3,124 @@ use Psr\Container\ContainerInterface;
return [
Psr\Log\LoggerInterface::class => function(ContainerInterface $container) {
return new Monolog\Logger('incoviba', [
new Monolog\Handler\FilterHandler(
(new Monolog\Handler\RotatingFileHandler('/logs/debug.log', 10))
->setFormatter(new Monolog\Formatter\LineFormatter(null, null, false, false, true)),
Monolog\Level::Debug,
Monolog\Level::Debug
),
new Monolog\Handler\FilterHandler(
(new Monolog\Handler\RotatingFileHandler('/logs/info.log', 10))
->setFormatter(new Monolog\Formatter\LineFormatter(null, null, false, false, true)),
Monolog\Level::Info,
Monolog\Level::Warning,
),
new Monolog\Handler\FilterHandler(
(new Monolog\Handler\RotatingFileHandler('/logs/error.log', 10))
->setFormatter(new Monolog\Formatter\LineFormatter(null, null, false, false, true)),
Monolog\Level::Error,
Monolog\Level::Error
),
new Monolog\Handler\FilterHandler(
(new Monolog\Handler\RotatingFileHandler('/logs/critical.log', 10))
->setFormatter(new Monolog\Formatter\LineFormatter(null, null, false, false, true)),
Monolog\Level::Critical
)
], [
$minLogLevel = Monolog\Level::Debug;
if ($container->has('DEBUG') and $container->get('DEBUG') === 'false') {
$minLogLevel = Monolog\Level::Warning;
}
$handlers = [];
switch($minLogLevel) {
case Monolog\Level::Debug:
$handlers []= new Monolog\Handler\FilterHandler(
($container->has('ENVIRONMENT') and $container->get('ENVIRONMENT') === 'development')
? (new Monolog\Handler\StreamHandler('/logs/debug.log'))
->setFormatter($container->get(Monolog\Formatter\LineFormatter::class))
: (new Monolog\Handler\RotatingFileHandler('/logs/debug.log', 10))
->setFormatter(new Monolog\Formatter\LineFormatter(null, null, false, false, true)),
Monolog\Level::Debug,
Monolog\Level::Debug,
false
);
case Monolog\Level::Info:
case Monolog\Level::Notice:
$handlers []= new Monolog\Handler\FilterHandler(
($container->has('ENVIRONMENT') and $container->get('ENVIRONMENT') === 'development')
? (new Monolog\Handler\StreamHandler('/logs/notices.log'))
->setFormatter($container->get(Monolog\Formatter\LineFormatter::class))
: (new Monolog\Handler\RotatingFileHandler('/logs/info.log', 10))
->setFormatter(new Monolog\Formatter\LineFormatter(null, null, false, false, true)),
Monolog\Level::Info,
Monolog\Level::Notice,
false
);
case Monolog\Level::Warning:
case Monolog\Level::Error:
$handlers []= new Monolog\Handler\FilterHandler(
($container->has('ENVIRONMENT') and $container->get('ENVIRONMENT') === 'development')
? (new Monolog\Handler\StreamHandler('/logs/error.log'))
->setFormatter($container->get(Monolog\Formatter\LineFormatter::class))
: (new Monolog\Handler\RotatingFileHandler('/logs/error.log', 10))
->setFormatter($container->get(Monolog\Formatter\LineFormatter::class)),
Monolog\Level::Warning,
Monolog\Level::Error,
false
);
case Monolog\Level::Critical:
case Monolog\Level::Alert:
case Monolog\Level::Emergency:
$handlers []= new Monolog\Handler\FilterHandler(
($container->has('ENVIRONMENT') and $container->get('ENVIRONMENT') === 'development')
? (new Monolog\Handler\StreamHandler('/logs/critical.log'))
->setFormatter($container->get(Monolog\Formatter\LineFormatter::class))
: (new Monolog\Handler\RotatingFileHandler('/logs/critical.log', 10))
->setFormatter($container->get(Monolog\Formatter\LineFormatter::class)),
Monolog\Level::Critical
);
}
return new Monolog\Logger('incoviba', $handlers, [
$container->get(Monolog\Processor\PsrLogMessageProcessor::class),
$container->get(Monolog\Processor\IntrospectionProcessor::class),
$container->get(Monolog\Processor\MemoryUsageProcessor::class),
$container->get(Monolog\Processor\MemoryPeakUsageProcessor::class)
]);
], $container->get(DateTimeZone::class));
},
'LoopLogger' => function(ContainerInterface $container) {
$handlers = [
'warning' => new Monolog\Handler\FilterHandler(
new Monolog\Handler\RotatingFileHandler('/logs/loop-error.log', 10),
Monolog\Level::Warning
),
'notice' => new Monolog\Handler\FilterHandler(
new Monolog\Handler\RotatingFileHandler('/logs/loop.log', 10),
Monolog\Level::Notice,
Monolog\Level::Notice
),
'debug' => new Monolog\Handler\FilterHandler(
new Monolog\Handler\RotatingFileHandler('/logs/loop-debug.log', 10),
Monolog\Level::Debug,
Monolog\Level::Debug
)
];
if ($container->has('ENVIRONMENT') and $container->get('ENVIRONMENT') === 'development') {
$handlers['warning'] = new Monolog\Handler\FilterHandler(
new Monolog\Handler\StreamHandler('/logs/loop-error.log'),
Monolog\Level::Warning);
$handlers['notice'] = new Monolog\Handler\FilterHandler(
new Monolog\Handler\StreamHandler('/logs/loop.log'),
Monolog\Level::Notice, Monolog\Level::Notice);
$handlers['debug'] = new Monolog\Handler\FilterHandler(
new Monolog\Handler\StreamHandler('/logs/loop-debug.log'),
Monolog\Level::Debug, Monolog\Level::Debug);
}
return new Monolog\Logger('loop', $handlers, [], $container->get(DateTimeZone::class));
},
'QueueLogger' => function(ContainerInterface $container) {
$handlers = [
'warning' => new Monolog\Handler\FilterHandler(
new Monolog\Handler\RotatingFileHandler('/logs/queue-error.log', 10),
Monolog\Level::Warning
),
'notice' => new Monolog\Handler\FilterHandler(
new Monolog\Handler\RotatingFileHandler('/logs/queue.log', 10),
Monolog\Level::Notice,
Monolog\Level::Notice
),
'debug' => new Monolog\Handler\FilterHandler(
new Monolog\Handler\RotatingFileHandler('/logs/queue-debug.log', 10),
Monolog\Level::Debug,
Monolog\Level::Debug
)
];
if ($container->has('ENVIRONMENT') and $container->get('ENVIRONMENT') === 'development') {
$handlers['warning'] = new Monolog\Handler\FilterHandler(
new Monolog\Handler\StreamHandler('/logs/queue-error.log'),
Monolog\Level::Warning);
$handlers['notice'] = new Monolog\Handler\FilterHandler(
new Monolog\Handler\StreamHandler('/logs/queue.log'),
Monolog\Level::Notice, Monolog\Level::Notice);
$handlers['debug'] = new Monolog\Handler\FilterHandler(
new Monolog\Handler\StreamHandler('/logs/queue-debug.log'),
Monolog\Level::Debug, Monolog\Level::Debug);
}
return new Monolog\Logger('queue', $handlers, [], $container->get(DateTimeZone::class));
}
];

View File

@ -0,0 +1,30 @@
<?php
use Psr\Container\ContainerInterface;
return [
Predis\ClientInterface::class => function(ContainerInterface $container) {
$options = [
'scheme' => 'tcp',
'host' => $container->has('REDIS_HOST') ? $container->get('REDIS_HOST') : 'redis',
'port' => $container->has('REDIS_PORT') ? $container->get('REDIS_PORT') : 6379
];
if ($container->has('REDIS_USER')) {
$options['username'] = $container->get('REDIS_USER');
}
if ($container->has('REDIS_PASSWORD')) {
$options['password'] = $container->get('REDIS_PASSWORD');
}
return new Predis\Client($options);
},
Pheanstalk\Pheanstalk::class => function(ContainerInterface $container) {
return Pheanstalk\Pheanstalk::create(
$container->get('BEANSTALKD_HOST'),
$container->has('BEANSTALKD_PORT') ? $container->get('BEANSTALKD_PORT') : 11300
);
},
Incoviba\Service\MQTT\MQTTInterface::class => function(ContainerInterface $container) {
$service = new Incoviba\Service\MQTT($container->get(Psr\Log\LoggerInterface::class));
$service->register('default', $container->get(Incoviba\Service\MQTT\Pheanstalk::class));
return $service;
}
];

View File

@ -0,0 +1,85 @@
<?php
namespace Incoviba\Command;
use DateTimeZone;
use Throwable;
use DateTimeInterface;
use DateTimeImmutable;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console;
use Incoviba\Service\Schedule;
#[Console\Attribute\AsCommand(
name: 'loop',
description: 'Run base loop',
)]
class BaseLoop extends Console\Command\Command
{
public function __construct(protected LoggerInterface $logger, protected Schedule $scheduleService,
protected DateTimeZone $timezone,
protected int $timeout = 5 * 60,
?string $name = null)
{
parent::__construct($name);
}
public function execute(Console\Input\InputInterface $input, Console\Output\OutputInterface $output): int
{
$this->write($output, 'Running loop...');
$this->waitNextTimeout($output);
$this->logger->info("Starting loop...");
$this->write($output, 'Starting loop...');
while (true) {
$commands = $this->scheduleService->getPending();
$start = new DateTimeImmutable('now', $this->timezone);
foreach ($commands as $command) {
$this->runCommand($input, $output, $command);
}
unset($commands);
memory_reset_peak_usage();
$this->waitNextTimeout($output, $start);
}
return self::SUCCESS;
}
protected function waitNextTimeout(Console\Output\OutputInterface $output, ?DateTimeInterface $start = null): void
{
// wait for next minute
if ($start === null) {
$start = new DateTimeImmutable('now', $this->timezone);
}
$nextTimeout = new DateTimeImmutable($start->format('Y-m-d H:i:00'), $this->timezone);
$nextTimeout = $nextTimeout->add(new \DateInterval("PT{$this->timeout}S"));
$diff = $nextTimeout->getTimestamp() - $start->getTimestamp();
if ($diff > 0) {
$this->logger->debug("Waiting {$diff} seconds...");
sleep($diff);
}
}
protected function runCommand(Console\Input\InputInterface $input, Console\Output\OutputInterface $output, string $commandName): int
{
try {
$command = $this->getApplication()->find($commandName);
} catch (Console\Exception\CommandNotFoundException $exception) {
$this->logger->warning($exception);
return self::FAILURE;
}
$cmd = new Console\Input\ArrayInput([
'command' => $commandName
]);
try {
$this->logger->notice("Running commnand: {$commandName}");
return $this->getApplication()->doRun($cmd, $output);
} catch (Throwable $exception) {
$this->logger->warning($exception);
return self::FAILURE;
}
}
protected function write(Console\Output\OutputInterface $output, string $message): void
{
$now = new DateTimeImmutable('now', $this->timezone);
$output->writeln("[{$now->format('Y-m-d H:i:s e')}] {$message}");
}
}

View File

@ -10,8 +10,8 @@ use Incoviba\Common\Alias\Command;
)]
class Comunas extends Command
{
public function execute(Console\Input\InputInterface $input, Console\Output\OutputInterface $output)
{
public function execute(Console\Input\InputInterface $input, Console\Output\OutputInterface $output): int
{
$this->logger->debug("Running {$this->getName()}");
$uri = '/api/direcciones/region/13/comunas';
$output->writeln("GET {$uri}");

View File

@ -10,7 +10,7 @@ use Incoviba\Common\Alias\Command;
)]
class Update extends Command
{
public function execute(Console\Input\InputInterface $input, Console\Output\OutputInterface $output)
public function execute(Console\Input\InputInterface $input, Console\Output\OutputInterface $output): int
{
$this->logger->debug("Running {$this->getName()}");
$uri = '/api/contabilidad/cartolas/update';

View File

@ -0,0 +1,34 @@
<?php
namespace Incoviba\Command;
use Symfony\Component\Console;
use Incoviba\Common\Alias;
#[Console\Attribute\AsCommand(
name: 'external:services',
description: 'Check external services',
)]
class ExternalServices extends Alias\Command
{
protected function configure(): void
{
$this->addOption('update', 'u', Console\Input\InputOption::VALUE_NONE, 'Update');
}
public function execute(Console\Input\InputInterface $input, Console\Output\OutputInterface $output): int
{
$update = $input->getOption('update');
$url = '/api/external/services/check';
if ($update) {
$url = '/api/external/services/update';
}
$output->writeln("GET {$url}");
$response = $this->client->get($url);
$output->writeln("Response Code: {$response->getStatusCode()}");
return Console\Command\Command::SUCCESS;
}
}

View File

@ -12,12 +12,12 @@ use Incoviba\Common\Alias\Command;
)]
class Full extends Command
{
public function __construct(ClientInterface $client, LoggerInterface $logger, protected array $commandsList, string $name = null)
public function __construct(ClientInterface $client, LoggerInterface $logger, protected array $commandsList, ?string $name = null)
{
parent::__construct($client, $logger, $name);
}
public function execute(Console\Input\InputInterface $input, Console\Output\OutputInterface $output)
public function execute(Console\Input\InputInterface $input, Console\Output\OutputInterface $output): int
{
foreach ($this->commandsList as $command) {
$cmd = new Console\Input\ArrayInput([

View File

@ -0,0 +1,67 @@
<?php
namespace Incoviba\Command\Job;
use DateMalformedStringException;
use DateTimeImmutable;
use DateTimeZone;
use Incoviba\Exception\Client\FastCGI as FastCGIException;
use Incoviba\Service;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console;
#[Console\Attribute\AsCommand(name: 'jobs:run', description: 'Run job')]
class Run extends Console\Command\Command
{
public function __construct(protected Service\FastCGI $fastcgi, protected LoggerInterface $logger,
protected Service\Job $jobService,
protected DateTimeZone $timeZone, ?string $name = null)
{
parent::__construct($name);
}
public function execute(Console\Input\InputInterface $input, Console\Output\OutputInterface $output): int
{
try {
$now = new DateTimeImmutable('now', $this->timeZone);
} catch (DateMalformedStringException) {
$now = new DateTimeImmutable();
}
if ($this->jobService->getPending() === 0) {
$output->writeln("[{$now->format('Y-m-d H:i:s e')}] No pending jobs to run.");
return self::SUCCESS;
}
$output->writeln("[{$now->format('Y-m-d H:i:s e')}] Running Ready Job...");
$this->runJob();
return $this->getResponses();
}
protected function runJob(): bool
{
$uri = "/api/queue/run";
try {
$this->fastcgi->get($uri);
return true;
} catch (FastCGIException $exception) {
$this->logger->error($exception->getMessage(), ['uri' => $uri, 'exception' =>$exception]);
return false;
}
}
protected function getResponses(): int
{
$result = self::SUCCESS;
$responses = $this->fastcgi->awaitResponses();
foreach ($responses as $response) {
if ($response->getError() !== '') {
$this->logger->error("Error running job", [
'error' => $response->getError(),
'body' => $response->getBody(),
'headers' => $response->getHeaders(),
]);
$result = self::FAILURE;
}
}
return $result;
}
}

View File

@ -3,6 +3,7 @@ namespace Incoviba\Command\Money;
use DateTimeImmutable;
use DateInterval;
use DateTimeZone;
use Symfony\Component\Console;
use Incoviba\Common\Alias\Command;
@ -12,13 +13,15 @@ use Incoviba\Common\Alias\Command;
)]
class IPC extends Command
{
public function execute(Console\Input\InputInterface $input, Console\Output\OutputInterface $output)
public function execute(Console\Input\InputInterface $input, Console\Output\OutputInterface $output): int
{
$this->logger->debug("Running {$this->getName()}");
$lastMonth = (new DateTimeImmutable())->sub(new DateInterval('P1M'));
$endLastYear = (new DateTimeImmutable())->sub(new DateInterval('P1Y'));
$timezone = new DateTimeZone($_ENV['TZ'] ?? 'America/Santiago');
$now = new DateTimeImmutable('now', $timezone);
$lastMonth = $now->sub(new DateInterval('P1M'));
$endLastYear = $now->sub(new DateInterval('P1Y'));
$uri = '/api/money/ipc';
$current = new DateTimeImmutable($lastMonth->format('Y-m-d'));
$current = new DateTimeImmutable($lastMonth->format('Y-m-d'), $timezone);
while ($current->diff($endLastYear)->days > 30) {
$data = [
'start' => $endLastYear->format('Y-12-1'),

View File

@ -2,6 +2,7 @@
namespace Incoviba\Command\Money;
use DateTimeImmutable;
use DateTimeZone;
use Symfony\Component\Console;
use Incoviba\Common\Alias\Command;
@ -11,10 +12,10 @@ use Incoviba\Common\Alias\Command;
)]
class UF extends Command
{
public function execute(Console\Input\InputInterface $input, Console\Output\OutputInterface $output)
public function execute(Console\Input\InputInterface $input, Console\Output\OutputInterface $output): int
{
$this->logger->debug("Running {$this->getName()}");
$now = new DateTimeImmutable();
$now = new DateTimeImmutable('now', new DateTimeZone($_ENV['TZ'] ?? 'America/Santiago'));
$uri = '/api/money/uf';
$data = [
'fecha' => $now->format('Y-m-d')

View File

@ -11,7 +11,7 @@ use Incoviba\Common\Alias\Command;
)]
class Update extends Command
{
public function execute(Console\Input\InputInterface $input, Console\Output\OutputInterface $output)
public function execute(Console\Input\InputInterface $input, Console\Output\OutputInterface $output): int
{
$this->logger->debug("Running {$this->getName()}");
$url = '/api/money/ufs';

View File

@ -10,7 +10,7 @@ use Incoviba\Common\Alias\Command;
)]
class Activos extends Command
{
public function execute(Console\Input\InputInterface $input, Console\Output\OutputInterface $output)
public function execute(Console\Input\InputInterface $input, Console\Output\OutputInterface $output): int
{
$this->logger->debug("Running {$this->getName()}");
$uri = '/api/proyectos';

81
cli/src/Command/Queue.php Normal file
View File

@ -0,0 +1,81 @@
<?php
namespace Incoviba\Command;
use DateTimeImmutable;
use DateTimeZone;
use Psr\Http\Client\ClientInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console;
use Incoviba\Service\Job;
use Incoviba\Common\Alias\Command;
use Throwable;
#[Console\Attribute\AsCommand(
name: 'queue',
description: 'Run queue'
)]
class Queue extends Command
{
public function __construct(ClientInterface $client, LoggerInterface $logger,
protected Job $jobService,
protected DateTimeZone $timezone,
protected string $baseCommand = '/code/bin/incoviba',
protected int $batchSize = 10,
?string $name = null)
{
parent::__construct($client, $logger, $name);
}
public function execute(Console\Input\InputInterface $input, Console\Output\OutputInterface $output): int
{
$this->logger->debug("Running {$this->getName()}");
$this->sections = [
'top' => $output->section(),
'bottom' => $output->section(),
];
$io = new Console\Style\SymfonyStyle($input, $this->sections['top']);
$now = new DateTimeImmutable('now', $this->timezone);
if ($this->jobService->getPending() === 0) {
$io->success("[{$now->format('Y-m-d H:i:s e')}] Queue is empty");
return self::SUCCESS;
}
$io->title("[{$now->format('Y-m-d H:i:s e')}] Running Queue...");
$results = [];
for ($i = 0; $i < $this->batchSize; $i++) {
if ($this->jobService->getPending() === 0) {
break;
}
$results []= $this->runJob();
}
return count(array_filter($results, fn ($result) => $result === self::FAILURE)) === 0 ? self::SUCCESS : self::FAILURE;
}
protected array $sections;
protected array $outputs = [];
protected function runJob(): int
{
$baseCommand = "{$this->baseCommand} jobs:run";
$command = "{$baseCommand}";
try {
exec($command, $output, $resultCode);
$this->outputs []= $output;
} catch (Throwable $exception) {
$this->logger->error("Failed to run command", [
'command' => $command,
'exception' => $exception
]);
return self::FAILURE;
}
if ($resultCode !== 0) {
$this->logger->error("Failed to run command", [
'command' => $command,
'result_code' => $resultCode
]);
return self::FAILURE;
} else {
return self::SUCCESS;
}
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace Incoviba\Command\Queue;
use Incoviba\Service;
use Symfony\Component\Console;
#[Console\Attribute\AsCommand(name: 'queue:pending', description: 'List pending jobs in queue')]
class Pending extends Console\Command\Command
{
public function __construct(protected Service\Job $jobService, ?string $name = null)
{
parent::__construct($name);
}
protected function execute(Console\Input\InputInterface $input, Console\Output\OutputInterface $output): int
{
$jobCount = $this->jobService->getPending();
$output->writeln("Found {$jobCount} pending jobs");
return self::SUCCESS;
}
}

View File

@ -0,0 +1,121 @@
<?php
namespace Incoviba\Command\Queue;
use Throwable;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console;
use Incoviba\Service;
#[Console\Attribute\AsCommand(name: 'queue:push', description: 'Push a job to the queue')]
class Push extends Console\Command\Command
{
public function __construct(protected LoggerInterface $logger, protected Service\Job $jobService, ?string $name = null)
{
parent::__construct($name);
}
protected function configure(): void
{
$this->addOption('configurations', 'c', Console\Input\InputOption::VALUE_REQUIRED | Console\Input\InputOption::VALUE_IS_ARRAY, 'Job configuration options array, each job configuration must be in valid JSON format');
$this->addOption('files', 'f', Console\Input\InputOption::VALUE_REQUIRED | Console\Input\InputOption::VALUE_IS_ARRAY, 'Paths to jobs configurations files with JSON array content');
}
protected function execute(Console\Input\InputInterface $input, Console\Output\OutputInterface $output): int
{
$io = new Console\Style\SymfonyStyle($input, $output);
$io->title("Pushing job");
$configurations = $this->getConfigurations($input);
if (count($configurations) === 0) {
$io->error('Missing configurations');
return self::FAILURE;
}
$result = self::SUCCESS;
foreach ($configurations as $configuration) {
if (!json_validate($configuration)) {
$io->error("Invalid JSON: {$configuration}");
continue;
}
$configuration = json_decode($configuration, true);
try {
$job = $this->jobService->push($configuration);
$io->success("Job pushed with ID {$job['id']}");
} catch (Throwable $exception) {
$io->error($exception->getMessage());
$result = self::FAILURE;
}
}
return $result;
}
protected function getConfigurations(Console\Input\InputInterface $input): array
{
return [
...$this->getFilesConfigurations($input),
...$this->getOptionConfigurations($input),
];
}
protected function getFilesConfigurations(Console\Input\InputInterface $input): array
{
$configurations = [];
$files = $input->getOption('files');
if ($files === null) {
return $configurations;
}
foreach ($files as $filePath) {
if (!file_exists($filePath)) {
continue;
}
$configurations = array_merge($configurations, $this->getFileConfigurations($filePath));
}
return $configurations;
}
protected function getFileConfigurations(string $filePath): array
{
$configurations = [];
if (!file_exists($filePath)) {
return $configurations;
}
$json = file_get_contents($filePath);
if (!json_validate($json)) {
return $configurations;
}
$tmp = json_decode($json, true);
foreach ($tmp as $config) {
try {
$configurations []= $this->processConfiguration(json_encode($config));
} catch (Throwable $exception) {
$this->logger->warning($exception->getMessage(), ['exception' => $exception, 'config' => $config]);
}
}
return $configurations;
}
protected function getOptionConfigurations(Console\Input\InputInterface $input): array
{
$configurations = [];
$configOptions = $input->getOption('configurations');
if ($configOptions === null) {
return $configurations;
}
foreach ($configOptions as $config) {
try {
$configurations []= $this->processConfiguration($config);
} catch (Throwable $exception) {
$this->logger->warning($exception->getMessage(), ['exception' => $exception, 'config' => $config]);
}
}
return $configurations;
}
protected function processConfiguration(string $configuration): string
{
$json = json_decode($configuration, true);
if (!array_key_exists('type', $json) and !array_key_exists('configuration', $json)) {
throw new Console\Exception\InvalidArgumentException('Missing type or configuration key in JSON');
}
if (array_key_exists('type', $json)) {
return json_encode($json);
}
return json_encode($json['configuration']);
}
}

View File

@ -10,7 +10,7 @@ use Incoviba\Common\Alias\Command;
)]
class Vigentes extends Command
{
public function execute(Console\Input\InputInterface $input, Console\Output\OutputInterface $output)
public function execute(Console\Input\InputInterface $input, Console\Output\OutputInterface $output): int
{
$this->logger->debug("Running {$this->getName()}");
$uri = '/api/ventas/cierres/vigentes';

View File

@ -10,7 +10,7 @@ use Incoviba\Common\Alias\Command;
)]
class Hoy extends Command
{
public function execute(Console\Input\InputInterface $input, Console\Output\OutputInterface $output)
public function execute(Console\Input\InputInterface $input, Console\Output\OutputInterface $output): int
{
$this->logger->debug("Running {$this->getName()}");
$uri = '/api/ventas/cuotas/hoy';

View File

@ -10,7 +10,7 @@ use Incoviba\Common\Alias\Command;
)]
class Pendientes extends Command
{
public function execute(Console\Input\InputInterface $input, Console\Output\OutputInterface $output)
public function execute(Console\Input\InputInterface $input, Console\Output\OutputInterface $output): int
{
$this->logger->debug("Running {$this->getName()}");
$uri = '/api/ventas/cuotas/pendiente';

View File

@ -10,7 +10,7 @@ use Incoviba\Common\Alias\Command;
)]
class PorVencer extends Command
{
public function execute(Console\Input\InputInterface $input, Console\Output\OutputInterface $output)
public function execute(Console\Input\InputInterface $input, Console\Output\OutputInterface $output): int
{
$this->logger->debug("Running {$this->getName()}");
$uri = '/api/ventas/cuotas/vencer';

View File

@ -0,0 +1,26 @@
<?php
namespace Incoviba\Command\Ventas\MedioPagos\Toku;
use Symfony\Component\Console;
use Incoviba\Common\Alias\Command;
#[Console\Attribute\AsCommand(name: 'external:toku:enqueue')]
class Enqueue extends Command
{
protected function configure(): void
{
$this->addArgument('venta_ids', Console\Input\InputArgument::REQUIRED, 'Venta IDs separated by |');
}
protected function execute(Console\Input\InputInterface $input, Console\Output\OutputInterface $output): int
{
$this->logger->debug("Running {$this->getName()}");
$uri = '/api/external/toku/enqueue';
$output->writeln("POST {$uri}");
$body = ['venta_ids' => explode('|', $input->getArgument('venta_ids'))];
$output->writeln(json_encode($body));
$response = $this->client->post($uri, ['json' => $body, 'headers' => ['Content-Type' => 'application/json']]);
$output->writeln("Response Code: {$response->getStatusCode()}");
$output->writeln($response->getBody()->getContents());
return Console\Command\Command::SUCCESS;
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace Incoviba\Command\Ventas\MedioPagos\Toku;
use Symfony\Component\Console;
use Incoviba\Common\Alias\Command;
#[Console\Attribute\AsCommand(name: 'external:toku:reset')]
class Reset extends Command
{
protected function configure(): void
{
$this->addOption('venta_ids', 'vid', Console\Input\InputOption::VALUE_REQUIRED, 'Venta IDs separated by |', '');
}
protected function execute(Console\Input\InputInterface $input, Console\Output\OutputInterface $output): int
{
$this->logger->debug("Running {$this->getName()}");
$uri = '/api/external/toku/reset';
$output->writeln("DELETE {$uri}");
$venta_ids = $input->getOption('venta_ids');
$body = [];
if (!empty($venta_ids)) {
$body = ['skips' => ['subscription' => explode('|', $venta_ids)]];
}
$options = count($body) > 0 ? ['json' => $body] : [];
$response = $this->client->delete($uri, $options);
$output->writeln("Response Code: {$response->getStatusCode()}");
$this->logger->debug("Response: {$response->getBody()->getContents()}");
return self::SUCCESS;
}
}

View File

@ -0,0 +1,55 @@
<?php
namespace Incoviba\Exception\Client;
use Throwable;
use Psr\Http\Client\ClientExceptionInterface;
class FastCGI implements ClientExceptionInterface
{
public function __construct(protected ?Throwable $previous = null) {}
public function getMessage(): string
{
$message = "Could not send request";
if ($this->previous !== null) {
$message .= ": {$this->previous->getMessage()}";
}
return $message;
}
public function getCode()
{
return $this->previous?->getCode() ?? 500;
}
public function getFile(): string
{
return $this->previous?->getFile() ?? '';
}
public function getLine(): int
{
return $this->previous?->getLine() ?? 0;
}
public function getTrace(): array
{
return $this->previous?->getTrace() ?? [];
}
public function getTraceAsString(): string
{
return $this->previous?->getTraceAsString() ?? '';
}
public function getPrevious(): ?Throwable
{
return $this->previous;
}
public function __toString()
{
return $this->getMessage();
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace Incoviba\Exception;
use Throwable;
use Exception;
abstract class MQTT extends Exception
{
public function __construct(string $message = "", int $code = 0, ?Throwable $previous = null)
{
$baseCode = 700;
$code = $baseCode + $code;
if ($message == "") {
$message = "MQTT Exception";
}
parent::__construct($message, $code, $previous);
}
}

View File

@ -0,0 +1,15 @@
<?php
namespace Incoviba\Exception\MQTT;
use Throwable;
use Incoviba\Exception\MQTT;
class Create extends MQTT
{
public function __construct(string $tube = '', string $payload = '', ?Throwable $previous = null)
{
$message = "Unable to create MQTT message: {$payload} in tube {$tube}";
$code = 11;
parent::__construct($message, $code, $previous);
}
}

View File

@ -0,0 +1,15 @@
<?php
namespace Incoviba\Exception\MQTT;
use Throwable;
use Incoviba\Exception\MQTT;
class Delete extends MQTT
{
public function __construct(string $tube, int $jobId, ?Throwable $previous = null)
{
$message = "Could not delete job {$jobId} in tube {$tube}";
$code = 13;
parent::__construct($message, $code, $previous);
}
}

View File

@ -0,0 +1,15 @@
<?php
namespace Incoviba\Exception\MQTT;
use Throwable;
use Incoviba\Exception\MQTT;
class Read extends MQTT
{
public function __construct(string $tube, ?Throwable $previous = null)
{
$message = "Error reading from tube {$tube}";
$code = 10;
parent::__construct($message, $code, $previous);
}
}

View File

@ -0,0 +1,14 @@
<?php
namespace Incoviba\Exception\MQTT;
use Incoviba\Exception\MQTT;
use Throwable;
class UnknownTransport extends MQTT
{
public function __construct(string $transportName, ?Throwable $previous = null)
{
$message = "Unknown transport {$transportName}";
parent::__construct($message, 1, $previous);
}
}

View File

@ -0,0 +1,16 @@
<?php
namespace Incoviba\Exception\MQTT;
use Throwable;
use Incoviba\Exception\MQTT;
class Update extends MQTT
{
public function __construct(string $tube, string $payload, ?int $jobId = null, ?Throwable $previous = null)
{
$jobString = $jobId !== null ? " with jobId {$jobId}" : '';
$message = "Could not update job{$jobString} with {$payload} in tube {$tube}";
$code = 12;
parent::__construct($message, $code, $previous);
}
}

9
cli/src/Service.php Normal file
View File

@ -0,0 +1,9 @@
<?php
namespace Incoviba;
use Psr\Log\LoggerInterface;
abstract class Service
{
public function __construct(protected LoggerInterface $logger) {}
}

View File

@ -0,0 +1,51 @@
<?php
namespace Incoviba\Service;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use ReflectionClass;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
class Commands
{
public function __construct(protected LoggerInterface $logger, public ?string $baseCommandsPath = null, public array $skipCommands = [])
{
if ($this->baseCommandsPath === null) {
$this->baseCommandsPath = implode(DIRECTORY_SEPARATOR, [
dirname(__DIR__, 1),
'Command'
]);
}
$this->baseCommandsPath = realpath($this->baseCommandsPath);
}
public function getCommandsList(): array
{
$commands = [];
$files = new RecursiveIteratorIterator((new RecursiveDirectoryIterator($this->baseCommandsPath)));
foreach ($files as $file) {
if ($file->isDir()) {
continue;
}
$basename = ltrim(str_replace(DIRECTORY_SEPARATOR, "\\",
str_replace([$this->baseCommandsPath, '.php'], '', $file->getRealPath())), "\\");
$namespace = "Incoviba\\Command";
$class = "{$namespace}\\{$basename}";
if (!class_exists($class)) {
$this->logger->error("Class {$class} not found");
continue;
}
$ref = new ReflectionClass($class);
$commandData = $ref->getAttributes(AsCommand::class)[0];
$commandName = $commandData->getArguments()['name'];
if (in_array($commandName, $this->skipCommands) or in_array($class, $this->skipCommands)) {
continue;
}
$commands[$commandName] = $class;
}
return $commands;
}
}

127
cli/src/Service/FastCGI.php Normal file
View File

@ -0,0 +1,127 @@
<?php
namespace Incoviba\Service;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerInterface;
use hollodotme\FastCGI as FCGI;
use Incoviba\Exception\Client\FastCGI as FastCGIException;
class FastCGI implements LoggerAwareInterface
{
public function __construct(protected Login $loginService, protected string $hostname, protected int $port,
protected string $documentRoot,
protected int $maxRequests = 50,
protected int $connectionTimeout = 5000, protected int $readTimeout = 5000)
{
$this->client = new FCGI\Client();
}
public LoggerInterface $logger;
public function getLogger(): LoggerInterface
{
return $this->logger;
}
public function setLogger(LoggerInterface $logger): void
{
$this->logger = $logger;
}
protected FCGI\Client $client;
protected FCGI\Interfaces\ConfiguresSocketConnection $socket;
public function connect(): self
{
$this->socket = new FCGI\SocketConnections\NetworkSocket($this->hostname, $this->port, $this->connectionTimeout, $this->readTimeout);
return $this;
}
protected array $socketIds = [];
/**
* @throws FastCGIException
*/
public function sendRequest(FCGI\Interfaces\ProvidesRequestData $request): self
{
if (count($this->socketIds) >= $this->maxRequests) {
throw new FastCGIException();
}
if (!isset($this->socket)) {
$this->connect();
}
$request = $this->setHeaders($request);
try {
$this->socketIds []= $this->client->sendAsyncRequest($this->socket, $request);
} catch (FCGI\Exceptions\FastCGIClientException $exception) {
$this->logger->error($exception->getMessage());
throw new FastCGIException($exception);
}
return $this;
}
/**
* @return array
*/
public function awaitResponses(): array
{
$responses = [];
$repeats = 0;
$maxRepeats = min(count($this->socketIds), $this->maxRequests);
while ($this->client->hasUnhandledResponses()) {
if ($repeats >= $maxRepeats) {
break;
}
try {
$readySocketIds = $this->client->getSocketIdsHavingResponse();
$readyResponses = $this->client->readReadyResponses(3000);
} catch (FCGI\Exceptions\FastCGIClientException $exception) {
$this->logger->error($exception->getMessage());
$repeats ++;
continue;
}
foreach ($readyResponses as $response) {
$responses []= $response;
$repeats ++;
}
$this->socketIds = array_diff($this->socketIds, $readySocketIds);
}
if ($this->client->hasUnhandledResponses()) {
$this->logger->error("Unhandled responses");
return array_merge($responses, $this->awaitResponses());
}
return $responses;
}
/**
* @param string $uri
* @return FastCGI
* @throws FastCGIException
*/
public function get(string $uri): self
{
$request = new FCGI\Requests\GetRequest($this->documentRoot, '');
$request->setRequestUri($uri);
return $this->sendRequest($request);
}
/**
* @param string $uri
* @param ?array $data
* @return FastCGI
* @throws FastCGIException
*/
public function post(string $uri, ?array $data): self
{
$content = new FCGI\RequestContents\JsonData($data ?? []);
$request = FCGI\Requests\PostRequest::newWithRequestContent($this->documentRoot, $content);
$request->setRequestUri($uri);
return $this->sendRequest($request);
}
protected function setHeaders(FCGI\Interfaces\ProvidesRequestData $request): FCGI\Interfaces\ProvidesRequestData
{
$apiKey = $this->loginService->getKey();
$request->setCustomVar('HTTP_AUTHORIZATION', "Bearer {$apiKey}");
return $request;
}
}

49
cli/src/Service/Job.php Normal file
View File

@ -0,0 +1,49 @@
<?php
namespace Incoviba\Service;
use DateInvalidTimeZoneException;
use DateMalformedStringException;
use DateTimeImmutable;
use DateTimeZone;
use Psr\Log\LoggerInterface;
use Incoviba\Exception\MQTT as MQTTException;
use Incoviba\Service\MQTT\MQTTInterface;
class Job
{
public function __construct(protected LoggerInterface $logger, protected MQTTInterface $mqttService) {}
protected string $redisKey;
public function getPending(): int
{
try {
return $this->mqttService->pending();
} catch (MQTTException $exception) {
$this->logger->warning($exception->getMessage(), ['exception' => $exception]);
return 0;
}
}
public function push(array $jobConfiguration): array
{
try {
$now = (new DateTimeImmutable('now', new DateTimeZone($_ENV['TZ'] ?? 'America/Santiago')));
} catch (DateMalformedStringException | DateInvalidTimeZoneException) {
$now = new DateTimeImmutable();
}
$data = [
'id' => $now->format('Uu'),
'configuration' => $jobConfiguration,
'executed' => false,
'created_at' => $now->format('Y-m-d H:i:s'),
'updated_at' => null,
'retries' => 0
];
try {
$this->mqttService->set(json_encode($data));
} catch (MQTTException $exception) {
$this->logger->warning($exception->getMessage(), ['exception' => $exception]);
}
return $data;
}
}

View File

@ -10,10 +10,11 @@ class Login
{
public function __construct(protected ClientInterface $client, protected LoggerInterface $logger,
protected string $tokenFilename,
protected string $username, protected string $password) {}
protected string $username, protected string $password, protected string $apiKey) {}
public function login(): string
{
$this->logger->info('Logging in');
$url = '/api/login';
try {
$response = $this->client->request('POST', $url, [
@ -24,22 +25,43 @@ class Login
'headers' => ['Content-Type' => 'application/x-www-form-urlencoded']
]);
} catch (ClientExceptionInterface $exception) {
$this->logger->error($exception);
$this->logger->error($exception, [
'username' => $this->username
]);
return '';
}
if ($response->getStatusCode() !== 200) {
$this->logger->error('Login failed', [
'statusCode' => $response->getStatusCode(),
'body' => $response->getBody()->getContents(),
'username' => $this->username,
'headers' => $response->getHeaders()
]);
return '';
}
$this->logger->info('Logged in');
$body = $response->getBody()->getContents();
$data = json_decode($body, true);
if (!key_exists('token', $data)) {
$this->logger->error('Token not found');
$this->logger->error('Token not found', [
'body' => $body
]);
return '';
}
$result = file_put_contents($this->tokenFilename, $data['token']);
if ($result === false) {
$this->logger->error('Failed to save token');
return '';
}
file_put_contents($this->tokenFilename, $data['token']);
return $data['token'];
}
/**
* @return string
* @throws Exception
*/
public function retrieveToken(): string
{
if (!file_exists($this->tokenFilename)) {
@ -55,22 +77,33 @@ class Login
'headers' => ['Authorization' => "Bearer {$token}"]
]);
} catch (ClientExceptionInterface $exception) {
$this->logger->error($exception);
$this->logger->error($exception, [
'token' => $token
]);
return false;
}
return $response->getStatusCode() === 200;
}
public function getKey(string $apiKey, string $separator = 'g'): string
public function getKey(?string $apiKey = null, string $separator = 'g'): string
{
if ($apiKey === null) {
$apiKey = $this->apiKey;
}
try {
$token = $this->retrieveToken();
if (!$this->tryToken(implode('', [md5($apiKey), $separator, $token]))) {
$savedToken = $this->retrieveToken();
$token = implode('', [md5($apiKey), $separator, $savedToken]);
if (!$this->tryToken($token)) {
throw new Exception('Token not valid');
}
} catch (Exception $exception) {
$this->logger->error($exception);
$token = $this->login();
$this->logger->notice($exception);
$savedToken = $this->login();
if ($savedToken === '' and file_exists($this->tokenFilename)) {
unlink($this->tokenFilename);
return '';
}
$token = implode('', [md5($apiKey), $separator, $savedToken]);
}
return implode('', [md5($apiKey), $separator, $token]);
return $token;
}
}

124
cli/src/Service/MQTT.php Normal file
View File

@ -0,0 +1,124 @@
<?php
namespace Incoviba\Service;
use Incoviba\Exception\MQTT as MQTTException;
use Incoviba\Service;
use Incoviba\Service\MQTT\MQTTInterface;
class MQTT extends Service implements MQTTInterface
{
protected array $transports = [];
public function register(string $name, MQTTInterface $transport): self
{
$this->transports[$name] = $transport;
return $this;
}
/**
* @param string $payload
* @param int $delay
* @param string|null $transportName
* @return $this
* @throws MQTTException\UnknownTransport
* @throws MQTTException\Create
*/
public function set(string $payload, int $delay = 0, ?string $transportName = null): self
{
$transport = $this->getTransport($transportName);
$transport->set($payload, $delay);
return $this;
}
/**
* @param string|null $transportName
* @return int
* @throws MQTTException\UnknownTransport
* @throws MQTTException\Read
*/
public function pending(?string $transportName = null): int
{
$transport = $this->getTransport($transportName);
return $transport->pending();
}
/**
* @param int|null $jobId
* @param string|null $transportName
* @return bool
* @throws MQTTException\UnknownTransport
* @throws MQTTException\Read
*/
public function exists(?int $jobId = null, ?string $transportName = null): bool
{
$transport = $this->getTransport($transportName);
return $transport->exists($jobId);
}
/**
* @param int|null $jobId
* @param string|null $transportName
* @return string
* @throws MQTTException\UnknownTransport
* @throws MQTTException\Read
*/
public function get(?int $jobId = null, ?string $transportName = null): string
{
$transport = $this->getTransport($transportName);
return $transport->get($jobId);
}
/**
* @param string $newPayload
* @param int|null $jobId
* @param string|null $transportName
* @return $this
* @throws MQTTException\UnknownTransport
* @throws MQTTException\Update
*/
public function update(string $newPayload, ?int $jobId = null, ?string $transportName = null): self
{
$transport = $this->getTransport($transportName);
$transport->update($newPayload, $jobId);
return $this;
}
/**
* @param int|null $jobId
* @param string|null $transportName
* @return $this
* @throws MQTTException\UnknownTransport
* @throws MQTTException\Delete
*/
public function remove(?int $jobId = null, ?string $transportName = null): self
{
$transport = $this->getTransport($transportName);
$transport->remove($jobId);
return $this;
}
/**
* @param string|null $transportName
* @return mixed
* @throws MQTTException\UnknownTransport
*/
protected function getTransport(?string $transportName): mixed
{
if (count($this->transports) === 0) {
throw new MQTTException\UnknownTransport('');
}
if ($transportName === null) {
if (array_key_exists('default', $this->transports)) {
$transportName = 'default';
} else {
$transportName = array_keys($this->transports)[0];
}
}
if (!array_key_exists($transportName, $this->transports)) {
if ($transportName === null) {
$transportName = '';
}
throw new MQTTException\UnknownTransport($transportName);
}
return $this->transports[$transportName];
}
}

View File

@ -0,0 +1,127 @@
<?php
namespace Incoviba\Service\MQTT;
use Exception;
use Psr\Log\LoggerInterface;
use xobotyi\beansclient;
use Incoviba\Service;
use Incoviba\Exception\MQTT;
class Beanstalkd extends Service implements MQTTInterface
{
const string DEFAULT_TUBE = 'default';
const int DEFAULT_TTR = 30;
const int DEFAULT_PRIORITY = 1_024;
public function __construct(LoggerInterface $logger, protected beansclient\Client $client,
protected string $tube = self::DEFAULT_TUBE,
protected int $ttr = self::DEFAULT_TTR,
protected int $priority = self::DEFAULT_PRIORITY)
{
parent::__construct($logger);
}
/**
* @param string $payload
* @param int $delay
* @return self
* @throws MQTT\Create
*/
public function set(string $payload, int $delay = 60): self
{
try {
$this->client->put($payload, $this->ttr, $this->priority, $delay);
} catch (Exception $exception) {
throw new MQTT\Create($this->tube, $payload, $exception);
}
return $this;
}
/**
* @return int
* @throws MQTT\Read
*/
public function pending(): int
{
try {
$stats = $this->client
->statsTube($this->tube);
} catch (Exception $exception) {
throw new MQTT\Read($this->tube, $exception);
}
if (!array_key_exists('current-jobs-ready', $stats)) {
throw new MQTT\Read($this->tube);
}
return $stats['current-jobs-ready'];
}
/**
* @param int|null $jobId
* @return bool
* @throws MQTT\Read
*/
public function exists(?int $jobId = null): bool
{
return $this->pending() > 0;
}
protected int $currentJobId;
/**
* @param int|null $jobId
* @return string
* @throws MQTT\Read
*/
public function get(?int $jobId = null): string
{
try {
if ($jobId !== null) {
$job = (object) $this->client
->reserveJob($jobId);
} else {
$job = (object) $this->client
->reserve();
}
} catch (Exception $exception) {
throw new MQTT\Read($this->tube, $exception);
}
$this->currentJobId = $job->id;
return $job->payload;
}
/**
* @param string $newPayload
* @param int|null $jobId
* @return self
* @throws MQTT\Update
*/
public function update(string $newPayload, ?int $jobId = null): self
{
try {
$this->remove($jobId);
$this->set($newPayload);
} catch (MQTT\Delete | MQTT\Create $exception) {
throw new MQTT\Update($this->tube, $newPayload, $jobId, $exception);
}
return $this;
}
/**
* @param int|null $jobId
* @return self
* @throws MQTT\Delete
*/
public function remove(?int $jobId = null): self
{
try {
if ($jobId === null) {
$jobId = $this->currentJobId;
}
$this->client
->delete($jobId);
} catch (Exception $exception) {
throw new MQTT\Delete($this->tube, $jobId, $exception);
}
return $this;
}
}

View File

@ -0,0 +1,12 @@
<?php
namespace Incoviba\Service\MQTT;
interface MQTTInterface
{
public function set(string $payload, int $delay = 0): self;
public function pending(): int;
public function exists(?int $jobId = null): bool;
public function get(?int $jobId = null): string;
public function update(string $newPayload, ?int $jobId = null): self;
public function remove(?int $jobId = null): self;
}

View File

@ -0,0 +1,65 @@
<?php
namespace Incoviba\Service\MQTT;
use Psr\Log\LoggerInterface;
use Pheanstalk as PBA;
use Incoviba\Service;
class Pheanstalk extends Service implements MQTTInterface
{
public function __construct(LoggerInterface $logger, protected PBA\Pheanstalk $client, string $tubeName = 'default')
{
parent::__construct($logger);
$this->tube = new PBA\Values\TubeName($tubeName);
}
protected PBA\Values\TubeName $tube;
public function set(string $payload, int $delay = 0): self
{
$this->client->useTube($this->tube);
$this->client->put($payload, $delay);
return $this;
}
public function pending(): int
{
$stats = $this->client->statsTube($this->tube);
return $stats->currentJobsReady;
}
public function exists(?int $jobId = null): bool
{
return $this->pending() > 0;
}
protected int $currentJobId;
public function get(?int $jobId = null): string
{
$this->client->watch($this->tube);
if ($jobId !== null) {
$jobId = new PBA\Values\JobId($jobId);
$job = $this->client->reserveJob($jobId);
} else {
$job = $this->client->reserve();
}
$this->currentJobId = $job->getId();
return $job->getData();
}
public function update(string $newPayload, ?int $jobId = null): self
{
$this->remove($jobId);
$this->set($newPayload);
return $this;
}
public function remove(?int $jobId = null): self
{
if ($jobId === null) {
$jobId = $this->currentJobId;
}
$this->client->watch($this->tube);
$this->client->delete(new PBA\Values\JobId($jobId));
return $this;
}
}

44
cli/src/Service/Redis.php Normal file
View File

@ -0,0 +1,44 @@
<?php
namespace Incoviba\Service;
use Exception;
use Predis\ClientInterface;
use Predis\Connection\ConnectionException;
class Redis
{
public function __construct(protected ClientInterface $client) {}
/**
* @param string $name
* @return string|null
* @throws Exception
*/
public function get(string $name): ?string
{
if (!$this->client->exists($name)) {
throw new Exception($name);
}
try {
return $this->client->get($name);
} catch (ConnectionException $exception) {
throw new Exception($exception->getMessage(), $exception->getCode(), $exception);
}
}
/**
* @param string $name
* @param mixed $value
* @param int $expirationTTL
* @return void
*/
public function set(string $name, mixed $value, int $expirationTTL = 60 * 60 * 24): void
{
$resolution = 'EX';
if ($expirationTTL === -1) {
$resolution = null;
$expirationTTL = null;
}
$this->client->set($name, $value, $resolution, $expirationTTL);
}
}

View File

@ -0,0 +1,66 @@
<?php
namespace Incoviba\Service;
use Cron\CronExpression;
use DateTimeInterface;
use DateTimeImmutable;
use Psr\Log\LoggerInterface;
use Throwable;
class Schedule
{
public function __construct(protected LoggerInterface $logger) {}
protected string $filename = '/var/spool/cron/crontabs/root';
public function getPending(): array
{
$now = new DateTimeImmutable();
$schedule = $this->getCommandList();
$commands = [];
foreach ($schedule as $line) {
$line = trim($line);
if (trim($line) === '' or str_starts_with($line, '#')) {
continue;
}
try {
$data = $this->parseCommandLine($line);
} catch (Throwable $exception) {
$this->logger->error($exception->getMessage(), ['line' => $line, 'exception' => $exception]);
continue;
}
if ($this->processSchedule($now, $data)) {
$commands[] = $data['command'];
}
}
return $commands;
}
protected function getCommandList(): array
{
if (!file_exists($this->filename)) {
return [];
}
return explode("\n", file_get_contents($this->filename));
}
protected function parseCommandLine(string $line): array
{
$regex = '/^(?<minutes>(\d{1,2}|\*|\*\/\d{1,2})?) (?<hours>(\d{1,2}|\*|\*\/\d{1,2})?) (?<day_month>(\d{1,2}|\*|\*\/\d{1,2})?) (?<month>(\d{1,2}|\*|\*\/\d{1,2})?) (?<day_week>(\d{1,2}|\*|\*\/\d{1,2})?) (?<command>.*)$/';
preg_match_all($regex, $line, $matches);
return [
'minutes' => $matches['minutes'][0],
'hours' => $matches['hours'][0],
'day_month' => $matches['day_month'][0],
'month' => $matches['month'][0],
'day_week' => $matches['day_week'][0],
'command' => trim(str_replace(['/code/bin/incoviba', '>> /logs/commands 2>&1'], '', $matches['command'][0])),
];
}
protected function processSchedule(DateTimeInterface $now, array $schedule): bool
{
$cronLine = "{$schedule['minutes']} {$schedule['hours']} {$schedule['day_month']} {$schedule['month']} {$schedule['day_week']}";
$cron = new CronExpression($cronLine);
return $cron->isDue($now);
}
}

View File

@ -0,0 +1,85 @@
<?php
namespace Incoviba\Service;
class SystemInfo
{
public function getAllInfo(): array
{
return [
'memory' => [
'usage' => $this->getMemoryUsage(),
'peak' => $this->getPeakMemoryUsage()
],
'cpu' => [
'usage' => $this->getCpuUsage(),
'last_15minutes' => $this->getCpuUsageLast15minutes(),
'cores' => $this->getCpuCores()
]
];
}
public function get(string $name): int|null|float
{
return match ($name) {
'memory' => $this->getMemoryUsage(),
'peak_memory' => $this->getPeakMemoryUsage(),
'cpu' => $this->getCpuUsage(),
'cpu_last_15minutes' => $this->getCpuUsageLast15minutes(),
'cpu_cores' => $this->getCpuCores(),
default => null
};
}
public function getMemoryUsage(): float
{
return memory_get_usage(true);
}
public function getPeakMemoryUsage(): float
{
return memory_get_peak_usage(true);
}
public function getCpuUsage(): float
{
return $this->getCpuLoad()[0];
}
public function getCpuUsageLast15minutes(): float
{
return $this->getCpuLoad()[1];
}
protected array $cpuLoad;
protected function getCpuLoad(): array
{
if (isset($this->cpuLoad)) {
$load = sys_getloadavg();
$cores = $this->getCpuCores();
array_walk($load, function (&$value) use ($cores) {
$value = $value / $cores;
});
$this->cpuLoad = $load;
unset($load);
}
return $this->cpuLoad;
}
protected function getCpuCores(): int
{
$cpu_cores = 1;
if (is_file('/proc/cpuinfo')) {
$cpuinfo = file('/proc/cpuinfo');
preg_match_all('/^processor/m', $cpuinfo, $matches);
$cpu_cores = count($matches[0]);
}
return $cpu_cores;
}
public function formatMemoryUsage(float $usage, string $unit = 'MB'): string
{
$sizeFactor = match ($unit) {
'MB' => 1024 * 1024,
'GB' => 1024 * 1024 * 1024,
default => 1
};
return number_format($usage / $sizeFactor, 2) . " {$unit}";
}
public function formatCpuLoad(float $load): string
{
return number_format($load * 100, 2) . '%';
}
}

5
cli/start_command Executable file
View File

@ -0,0 +1,5 @@
#!/usr/bin/bash
printenv >> /etc/environment
/code/bin/incoviba