mirror of
https://github.com/nextcloud/server.git
synced 2025-02-25 09:20:16 +00:00
feat(upgrade): release metadata
Signed-off-by: Maxence Lange <maxence@artificial-owl.com>
This commit is contained in:
parent
88cfab4f32
commit
79e6014879
5 changed files with 287 additions and 124 deletions
core/Command/Db/Migrations
lib/private
|
@ -8,23 +8,21 @@ declare(strict_types=1);
|
|||
*/
|
||||
namespace OC\Core\Command\Db\Migrations;
|
||||
|
||||
use OC\DB\Connection;
|
||||
use OC\DB\MigrationService;
|
||||
use OC\Migration\MetadataManager;
|
||||
use OCP\App\IAppManager;
|
||||
use ReflectionClass;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
class GenerateMetadataCommand extends Command {
|
||||
public function __construct(
|
||||
private readonly Connection $connection,
|
||||
private readonly MetadataManager $metadataManager,
|
||||
private readonly IAppManager $appManager,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure() {
|
||||
protected function configure(): void {
|
||||
$this->setName('migrations:generate-metadata')
|
||||
->setHidden(true)
|
||||
->setDescription('Generate metadata from DB migrations - internal and should not be used');
|
||||
|
@ -45,7 +43,9 @@ class GenerateMetadataCommand extends Command {
|
|||
return 0;
|
||||
}
|
||||
|
||||
private function extractMigrationMetadata(): array {
|
||||
|
||||
|
||||
public function extractMigrationMetadata(): array {
|
||||
return [
|
||||
'core' => $this->extractMigrationMetadataFromCore(),
|
||||
'apps' => $this->extractMigrationMetadataFromApps()
|
||||
|
@ -53,7 +53,7 @@ class GenerateMetadataCommand extends Command {
|
|||
}
|
||||
|
||||
private function extractMigrationMetadataFromCore(): array {
|
||||
return $this->extractMigrationAttributes('core');
|
||||
return $this->metadataManager->extractMigrationAttributes('core');
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -72,35 +72,11 @@ class GenerateMetadataCommand extends Command {
|
|||
if (!$alreadyLoaded) {
|
||||
$this->appManager->loadApp($appId);
|
||||
}
|
||||
$metadata[$appId] = $this->extractMigrationAttributes($appId);
|
||||
$metadata[$appId] = $this->metadataManager->extractMigrationAttributes($appId);
|
||||
if (!$alreadyLoaded) {
|
||||
$this->appManager->disableApp($appId);
|
||||
}
|
||||
}
|
||||
return $metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* We get all migrations from an app, and for each migration we extract attributes
|
||||
*
|
||||
* @param string $appId
|
||||
*
|
||||
* @return array
|
||||
* @throws \Exception
|
||||
*/
|
||||
private function extractMigrationAttributes(string $appId): array {
|
||||
$ms = new MigrationService($appId, $this->connection);
|
||||
|
||||
$metadata = [];
|
||||
foreach($ms->getAvailableVersions() as $version) {
|
||||
$metadata[$version] = [];
|
||||
$class = new ReflectionClass($ms->createInstance($version));
|
||||
$attributes = $class->getAttributes();
|
||||
foreach ($attributes as $attribute) {
|
||||
$metadata[$version][] = $attribute->newInstance();
|
||||
}
|
||||
}
|
||||
|
||||
return $metadata;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,12 +8,9 @@ declare(strict_types=1);
|
|||
*/
|
||||
namespace OC\Core\Command\Db\Migrations;
|
||||
|
||||
use OC\DB\Connection;
|
||||
use OC\DB\MigrationService;
|
||||
use OCP\Migration\Attributes\GenericMigrationAttribute;
|
||||
use OC\Migration\MetadataManager;
|
||||
use OC\Updater\ReleaseMetadata;
|
||||
use OCP\Migration\Attributes\MigrationAttribute;
|
||||
use OCP\Migration\Exceptions\AttributeException;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Helper\Table;
|
||||
use Symfony\Component\Console\Helper\TableCell;
|
||||
|
@ -24,14 +21,15 @@ use Symfony\Component\Console\Input\InputInterface;
|
|||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
class PreviewCommand extends Command {
|
||||
private bool $initiated = false;
|
||||
public function __construct(
|
||||
private readonly Connection $connection,
|
||||
private readonly LoggerInterface $logger,
|
||||
private readonly MetadataManager $metadataManager,
|
||||
private readonly ReleaseMetadata $releaseMetadata,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure() {
|
||||
protected function configure(): void {
|
||||
$this
|
||||
->setName('migrations:preview')
|
||||
->setDescription('Get preview of available DB migrations in case of initiating an upgrade')
|
||||
|
@ -42,21 +40,37 @@ class PreviewCommand extends Command {
|
|||
|
||||
public function execute(InputInterface $input, OutputInterface $output): int {
|
||||
$version = $input->getArgument('version');
|
||||
if (filter_var($version, FILTER_VALIDATE_URL)) {
|
||||
$metadata = $this->releaseMetadata->downloadMetadata($version);
|
||||
} elseif (str_starts_with($version, '/')) {
|
||||
$metadata = json_decode(file_get_contents($version), true, flags: JSON_THROW_ON_ERROR);
|
||||
} else {
|
||||
$metadata = $this->releaseMetadata->getMetadata($version);
|
||||
}
|
||||
|
||||
$metadata = $this->getMetadata($version);
|
||||
$parsed = $this->getMigrationsAttributes($metadata);
|
||||
$parsed = $this->metadataManager->getMigrationsAttributesFromReleaseMetadata($metadata['migrations'] ?? [], true);
|
||||
|
||||
$table = new Table($output);
|
||||
$this->displayMigrations($table, 'core', $parsed['core']);
|
||||
|
||||
$this->displayMigrations($table, 'core', $parsed['core'] ?? []);
|
||||
foreach ($parsed['apps'] as $appId => $migrations) {
|
||||
if (!empty($migrations)) {
|
||||
$this->displayMigrations($table, $appId, $migrations);
|
||||
}
|
||||
}
|
||||
$table->render();
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function displayMigrations(Table $table, string $appId, array $data): void {
|
||||
$done = $this->getDoneMigrations($appId);
|
||||
$done = array_diff($done, ['30000Date20240429122720']);
|
||||
if (empty($data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->initiated) {
|
||||
$table->addRow(new TableSeparator());
|
||||
}
|
||||
$this->initiated = true;
|
||||
|
||||
$table->addRow(
|
||||
[
|
||||
|
@ -70,13 +84,9 @@ class PreviewCommand extends Command {
|
|||
]
|
||||
)->addRow(new TableSeparator());
|
||||
|
||||
/** @var MigrationAttribute[] $attributes */
|
||||
foreach($data as $migration => $attributes) {
|
||||
if (in_array($migration, $done)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$attributesStr = [];
|
||||
/** @var MigrationAttribute[] $attributes */
|
||||
foreach($attributes as $attribute) {
|
||||
$definition = '<info>' . $attribute->definition() . "</info>";
|
||||
$definition .= empty($attribute->getDescription()) ? '' : "\n " . $attribute->getDescription();
|
||||
|
@ -85,78 +95,5 @@ class PreviewCommand extends Command {
|
|||
}
|
||||
$table->addRow([$migration, implode("\n", $attributesStr)]);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
private function getMetadata(string $version): array {
|
||||
$metadata = json_decode(file_get_contents('/tmp/nextcloud-' . $version . '.metadata'), true);
|
||||
if (!$metadata) {
|
||||
throw new \Exception();
|
||||
}
|
||||
return $metadata['migrations'] ?? [];
|
||||
}
|
||||
|
||||
private function getDoneMigrations(string $appId): array {
|
||||
$ms = new MigrationService($appId, $this->connection);
|
||||
return $ms->getMigratedVersions();
|
||||
}
|
||||
|
||||
private function getMigrationsAttributes(array $metadata): array {
|
||||
$appsAttributes = [];
|
||||
foreach (array_keys($metadata['apps']) as $appId) {
|
||||
$appsAttributes[$appId] = $this->parseMigrations($metadata['apps'][$appId] ?? []);
|
||||
}
|
||||
|
||||
return [
|
||||
'core' => $this->parseMigrations($metadata['core'] ?? []),
|
||||
'apps' => $appsAttributes
|
||||
];
|
||||
}
|
||||
|
||||
private function parseMigrations(array $migrations): array {
|
||||
$parsed = [];
|
||||
foreach (array_keys($migrations) as $entry) {
|
||||
$items = $migrations[$entry];
|
||||
$parsed[$entry] = [];
|
||||
foreach ($items as $item) {
|
||||
try {
|
||||
$parsed[$entry][] = $this->createAttribute($item);
|
||||
} catch (AttributeException $e) {
|
||||
$this->logger->warning(
|
||||
'exception while trying to create attribute',
|
||||
['exception' => $e, 'item' => json_encode($item)]
|
||||
);
|
||||
$parsed[$entry][] = new GenericMigrationAttribute($item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $item
|
||||
*
|
||||
* @return MigrationAttribute|null
|
||||
* @throws AttributeException
|
||||
*/
|
||||
private function createAttribute(array $item): ?MigrationAttribute {
|
||||
$class = $item['class'] ?? '';
|
||||
$namespace = 'OCP\Migration\Attributes\\';
|
||||
if (!str_starts_with($class, $namespace)
|
||||
|| !ctype_alpha(substr($class, strlen($namespace)))) {
|
||||
throw new AttributeException('class name does not looks valid');
|
||||
}
|
||||
|
||||
try {
|
||||
$attribute = new $class();
|
||||
return $attribute->import($item);
|
||||
} catch (\Error) {
|
||||
throw new AttributeException('cannot import Attribute');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
153
lib/private/Migration/MetadataManager.php
Normal file
153
lib/private/Migration/MetadataManager.php
Normal file
|
@ -0,0 +1,153 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
namespace OC\Migration;
|
||||
|
||||
use OC\DB\Connection;
|
||||
use OC\DB\MigrationService;
|
||||
use OCP\App\IAppManager;
|
||||
use OCP\Migration\Attributes\GenericMigrationAttribute;
|
||||
use OCP\Migration\Attributes\MigrationAttribute;
|
||||
use OCP\Migration\Exceptions\AttributeException;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use ReflectionClass;
|
||||
|
||||
/**
|
||||
* Helps managing DB Migrations' Metadata
|
||||
*
|
||||
* @since 30.0.0
|
||||
*/
|
||||
class MetadataManager {
|
||||
public function __construct(
|
||||
private readonly IAppManager $appManager,
|
||||
private readonly Connection $connection,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* We get all migrations from an app (or 'core'), and
|
||||
* for each migration files we extract its attributes
|
||||
*
|
||||
* @param string $appId
|
||||
*
|
||||
* @return array
|
||||
* @since 30.0.0
|
||||
*/
|
||||
public function extractMigrationAttributes(string $appId): array {
|
||||
$ms = new MigrationService($appId, $this->connection);
|
||||
|
||||
$metadata = [];
|
||||
foreach($ms->getAvailableVersions() as $version) {
|
||||
$metadata[$version] = [];
|
||||
$class = new ReflectionClass($ms->createInstance($version));
|
||||
$attributes = $class->getAttributes();
|
||||
foreach ($attributes as $attribute) {
|
||||
$metadata[$version][] = $attribute->newInstance();
|
||||
}
|
||||
}
|
||||
|
||||
return $metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* convert direct data from release metadata into a list of Migrations' Attribute
|
||||
*
|
||||
* @param array $metadata
|
||||
* @param bool $filterKnownMigrations ignore metadata already done in local instance
|
||||
*
|
||||
* @return array
|
||||
* @since 30.0.0
|
||||
*/
|
||||
public function getMigrationsAttributesFromReleaseMetadata(
|
||||
array $metadata,
|
||||
bool $filterKnownMigrations = false
|
||||
): array {
|
||||
$appsAttributes = [];
|
||||
foreach (array_keys($metadata['apps']) as $appId) {
|
||||
if ($filterKnownMigrations && !$this->appManager->isInstalled($appId)) {
|
||||
continue; // if not interested and app is not installed
|
||||
}
|
||||
$done = ($filterKnownMigrations) ? $this->getKnownMigrations($appId) : [];
|
||||
$appsAttributes[$appId] = $this->parseMigrations($metadata['apps'][$appId] ?? [], $done);
|
||||
}
|
||||
|
||||
$done = ($filterKnownMigrations) ? $this->getKnownMigrations('core') : [];
|
||||
return [
|
||||
'core' => $this->parseMigrations($metadata['core'] ?? [], $done),
|
||||
'apps' => $appsAttributes
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* convert raw data to a list of MigrationAttribute
|
||||
*
|
||||
* @param array $migrations
|
||||
* @param array $ignoreMigrations
|
||||
*
|
||||
* @return array<string, MigrationAttribute[]>
|
||||
*/
|
||||
private function parseMigrations(array $migrations, array $ignoreMigrations = []): array {
|
||||
$parsed = [];
|
||||
foreach (array_keys($migrations) as $entry) {
|
||||
if (in_array($entry, $ignoreMigrations)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$parsed[$entry] = [];
|
||||
foreach ($migrations[$entry] as $item) {
|
||||
try {
|
||||
$parsed[$entry][] = $this->createAttribute($item);
|
||||
} catch (AttributeException $e) {
|
||||
$this->logger->warning('exception while trying to create attribute', ['exception' => $e, 'item' => json_encode($item)]);
|
||||
$parsed[$entry][] = new GenericMigrationAttribute($item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* returns migrations already done
|
||||
*
|
||||
* @param string $appId
|
||||
*
|
||||
* @return array
|
||||
* @throws \Exception
|
||||
*/
|
||||
private function getKnownMigrations(string $appId): array {
|
||||
$ms = new MigrationService($appId, $this->connection);
|
||||
return $ms->getMigratedVersions();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* generate (deserialize) a MigrationAttribute from a serialized version
|
||||
*
|
||||
* @param array $item
|
||||
*
|
||||
* @return MigrationAttribute
|
||||
* @throws AttributeException
|
||||
*/
|
||||
private function createAttribute(array $item): MigrationAttribute {
|
||||
$class = $item['class'] ?? '';
|
||||
$namespace = 'OCP\Migration\Attributes\\';
|
||||
if (!str_starts_with($class, $namespace)
|
||||
|| !ctype_alpha(substr($class, strlen($namespace)))) {
|
||||
throw new AttributeException('class name does not looks valid');
|
||||
}
|
||||
|
||||
try {
|
||||
$attribute = new $class();
|
||||
return $attribute->import($item);
|
||||
} catch (\Error) {
|
||||
throw new AttributeException('cannot import Attribute');
|
||||
}
|
||||
}
|
||||
}
|
17
lib/private/Updater/Exceptions/ReleaseMetadataException.php
Normal file
17
lib/private/Updater/Exceptions/ReleaseMetadataException.php
Normal file
|
@ -0,0 +1,17 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
namespace OC\Updater\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
||||
/**
|
||||
* @since 30.0.0
|
||||
*/
|
||||
class ReleaseMetadataException extends Exception {
|
||||
}
|
80
lib/private/Updater/ReleaseMetadata.php
Normal file
80
lib/private/Updater/ReleaseMetadata.php
Normal file
|
@ -0,0 +1,80 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
namespace OC\Updater;
|
||||
|
||||
use Exception;
|
||||
use JsonException;
|
||||
use OC\Updater\Exceptions\ReleaseMetadataException;
|
||||
use OCP\Http\Client\IClientService;
|
||||
|
||||
/** retrieve releases metadata from official servers
|
||||
*
|
||||
* @since 30.0.0
|
||||
*/
|
||||
class ReleaseMetadata {
|
||||
public function __construct(
|
||||
private readonly IClientService $clientService,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* returns metadata based on release version
|
||||
*
|
||||
* - version is a stable release, metadata is downloaded from official releases folder
|
||||
* - version is not a table release, metadata is downloaded from official prereleases folder
|
||||
* - version is a major version (30, 31, 32, ...), latest metadata are downloaded
|
||||
*
|
||||
* @param string $version
|
||||
*
|
||||
* @return array
|
||||
* @throws ReleaseMetadataException
|
||||
* @since 30.0.0
|
||||
*/
|
||||
public function getMetadata(string $version): array {
|
||||
if (!str_contains($version, '.')) {
|
||||
$url = 'https://download.nextcloud.com/server/releases/latest-' . $version . '.metadata';
|
||||
} else {
|
||||
[,,$minor] = explode('.', $version);
|
||||
if (ctype_digit($minor)) {
|
||||
$url = 'https://download.nextcloud.com/server/releases/nextcloud-' . $version . '.metadata';
|
||||
} else {
|
||||
$url = 'https://download.nextcloud.com/server/prereleases/nextcloud-' . $version . '.metadata';
|
||||
}
|
||||
}
|
||||
return $this->downloadMetadata($url);
|
||||
}
|
||||
|
||||
/**
|
||||
* download Metadata from a link
|
||||
*
|
||||
* @param string $url
|
||||
*
|
||||
* @return array
|
||||
* @throws ReleaseMetadataException
|
||||
* @since 30.0.0
|
||||
*/
|
||||
public function downloadMetadata(string $url): array {
|
||||
$client = $this->clientService->newClient();
|
||||
try {
|
||||
$response = $client->get($url, [
|
||||
'timeout' => 10,
|
||||
'connect_timeout' => 10,
|
||||
'verify' => false,
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
throw new ReleaseMetadataException('could not reach metadata at ' . $url, previous: $e);
|
||||
}
|
||||
|
||||
try {
|
||||
return json_decode($response->getBody(), true, flags: JSON_THROW_ON_ERROR);
|
||||
} catch (JsonException) {
|
||||
throw new ReleaseMetadataException('remote document is not valid');
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue