...
 
Commits (4)
<?php
/**
* Features
*
* @author edgebal
*/
namespace Minds\Controllers\Cli;
use Minds\Cli;
use Minds\Core\Di\Di;
use Minds\Core\Features\Manager;
use Minds\Exceptions\CliException;
use Minds\Interfaces;
class Features extends Cli\Controller implements Interfaces\CliControllerInterface
{
/**
* @inheritDoc
*/
public function help($command = null)
{
$this->out('Syntax usage: cli features sync');
}
/**
* @inheritDoc
*/
public function exec()
{
return $this->help();
}
public function sync()
{
/** @var Manager $manager */
$manager = Di::_()->get('Features\Manager');
$ttl = $this->getOpt('ttl') ?: 300;
while (true /* Forever running task */) {
$this->out([date('c'), "TTL: {$ttl}"], static::OUTPUT_PRE);
foreach ($manager->sync($ttl) as $key => $output) {
$this->out(sprintf("Sync %s: %s", $key, $output));
}
if (!$this->getOpt('forever')) {
break;
}
$this->out("Done, sleeping {$ttl}s");
sleep($ttl);
}
}
}
......@@ -8,6 +8,7 @@
namespace Minds\Core\Features;
use Exception;
use Minds\Core\Di\Di;
use Minds\Core\Features\Exceptions\FeatureNotImplementedException;
use Minds\Core\Sessions\ActiveSession;
......@@ -47,6 +48,24 @@ class Manager
$this->featureKeys = ($features ?? Di::_()->get('Features\Keys')) ?: [];
}
/**
* Synchronizes all services using their respective mechanisms
* @param int $ttl
* @return iterable
*/
public function sync(int $ttl): iterable
{
foreach ($this->services as $service) {
try {
$output = $service->sync($ttl) ? 'OK' : 'NOT SYNC\'D';
} catch (Exception $e) {
$output = $e;
}
yield get_class($service) => $output;
}
}
/**
* Checks if a feature is enabled
* @param string $feature
......
......@@ -30,6 +30,15 @@ class Config extends BaseService
$this->config = $config ?: Di::_()->get('Config');
}
/**
* @inheritDoc
*/
public function sync(int $ttl): bool
{
// No need for sync
return true;
}
/**
* @inheritDoc
*/
......
......@@ -26,6 +26,15 @@ class Environment extends BaseService
return $this;
}
/**
* @inheritDoc
*/
public function sync(int $ttl): bool
{
// No need for sync
return true;
}
/**
* @inheritDoc
*/
......
......@@ -18,6 +18,13 @@ interface ServiceInterface
*/
public function setUser(?User $user): ServiceInterface;
/**
* Synchronizes and caches the service's schema/data, if needed
* @param int $ttl
* @return bool
*/
public function sync(int $ttl): bool;
/**
* Fetches the whole feature flag set
* @param string[] $keys Array of whitelisted keys
......
......@@ -7,11 +7,20 @@
namespace Minds\Core\Features\Services;
use Exception;
use Minds\Core\Config;
use Minds\Core\Di\Di;
use Minds\UnleashClient\Config as UnleashConfig;
use Minds\Core\Features\Services\Unleash\Entity;
use Minds\Core\Features\Services\Unleash\Repository;
use Minds\UnleashClient\Exceptions\InvalidFeatureImplementationException;
use Minds\UnleashClient\Exceptions\InvalidFeatureNameException;
use Minds\UnleashClient\Exceptions\InvalidFeaturesArrayException;
use Minds\UnleashClient\Exceptions\InvalidStrategyImplementationException;
use Minds\UnleashClient\Exceptions\NoContextException;
use Minds\UnleashClient\Factories\FeatureArrayFactory as UnleashFeatureArrayFactory;
use Minds\UnleashClient\Entities\Context;
use Minds\UnleashClient\Unleash as UnleashClient;
use Minds\UnleashClient\Unleash as UnleashResolver;
use Minds\UnleashClient\Http\Client as UnleashClient;
/**
* Unleash server (GitLab FF) feature flags service
......@@ -22,47 +31,79 @@ class Unleash extends BaseService
/** @var Config */
protected $config;
/** @var Repository */
protected $repository;
/** @var UnleashResolver */
protected $unleashResolver;
/** @var UnleashFeatureArrayFactory */
protected $unleashFeatureArrayFactory;
/** @var UnleashClient */
protected $unleash;
protected $unleashClient;
/**
* Unleash constructor.
* @param Config $config
* @param UnleashClient $unleash
* @param Repository $repository
* @param UnleashResolver $unleashResolver
* @param UnleashFeatureArrayFactory $unleashFeatureArrayFactory
* @param UnleashClient $unleashClient
*/
public function __construct(
$config = null,
$unleash = null
$repository = null,
$unleashResolver = null,
$unleashFeatureArrayFactory = null,
$unleashClient = null
) {
$this->config = $config ?: Di::_()->get('Config');
$this->unleash = $unleash ?: $this->initUnleashClient();
$this->repository = $repository ?: new Repository();
$this->unleashResolver = $unleashResolver ?: new UnleashResolver(Di::_()->get('Logger\Singleton'));
$this->unleashFeatureArrayFactory = $unleashFeatureArrayFactory ?: new UnleashFeatureArrayFactory();
$this->unleashClient = $unleashClient ?: (new Unleash\ClientFactory($this->config, Di::_()->get('Logger\Singleton')))->build();
}
/**
* Initializes Unleash client package
* @return UnleashClient
* @inheritDoc
* @throws Exception
*/
public function initUnleashClient(): UnleashClient
public function sync(int $ttl): bool
{
$configValues = $this->config->get('unleash');
$config = new UnleashConfig(
getenv('UNLEASH_API_URL') ?: ($configValues['apiUrl'] ?? null),
getenv('UNLEASH_INSTANCE_ID') ?: ($configValues['instanceId'] ?? null),
getenv('MINDS_ENV') ?: ($configValues['applicationName'] ?? 'development'),
$configValues['pollingIntervalSeconds'] ?? null,
$configValues['metricsIntervalSeconds'] ?? null
);
$registered = $this->unleashClient->register();
if (!$registered) {
throw new Exception('Could not register Unleash client');
}
$now = time();
$features = $this->unleashClient->fetch();
foreach ($features as $feature) {
$entity = new Entity();
$entity
->setId($feature['name'])
->setData($feature)
->setCreatedAt($now)
->setStaleAt($now + $ttl);
$logger = Di::_()->get('Logger\Singleton');
$cache = Di::_()->get('Cache\PsrWrapper');
$this->repository
->add($entity);
}
return new UnleashClient($config, $logger, null, $cache);
return true;
}
/**
* @inheritDoc
* @throws \Psr\SimpleCache\InvalidArgumentException
* @param array $keys
* @return array
* @throws InvalidFeatureImplementationException
* @throws InvalidFeatureNameException
* @throws InvalidFeaturesArrayException
* @throws InvalidStrategyImplementationException
* @throws NoContextException
*/
public function fetch(array $keys): array
{
......@@ -77,11 +118,21 @@ class Unleash extends BaseService
->setUserId((string) $this->user->guid);
}
// Read features from local repository
$features = $this->unleashFeatureArrayFactory
->build(
$this->repository
->getAllData()
->toArray()
);
// Return whitelisted 'features' array with its values resolved
return array_intersect_key(
$this->unleash
$this->unleashResolver
->setContext($context)
->setFeatures($features)
->export(),
array_flip($keys)
);
......
<?php
/**
* ClientFactory
*
* @author edgebal
*/
namespace Minds\Core\Features\Services\Unleash;
use Minds\Core\Config as MindsConfig;
use Minds\Core\Di\Di;
use Minds\Core\Log\Logger;
use Minds\UnleashClient\Http\Client;
use Minds\UnleashClient\Http\Config;
class ClientFactory
{
/** @var MindsConfig */
protected $config;
/** @var Logger */
protected $logger;
/**
* ClientFactory constructor.
* @param MindsConfig $config
* @param Logger $logger
*/
public function __construct(
$config = null,
$logger = null
) {
$this->config = $config ?: Di::_()->get('Config');
$this->logger = $logger ?: Di::_()->get('Logger\Singleton');
}
/**
* Builds an Unleash Client using environment configuration
* @return Client
*/
public function build(): Client
{
$configValues = $this->config->get('unleash');
$config = new Config(
getenv('UNLEASH_API_URL') ?: ($configValues['apiUrl'] ?? null),
getenv('UNLEASH_INSTANCE_ID') ?: ($configValues['instanceId'] ?? null),
getenv('MINDS_ENV') ?: ($configValues['applicationName'] ?? 'development'),
$configValues['pollingIntervalSeconds'] ?? null,
$configValues['metricsIntervalSeconds'] ?? null
);
return new Client($config, $this->logger);
}
}
<?php
/**
* Entity
*
* @author edgebal
*/
namespace Minds\Core\Features\Services\Unleash;
use Minds\Traits\MagicAttributes;
/**
* Entity for cached feature flags
* @package Minds\Core\Features\Services\Unleash
* @method string getId()
* @method Entity setId(string $id)
* @method array getData()
* @method Entity setData(array $data)
* @method int getCreatedAt()
* @method Entity setCreatedAt(int $createdAt)
* @method int getStaleAt()
* @method Entity setStaleAt(int $staleAt)
*/
class Entity
{
use MagicAttributes;
/** @var string */
protected $id;
/** @var array */
protected $data;
/** @var int */
protected $createdAt;
/** @var int */
protected $staleAt;
}
<?php
/**
* Repository
*
* @author edgebal
*/
namespace Minds\Core\Features\Services\Unleash;
use Cassandra\Timestamp;
use Exception;
use Minds\Common\Repository\Response;
use Minds\Core\Data\Cassandra\Client;
use Minds\Core\Data\Cassandra\Prepared\Custom;
use Minds\Core\Di\Di;
use Minds\Helpers\Log;
use NotImplementedException;
class Repository
{
/** @var Client */
protected $db;
/**
* Repository constructor.
* @param Client $db
*/
public function __construct(
$db = null
) {
$this->db = $db ?: Di::_()->get('Database\Cassandra\Cql');
}
/**
* Returns a list of all feature toggles cached in Cassandra
* @return Response
*/
public function getList(): Response
{
$cql = "SELECT * FROM feature_toggles_cache";
$prepared = new Custom();
$prepared->query($cql);
$response = new Response();
try {
$rows = $this->db->request($prepared);
foreach ($rows ?: [] as $row) {
$entity = new Entity();
$entity
->setId($row['id'])
->setData(json_decode($row['data'], true))
->setCreatedAt($row['created_at']->time())
->setStaleAt($row['stale_at']->time());
$response[] = $entity;
}
} catch (Exception $e) {
Log::warning($e);
}
return $response;
}
/**
* Shortcut method that casts all the data from getList() entities
* @return Response
*/
public function getAllData(): Response
{
return $this->getList()->map(function (Entity $entity) {
return $entity->getData();
});
}
/**
* Adds a new feature toggle entity to Cassandra
* @param Entity $entity
* @return bool
* @throws Exception
*/
public function add(Entity $entity): bool
{
if (!$entity->getId()) {
throw new Exception('Invalid Unleash entity name');
}
$cql = "INSERT INTO feature_toggles_cache (id, data, created_at, stale_at) VALUES (?, ?, ?, ?)";
$values = [
(string) $entity->getId(),
(string) json_encode($entity->getData()),
new Timestamp($entity->getCreatedAt()),
new Timestamp($entity->getStaleAt())
];
$prepared = new Custom();
$prepared->query($cql, $values);
try {
return (bool) $this->db->request($prepared, true);
} catch (Exception $e) {
Log::warning($e);
return false;
}
}
/**
* Shortcut to add
* @param Entity $entity
* @return bool
* @throws Exception
*/
public function update(Entity $entity): bool
{
return $this->add($entity);
}
/**
* Deletes an entity. Not implemented.
* @param string $id
* @return bool
* @throws NotImplementedException
*/
public function delete(string $id): bool
{
throw new NotImplementedException();
}
}
......@@ -1567,4 +1567,11 @@ CREATE TABLE minds.video_transcodes (
PRIMARY KEY (guid, profile_id)
);
ALTER TABLE minds.video_transcodes ADD failure_reason text;
\ No newline at end of file
ALTER TABLE minds.video_transcodes ADD failure_reason text;
CREATE TABLE minds.feature_toggles_cache (
id text PRIMARY KEY,
created_at timestamp,
data text,
stale_at timestamp
);
......@@ -2,13 +2,12 @@
namespace Spec\Minds\Core\Features;
use Minds\Core\Config;
use Minds\Core\Di\Di;
use Minds\Core\Features\Exceptions\FeatureNotImplementedException;
use Minds\Core\Features\Manager;
use Minds\Core\Features\Services\ServiceInterface;
use Minds\Core\Sessions\ActiveSession;
use Minds\Entities\User;
use PhpSpec\Exception\Example\FailureException;
use PhpSpec\ObjectBehavior;
class ManagerSpec extends ObjectBehavior
......@@ -43,6 +42,24 @@ class ManagerSpec extends ObjectBehavior
$this->shouldHaveType(Manager::class);
}
public function it_should_sync()
{
$this->service1->sync(30)
->shouldBeCalled()
->willReturn(true);
$this->service2->sync(30)
->shouldBeCalled()
->willReturn(false);
$this
->sync(30)
->shouldBeAnIterator([
get_class($this->service1->getWrappedObject()) => 'OK',
get_class($this->service2->getWrappedObject()) => 'NOT SYNC\'D',
]);
}
public function it_should_throw_during_has_if_a_feature_does_not_exist(
User $user
) {
......@@ -181,4 +198,25 @@ class ManagerSpec extends ObjectBehavior
'feature3' => false,
]);
}
public function getMatchers(): array
{
$matchers = [];
$matchers['beAnIterator'] = function ($subject, $elements = null) {
if (!is_iterable($subject)) {
throw new FailureException("Subject should be an iterable");
}
$resolvedSubject = iterator_to_array($subject);
if ($elements !== null && $elements !== $resolvedSubject) {
throw new FailureException("Subject elements don't match");
}
return true;
};
return $matchers;
}
}
......@@ -25,6 +25,13 @@ class ConfigSpec extends ObjectBehavior
$this->shouldHaveType(Config::class);
}
public function it_should_sync()
{
$this
->sync(30)
->shouldReturn(true);
}
public function it_should_fetch(
User $user1,
User $user2
......
......@@ -14,6 +14,13 @@ class EnvironmentSpec extends ObjectBehavior
$this->shouldHaveType(Environment::class);
}
public function it_should_sync()
{
$this
->sync(30)
->shouldReturn(true);
}
public function it_should_fetch(
User $user1,
User $user2
......
......@@ -2,11 +2,15 @@
namespace Spec\Minds\Core\Features\Services;
use Minds\Common\Repository\Response;
use Minds\Core\Config;
use Minds\Core\Features\Services\Unleash;
use Minds\Core\Features\Services\Unleash\Repository;
use Minds\Entities\User;
use Minds\UnleashClient\Entities\Context;
use Minds\UnleashClient\Unleash as UnleashClient;
use Minds\UnleashClient\Factories\FeatureArrayFactory as UnleashFeatureArrayFactory;
use Minds\UnleashClient\Http\Client as UnleashClient;
use Minds\UnleashClient\Unleash as UnleashResolver;
use PhpSpec\ObjectBehavior;
use Prophecy\Argument;
......@@ -15,16 +19,38 @@ class UnleashSpec extends ObjectBehavior
/** @var Config */
protected $config;
/** @var Repository */
protected $repository;
/** @var UnleashResolver */
protected $unleashResolver;
/** @var UnleashFeatureArrayFactory */
protected $unleashFeatureArrayFactory;
/** @var UnleashClient */
protected $unleash;
protected $unleashClient;
public function let(
Config $config,
UnleashClient $unleash
Repository $repository,
UnleashResolver $unleashResolver,
UnleashFeatureArrayFactory $unleashFeatureArrayFactory,
UnleashClient $unleashClient
) {
$this->config = $config;
$this->unleash = $unleash;
$this->beConstructedWith($config, $unleash);
$this->repository = $repository;
$this->unleashResolver = $unleashResolver;
$this->unleashFeatureArrayFactory = $unleashFeatureArrayFactory;
$this->unleashClient = $unleashClient;
$this->beConstructedWith(
$config,
$repository,
$unleashResolver,
$unleashFeatureArrayFactory,
$unleashClient
);
}
public function it_is_initializable()
......@@ -32,19 +58,60 @@ class UnleashSpec extends ObjectBehavior
$this->shouldHaveType(Unleash::class);
}
public function it_should_fetch()
public function it_should_sync()
{
$this->unleash->setContext(Argument::that(function (Context $context) {
$this->unleashClient->register()
->shouldBeCalled()
->willReturn(true);
$this->unleashClient->fetch()
->shouldBeCalled()
->willReturn([
['name' => 'feature1'],
['name' => 'feature2'],
]);
$this->repository->add(Argument::type(Unleash\Entity::class))
->shouldBeCalledTimes(2)
->willReturn(true);
$this
->sync(30)
->shouldReturn(true);
}
public function it_should_fetch(
Response $response
) {
$featuresMock = ['featuresMock' . mt_rand()];
$resolvedFeaturesMock = ['resolvedFeaturesMock' . mt_rand()];
$this->repository->getAllData()
->shouldBeCalled()
->willReturn($response);
$response->toArray()
->shouldBeCalled()
->willReturn($featuresMock);
$this->unleashFeatureArrayFactory->build($featuresMock)
->shouldBeCalled()
->willReturn($resolvedFeaturesMock);
$this->unleashResolver->setFeatures($resolvedFeaturesMock)
->shouldBeCalled()
->willReturn($this->unleashResolver);
$this->unleashResolver->setContext(Argument::that(function (Context $context) {
return
$context->getUserId() === null &&
$context->getUserGroups() === ['anonymous']
;
}))
->shouldBeCalled()
->willReturn($this->unleash);
->willReturn($this->unleashResolver);
$this->unleash->export()
$this->unleashResolver->export()
->shouldBeCalled()
->willReturn([
'feature1' => true,
......@@ -76,8 +143,28 @@ class UnleashSpec extends ObjectBehavior
}
public function it_should_fetch_with_user(
User $user
User $user,
Response $response
) {
$featuresMock = ['featuresMock' . mt_rand()];
$resolvedFeaturesMock = ['resolvedFeaturesMock' . mt_rand()];
$this->repository->getAllData()
->shouldBeCalled()
->willReturn($response);
$response->toArray()
->shouldBeCalled()
->willReturn($featuresMock);
$this->unleashFeatureArrayFactory->build($featuresMock)
->shouldBeCalled()
->willReturn($resolvedFeaturesMock);
$this->unleashResolver->setFeatures($resolvedFeaturesMock)
->shouldBeCalled()
->willReturn($this->unleashResolver);
$user->get('guid')
->shouldBeCalled()
->willReturn(1000);
......@@ -98,16 +185,16 @@ class UnleashSpec extends ObjectBehavior
->shouldBeCalled()
->willReturn(true);
$this->unleash->setContext(Argument::that(function (Context $context) {
$this->unleashResolver->setContext(Argument::that(function (Context $context) {
return
$context->getUserId() === '1000' &&
$context->getUserGroups() === ['authenticated', 'admin', 'canary', 'pro', 'plus']
;
}))
->shouldBeCalled()
->willReturn($this->unleash);
->willReturn($this->unleashResolver);
$this->unleash->export()
$this->unleashResolver->export()
->shouldBeCalled()
->willReturn([
'feature1' => true,
......
......@@ -38,7 +38,7 @@
"psr/http-server-middleware": "1.0.1",
"monolog/monolog": "1.25.2",
"psr/simple-cache": "1.0.1",
"minds/unleash-client-php": "0.1.1"
"minds/unleash-client-php": "0.1.2"
},
"repositories": [
{
......
This diff is collapsed.