mirror of
https://github.com/pelican-dev/panel.git
synced 2026-07-16 04:03:50 +03:00
add p:egg:normalize command (#2379)
This commit is contained in:
152
app/Console/Commands/Egg/NormalizeEggCommand.php
Normal file
152
app/Console/Commands/Egg/NormalizeEggCommand.php
Normal file
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands\Egg;
|
||||
|
||||
use App\Enums\EggFormat;
|
||||
use App\Exceptions\Service\InvalidFileUploadException;
|
||||
use App\Models\Egg;
|
||||
use App\Services\Eggs\Sharing\EggExporterService;
|
||||
use App\Services\Eggs\Sharing\EggImporterService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Console\Command;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
class NormalizeEggCommand extends Command
|
||||
{
|
||||
protected $description = 'Upgrades egg json/yaml to latest format as yaml';
|
||||
|
||||
protected $signature = 'p:egg:normalize {file} {--delete-original}';
|
||||
|
||||
public function __construct(
|
||||
private EggExporterService $exporter,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(EggImporterService $importer): int
|
||||
{
|
||||
$inputFile = $this->argument('file');
|
||||
$unparsed = file_get_contents($inputFile);
|
||||
if ($unparsed === false) {
|
||||
$this->error("Failed to read file: {$inputFile}");
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$extension = strtolower(pathinfo($inputFile, PATHINFO_EXTENSION));
|
||||
|
||||
$this->info("Importing {$inputFile}");
|
||||
|
||||
$format = match ($extension) {
|
||||
'yaml', 'yml' => EggFormat::YAML,
|
||||
'json' => EggFormat::JSON,
|
||||
default => null,
|
||||
};
|
||||
if (is_null($format)) {
|
||||
$this->error(" -> unsupported extension {$extension} for {$inputFile}");
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
try {
|
||||
$eggArray = $importer->parse($unparsed, $format);
|
||||
} catch (InvalidFileUploadException) {
|
||||
$this->error(" -> unsupported file version, is it actually an egg? ({$inputFile})");
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
if (
|
||||
!array_key_exists('meta', $eggArray) ||
|
||||
!is_array($eggArray['meta'])
|
||||
) {
|
||||
$this->error(" -> does not contain existing meta or meta is not array, is it actually an egg? ({$inputFile})");
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
if (!array_key_exists('exported_at', $eggArray)) {
|
||||
$this->error(" -> does not contain existing exported_at, is it actually an egg? ({$inputFile})");
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
if (
|
||||
!array_key_exists('update_url', $eggArray['meta']) ||
|
||||
!is_string($eggArray['meta']['update_url'])
|
||||
) {
|
||||
$this->error(" -> does not contain existing meta.update_url or is not a string, is it actually an egg? ({$inputFile})");
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// We upgraded our in-memory array when we imported, so set to latest version before export
|
||||
$eggArray['meta']['version'] = Egg::EXPORT_VERSION;
|
||||
$eggArray['meta']['update_url'] = self::replaceExtension($eggArray['meta']['update_url']);
|
||||
self::fixVariableRules($eggArray);
|
||||
|
||||
$outputFile = self::replaceExtension($inputFile);
|
||||
|
||||
if ($outputFile === $inputFile && !$this->hasFileChanged($unparsed, $eggArray)) {
|
||||
$this->info(' -> no changes required');
|
||||
|
||||
return Command::SUCCESS;
|
||||
} else {
|
||||
$eggArray['exported_at'] = Carbon::now()->toAtomString();
|
||||
}
|
||||
|
||||
$this->info(" -> exporting to {$outputFile}");
|
||||
$yaml = $this->eggToYaml($eggArray);
|
||||
if (file_put_contents($outputFile, $yaml) === false) {
|
||||
$this->error(" -> failed to write output file: {$outputFile}");
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
if ($this->option('delete-original') && $outputFile !== $inputFile) {
|
||||
$this->info(' -> deleting input file as requested');
|
||||
if (!unlink($inputFile)) {
|
||||
$this->warn(" -> failed to delete original file: {$inputFile}");
|
||||
}
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
private static function replaceExtension(string $path): string
|
||||
{
|
||||
return preg_replace('/^(.*\.)(?:yml|json|yaml)$/', '$1yaml', $path);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $eggArray
|
||||
*/
|
||||
private static function fixVariableRules(array &$eggArray): void
|
||||
{
|
||||
if (!array_key_exists('variables', $eggArray)) {
|
||||
return;
|
||||
}
|
||||
foreach ($eggArray['variables'] as &$var) {
|
||||
unset($var['field_type']);
|
||||
if (!array_key_exists('rules', $var)) {
|
||||
continue;
|
||||
}
|
||||
$var['rules'] = is_array($var['rules']) ? $var['rules'] : explode('|', $var['rules']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $eggArray
|
||||
*/
|
||||
private function hasFileChanged(string $unparsed, array $eggArray): bool
|
||||
{
|
||||
$yaml = $this->eggToYaml($eggArray);
|
||||
|
||||
return $unparsed !== $yaml;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $eggArray
|
||||
*/
|
||||
private function eggToYaml(array $eggArray): string
|
||||
{
|
||||
return Yaml::dump($this->exporter->yamlExport($eggArray), 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK | Yaml::DUMP_OBJECT_AS_MAP);
|
||||
}
|
||||
}
|
||||
@@ -78,7 +78,7 @@ class EggExporterService
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function yamlExport(mixed $data): mixed
|
||||
public function yamlExport(mixed $data): mixed
|
||||
{
|
||||
if ($data instanceof Collection) {
|
||||
$data = $data->all();
|
||||
|
||||
@@ -146,7 +146,7 @@ class EggImporterService
|
||||
*
|
||||
* @throws InvalidFileUploadException|JsonException
|
||||
*/
|
||||
protected function parse(string $content, EggFormat $format): array
|
||||
public function parse(string $content, EggFormat $format): array
|
||||
{
|
||||
try {
|
||||
$parsed = match ($format) {
|
||||
|
||||
168
tests/Unit/Console/Commands/Egg/NormalizerEggCommandTest.php
Normal file
168
tests/Unit/Console/Commands/Egg/NormalizerEggCommandTest.php
Normal file
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Unit\Console\Commands\Egg {
|
||||
use App\Tests\TestCase;
|
||||
use Carbon\Carbon;
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Testing\PendingCommand;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
use function PHPUnit\Framework\assertFileDoesNotExist;
|
||||
use function PHPUnit\Framework\assertFileEquals;
|
||||
use function PHPUnit\Framework\assertFileExists;
|
||||
|
||||
class NormalizeEggCommandTest extends TestCase
|
||||
{
|
||||
private ?string $tmpdir = null;
|
||||
|
||||
private ?CarbonInterface $origTime = null;
|
||||
|
||||
public function test_upgrade_ptdlv2_to_plcnv3(): void
|
||||
{
|
||||
$orig = $this->tmpdir . '/egg-ptdlv2.json';
|
||||
$this->copyExampleFile('egg-ptdlv2.json');
|
||||
$this->runCommand($orig)
|
||||
->expectsOutputToContain(' -> exporting to ')
|
||||
->run();
|
||||
|
||||
assertFileEquals(
|
||||
dirname(__FILE__) . '/egg-plcnv3-example.yaml',
|
||||
substr($orig, 0, -4) . 'yaml',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @depends test_upgrade_ptdlv2_to_plcnv3
|
||||
*/
|
||||
public function test_idempotency(): void
|
||||
{
|
||||
$orig = $this->tmpdir . '/egg-ptdlv2.json';
|
||||
$this->copyExampleFile('egg-ptdlv2.json');
|
||||
$this->runCommand($orig)
|
||||
->expectsOutputToContain(' -> exporting to ')
|
||||
->run();
|
||||
|
||||
$newFile = substr($orig, 0, -4) . 'yaml';
|
||||
assertFileExists($newFile);
|
||||
|
||||
copy($newFile, $this->tmpdir . '/copy.yaml');
|
||||
|
||||
// turn off datetime mocking (i.e. get a new datetime)
|
||||
// so we can ensure a new datetime is only written to the file
|
||||
// if the content has changed
|
||||
Carbon::setTestNow(null);
|
||||
|
||||
$this->runCommand($newFile)
|
||||
->expectsOutputToContain(' -> no changes required')
|
||||
->doesntExpectOutputToContain(' -> exporting to ')
|
||||
->run();
|
||||
|
||||
assertFileEquals(
|
||||
$newFile,
|
||||
$this->tmpdir . '/copy.yaml',
|
||||
);
|
||||
}
|
||||
|
||||
public function test_delete_original(): void
|
||||
{
|
||||
$orig = $this->tmpdir . '/egg-ptdlv2.json';
|
||||
$this->copyExampleFile('egg-ptdlv2.json');
|
||||
|
||||
$this->runCommand(' --delete-original ' . $orig)
|
||||
->expectsOutputToContain(' -> exporting to ')
|
||||
->expectsOutputToContain(' -> deleting input file as requested')
|
||||
->run();
|
||||
|
||||
$newFile = substr($orig, 0, -4) . 'yaml';
|
||||
|
||||
assertFileExists($newFile);
|
||||
assertFileDoesNotExist($orig);
|
||||
}
|
||||
|
||||
public function test_dont_delete_if_error(): void
|
||||
{
|
||||
$orig = $this->tmpdir . '/egg-ptdlv2.json';
|
||||
$this->copyExampleFile('egg-ptdlv2.json');
|
||||
|
||||
$GLOBALS['SHOULD_MOCK_FILE_PUT'] = true;
|
||||
|
||||
$this->artisan('p:egg:normalize --delete-original ' . $orig)
|
||||
->expectsOutputToContain('Importing ')
|
||||
->expectsOutputToContain(' -> failed to write output file')
|
||||
->assertFailed()
|
||||
->run();
|
||||
|
||||
unset($GLOBALS['SHOULD_MOCK_FILE_PUT']);
|
||||
|
||||
$newFile = substr($orig, 0, -4) . 'yaml';
|
||||
|
||||
assertFileDoesNotExist($newFile);
|
||||
assertFileExists($orig);
|
||||
}
|
||||
|
||||
private function runCommand(string $arguments): PendingCommand
|
||||
{
|
||||
return $this->artisan('p:egg:normalize ' . $arguments)
|
||||
->expectsOutputToContain('Importing ')
|
||||
->assertSuccessful();
|
||||
}
|
||||
|
||||
private function copyExampleFile(string $filename): void
|
||||
{
|
||||
if (!copy(
|
||||
dirname(__FILE__) . '/' . $filename,
|
||||
$this->tmpdir . '/' . $filename)
|
||||
) {
|
||||
throw new \Exception('unable to copy json file to tmpdir');
|
||||
}
|
||||
}
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
register_shutdown_function([$this, 'shutdownHandler']);
|
||||
|
||||
$this->tmpdir = sys_get_temp_dir() . '/' . Uuid::v4()->toRfc4122();
|
||||
if (!mkdir($this->tmpdir)) {
|
||||
throw new \Exception('unable to create tmp dir');
|
||||
}
|
||||
|
||||
// save the datetime set by the test framework to restore it after out tests
|
||||
$this->origTime = Carbon::getTestNow();
|
||||
Carbon::setTestNow(Carbon::createFromDate(2026, 06, 01)->setTime(01, 02, 03));
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
parent::tearDown();
|
||||
|
||||
Carbon::setTestNow($this->origTime);
|
||||
|
||||
if (!is_null($this->tmpdir)) {
|
||||
foreach (glob("{$this->tmpdir}/*") as $filename) {
|
||||
unlink($filename);
|
||||
}
|
||||
rmdir($this->tmpdir);
|
||||
}
|
||||
}
|
||||
|
||||
public function shutdownHandler(): void
|
||||
{
|
||||
// try and delete our tmp files to leave everything clean
|
||||
error_get_last() && $this->tearDown();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
namespace App\Console\Commands\Egg {
|
||||
/** @phpstan-ignore missingType.parameter */
|
||||
function file_put_contents(string $filename, mixed $data, int $flags = 0, $context = null): int|false
|
||||
{
|
||||
if (($GLOBALS['SHOULD_MOCK_FILE_PUT'] ?? false) === true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return \file_put_contents($filename, $data, $flags, $context);
|
||||
}
|
||||
}
|
||||
54
tests/Unit/Console/Commands/Egg/egg-plcnv3-example.yaml
Normal file
54
tests/Unit/Console/Commands/Egg/egg-plcnv3-example.yaml
Normal file
@@ -0,0 +1,54 @@
|
||||
_comment: 'DO NOT EDIT: FILE GENERATED AUTOMATICALLY BY PANEL'
|
||||
meta:
|
||||
version: PLCN_v3
|
||||
update_url: 'https://raw.githubusercontent.com/pelican-dev/panel/refs/heads/main/tests/Unit/Console/Command/Egg/egg-ptdlv2.yaml'
|
||||
exported_at: '2026-06-01T01:02:03+00:00'
|
||||
name: 'Egg Example'
|
||||
author: example@example.com
|
||||
uuid: 21f1c702-953a-45ca-ae58-c205e8a4ea7e
|
||||
description: 'Egg for test purposes'
|
||||
tags:
|
||||
- test
|
||||
features:
|
||||
- eula
|
||||
- java_version
|
||||
- pid_limit
|
||||
docker_images:
|
||||
'ghcr.io/pelican-eggs/yolks:java_8': 'ghcr.io/pelican-eggs/yolks:java_8'
|
||||
'ghcr.io/pelican-eggs/yolks:java_11': 'ghcr.io/pelican-eggs/yolks:java_11'
|
||||
'ghcr.io/pelican-eggs/yolks:java_16': 'ghcr.io/pelican-eggs/yolks:java_16'
|
||||
'ghcr.io/pelican-eggs/yolks:java_17': 'ghcr.io/pelican-eggs/yolks:java_17'
|
||||
file_denylist: { }
|
||||
config:
|
||||
files:
|
||||
server.properties:
|
||||
parser: properties
|
||||
find:
|
||||
server-ip: 0.0.0.0
|
||||
server-port: '{{server.allocations.default.port}}'
|
||||
startup:
|
||||
done: ')! For help, type '
|
||||
logs: { }
|
||||
stop: stop
|
||||
scripts:
|
||||
installation:
|
||||
script: |-
|
||||
#!/bin/ash
|
||||
echo test some escaped backslashes (/), then proper backslashes (/)
|
||||
container: 'ghcr.io/pelican-eggs/installers:alpine'
|
||||
entrypoint: ash
|
||||
variables:
|
||||
-
|
||||
name: 'Modpack Version'
|
||||
description: ''
|
||||
env_variable: MODPACK_VERSION
|
||||
default_value: 1.0.0
|
||||
user_viewable: true
|
||||
user_editable: true
|
||||
rules:
|
||||
- required
|
||||
- string
|
||||
- 'max:20'
|
||||
sort: null
|
||||
startup_commands:
|
||||
Default: 'java -Xms128M -Xmx{{SERVER_MEMORY}}M -jar minecraft.jar'
|
||||
54
tests/Unit/Console/Commands/Egg/egg-ptdlv2.json
Normal file
54
tests/Unit/Console/Commands/Egg/egg-ptdlv2.json
Normal file
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"_comment": "DO NOT EDIT: FILE GENERATED AUTOMATICALLY BY PANEL",
|
||||
"meta": {
|
||||
"version": "PTDL_v2",
|
||||
"update_url": "https://raw.githubusercontent.com/pelican-dev/panel/refs/heads/main/tests/Unit/Console/Command/Egg/egg-ptdlv2.json"
|
||||
},
|
||||
"exported_at": "2026-06-01T01:02:03+00:00",
|
||||
"name": "Egg Example",
|
||||
"author": "example@example.com",
|
||||
"uuid": "21f1c702-953a-45ca-ae58-c205e8a4ea7e",
|
||||
"description": "Egg for test purposes",
|
||||
"tags": [
|
||||
"test"
|
||||
],
|
||||
"features": [
|
||||
"eula",
|
||||
"java_version",
|
||||
"pid_limit"
|
||||
],
|
||||
"docker_images": {
|
||||
"ghcr.io\/pelican-eggs\/yolks:java_8": "ghcr.io\/pelican-eggs\/yolks:java_8",
|
||||
"ghcr.io\/pelican-eggs\/yolks:java_11": "ghcr.io\/pelican-eggs\/yolks:java_11",
|
||||
"ghcr.io\/pelican-eggs\/yolks:java_16": "ghcr.io\/pelican-eggs\/yolks:java_16",
|
||||
"ghcr.io\/pelican-eggs\/yolks:java_17": "ghcr.io\/pelican-eggs\/yolks:java_17"
|
||||
},
|
||||
"file_denylist": [],
|
||||
"startup": "java -Xms128M -Xmx{{SERVER_MEMORY}}M -jar minecraft.jar",
|
||||
"config": {
|
||||
"files": "{\r\n \"server.properties\": {\r\n \"parser\": \"properties\",\r\n \"find\": {\r\n \"server-ip\": \"0.0.0.0\",\r\n \"server-port\": \"{{server.build.default.port}}\"\r\n }\r\n }\r\n}",
|
||||
"startup": "{\r\n \"done\": \")! For help, type \"\r\n}",
|
||||
"logs": "{}",
|
||||
"stop": "stop"
|
||||
},
|
||||
"scripts": {
|
||||
"installation": {
|
||||
"script": "#!\/bin\/ash\r\necho test some escaped backslashes (\/), then proper backslashes (/)",
|
||||
"container": "ghcr.io\/pelican-eggs\/installers:alpine",
|
||||
"entrypoint": "ash"
|
||||
}
|
||||
},
|
||||
"variables": [
|
||||
{
|
||||
"name": "Modpack Version",
|
||||
"description": "",
|
||||
"env_variable": "MODPACK_VERSION",
|
||||
"default_value": "1.0.0",
|
||||
"user_viewable": true,
|
||||
"user_editable": true,
|
||||
"rules": "required|string|max:20",
|
||||
"sort": null,
|
||||
"field_type": "text"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user