Skip to content

Commit

Permalink
Add command to build standalone binary
Browse files Browse the repository at this point in the history
  • Loading branch information
tigitz committed Jan 31, 2024
1 parent ff79d5b commit 34ab073
Show file tree
Hide file tree
Showing 6 changed files with 288 additions and 29 deletions.
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,14 @@ jobs:
- name: Install PHPUnit
run: vendor/bin/simple-phpunit install

# Speed up the CompileCommand test
- name: Cache PHP static building artifacts
uses: actions/cache@v4
with:
path: |
/tmp/castor-php-static-compiler
key: php-static-build-cache-${{ hashFiles('src/Console/Command/CompileCommand.php', 'tests/CompileCommandTest.php') }}

- name: Run tests
run: vendor/bin/simple-phpunit

Expand Down
2 changes: 2 additions & 0 deletions src/Console/ApplicationFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace Castor\Console;

use Castor\Console\Command\CompileCommand;
use Castor\Console\Command\DebugCommand;
use Castor\Console\Command\RepackCommand;
use Castor\ContextRegistry;
Expand Down Expand Up @@ -67,6 +68,7 @@ public static function create(): SymfonyApplication

if (!class_exists(\RepackedApplication::class)) {
$application->add(new RepackCommand());
$application->add(new CompileCommand());
}

return $application;
Expand Down
189 changes: 189 additions & 0 deletions src/Console/Command/CompileCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
<?php

namespace Castor\Console\Command;

use Castor\PathHelper;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\ProgressBar;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\Process\Process;
use Symfony\Contracts\HttpClient\HttpClientInterface;

/**
* @internal
*/
class CompileCommand extends Command
{
private HttpClientInterface $httpClient;
private Filesystem $fs;

public function __construct()
{
parent::__construct();

$this->httpClient = HttpClient::create();
$this->fs = new Filesystem();
}

protected function configure(): void
{
$this->getDefinition()->addOptions(RepackCommand::getOptions());

$this
->setName('compile')
->addOption('arch', null, InputOption::VALUE_REQUIRED, 'Target architecture for PHP compilation', 'x86_64', ['x86_64', 'aarch64'])
->addOption('php-version', null, InputOption::VALUE_REQUIRED, 'PHP version in major.minor format', '8.2')
->addOption('php-extensions', null, InputOption::VALUE_REQUIRED, 'PHP extensions required, in a comma-separated format. Defaults are the minimum required to run a basic "Hello World" task in Castor.', 'mbstring,phar,posix,tokenizer')
->addOption('php-rebuild', null, InputOption::VALUE_NONE, 'Force PHP build compilation.')
->setHidden(true)
;
}

protected function execute(InputInterface $input, OutputInterface $output): int
{
$os = $input->getOption('os');
if ('windows' === $os) {
throw new \RuntimeException('Standalone binary compilation is not yet supported for Windows.');
}

$arch = $input->getOption('arch');
if (!\in_array($arch, ['x86_64', 'aarch64'])) {
throw new \RuntimeException('Target architecture must be one of x86_64 or aarch64');
}

$io = new SymfonyStyle($input, $output);

$io->section('Executing the repack command to build your Castor app as a PHAR archive.');
$this->runRepackCommand($input, $output);

$io->section('Compiling PHP and your Castor app PHAR archive into a standalone binary');

$spcBinaryPath = sys_get_temp_dir() . '/castor-php-static-compiler/spc';
$spcBinaryDir = \dirname($spcBinaryPath);

if (!$this->fs->exists($spcBinaryDir)) {
$io->text(sprintf('Creating directory "%s" as it does not exist.', $spcBinaryDir));
$this->fs->mkdir($spcBinaryDir, 0o755);
}

if (!$this->fs->exists($spcBinaryPath)) {
$spcSourceUrl = sprintf('https://dl.static-php.dev/static-php-cli/spc-bin/nightly/spc-%s-%s', $os, $arch);
$io->text(sprintf('Downloading the static-php-cli (spc) tool from "%s" to "%s"', $spcSourceUrl, $spcBinaryPath));
$this->downloadSPC($spcSourceUrl, $spcBinaryPath, $output);
$io->newLine(2);
} else {
$io->text(sprintf('Using the static-php-cli (spc) tool from "%s"', $spcBinaryPath));
}

$forcePHPRebuild = $input->getOption('php-rebuild');

if (!$this->fs->exists($spcBinaryDir . '/buildroot/bin/micro.sfx') || $forcePHPRebuild) {
$installSPCDepsProcess = new Process(
command: [
$spcBinaryPath, 'doctor',
'--auto-fix',
],
cwd: $spcBinaryDir,
timeout: 5 * 60
);
$io->text('Running command: ' . $installSPCDepsProcess->getCommandLine());
$installSPCDepsProcess->mustRun(fn ($type, $buffer) => print ($buffer));

$phpVersion = $input->getOption('php-version');
$phpExtensions = $input->getOption('php-extensions');

$downloadProcess = new Process(
command: [
$spcBinaryPath, 'download',
'--for-extensions=' . $phpExtensions,
'--with-php=' . $phpVersion,
],
cwd: $spcBinaryDir,
timeout: 5 * 60
);
$io->text('Running command: ' . $downloadProcess->getCommandLine());
$downloadProcess->mustRun(fn ($type, $buffer) => print ($buffer));

$buildProcess = new Process(
command: [
$spcBinaryPath, 'build', $phpExtensions,
'--build-micro',
'--arch=' . $arch,
'-r',
],
cwd: $spcBinaryDir,
timeout: 60 * 60
);
$io->text('Running command: ' . $buildProcess->getCommandLine());
$buildProcess->mustRun(fn ($type, $buffer) => print ($buffer));
}

// __DIR__ will be castor-app/vendor/jolicode/castor/src/Console/Command when running castor-app/vendor/bin/castor
$castorAppSourceDir = PathHelper::realpath(__DIR__ . '/../../../../../../');
$appName = $input->getOption('app-name');
$pharFilePath = sprintf('%s/%s.%s.phar', $castorAppSourceDir, $appName, $os);

$buildProcess = new Process(
[
$spcBinaryPath,
'micro:combine', $pharFilePath,
'--output=' . $castorAppSourceDir . '/' . $appName,
],
cwd: $spcBinaryDir
);

$io->text('Running command: ' . $buildProcess->getCommandLine());
$buildProcess->mustRun(fn ($type, $buffer) => print ($buffer));

return Command::SUCCESS;
}

private function runRepackCommand(InputInterface $input, OutputInterface $output): void
{
$arguments = [
'command' => RepackCommand::COMMAND_NAME,
];

foreach (RepackCommand::getOptions() as $option) {
$arguments['--' . $option->getName()] = $input->getOption($option->getName());
}

$commandInput = new ArrayInput($arguments);

if (null === $this->getApplication()) {
throw new \RuntimeException(sprintf('"%s::$application" must be set before running %s', Command::class, __FUNCTION__));
}

$command = $this->getApplication()->find(RepackCommand::COMMAND_NAME);
$command->run($commandInput, $output);
}

private function downloadSPC(string $spcSourceUrl, string $spcBinaryDestination, OutputInterface $output): void
{
$response = $this->httpClient->request('GET', $spcSourceUrl);
$contentLength = $response->getHeaders()['content-length'][0] ?? 0;

$outputStream = fopen($spcBinaryDestination, 'w');
$progressBar = new ProgressBar($output, (int) $contentLength);

if (false === $outputStream) {
throw new \RuntimeException(sprintf('Failed to open file "%s" for writing', $spcBinaryDestination));
}

foreach ($this->httpClient->stream($response) as $chunk) {
fwrite($outputStream, $chunk->getContent());
$progressBar->advance(\strlen($chunk->getContent()));
}

fclose($outputStream);
chmod($spcBinaryDestination, 0o755);

$progressBar->finish();
}
}
23 changes: 18 additions & 5 deletions src/Console/Command/RepackCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,28 @@
*/
class RepackCommand extends Command
{
public const COMMAND_NAME = 'repack';

/**
* @return array<InputOption>
*/
public static function getOptions(): array
{
return [
new InputOption('app-name', null, InputOption::VALUE_REQUIRED, 'The name of the phar application', 'my-app'),
new InputOption('app-version', null, InputOption::VALUE_REQUIRED, 'The version of the phar application', '1.0.0'),
new InputOption('os', null, InputOption::VALUE_REQUIRED, 'The targeted OS', 'linux', ['linux', 'macos', 'windows']),
];
}

protected function configure(): void
{
$this
->setName('repack')
->addOption('app-name', null, InputOption::VALUE_REQUIRED, 'The name of the phar application', 'my-app')
->addOption('app-version', null, InputOption::VALUE_REQUIRED, 'The version of the phar application', '1.0.0')
->addOption('os', null, InputOption::VALUE_REQUIRED, 'The targeted OS', 'linux', ['linux', 'macos', 'windows'])
->setName(self::COMMAND_NAME)
->setHidden(true)
;

$this->getDefinition()->addOptions(self::getOptions());
}

protected function execute(InputInterface $input, OutputInterface $output): int
Expand Down Expand Up @@ -89,7 +102,7 @@ class RepackedApplication extends Application
),
];
}
// add all files from the FunctionFinder, this is usefull if the file
// add all files from the FunctionFinder, this is useful if the file
// are in a hidden directory, because it's not included by default by
// box
$boxConfig['files'] = [
Expand Down
40 changes: 40 additions & 0 deletions tests/CompileCommandTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

namespace Castor\Tests;

use PHPUnit\Framework\TestCase;
use Symfony\Component\Process\Process;

class CompileCommandTest extends TestCase
{
public function test()
{
$castorAppDirPath = RepackCommandTest::setupRepackedCastorApp('castor-test-compile');
$castAppName = 'castor-compiled-app';

(new Process(
[
'vendor/jolicode/castor/bin/castor',
'compile',
'--os', 'linux',
'--app-name', $castAppName,
'--php-extensions', 'mbstring,phar,posix,tokenizer',
],
cwd: $castorAppDirPath,
timeout: 5 * 60)
)->mustRun();

$binary = $castorAppDirPath . '/' . $castAppName;
$this->assertFileExists($binary);

(new Process([$binary], cwd: $castorAppDirPath))->mustRun();

$p = (new Process([$binary, 'hello'], cwd: $castorAppDirPath))->mustRun();
$this->assertSame('hello', $p->getOutput());

// Twice, because we want to be sure the phar is not corrupted after a
// run
$p = (new Process([$binary, 'hello'], cwd: $castorAppDirPath))->mustRun();
$this->assertSame('hello', $p->getOutput());
}
}
55 changes: 31 additions & 24 deletions tests/RepackCommandTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,36 @@ class RepackCommandTest extends TestCase
{
public function test()
{
$tmp = sys_get_temp_dir() . '/castor-test-repack';
$castorAppDirPath = self::setupRepackedCastorApp('castor-test-repack');

(new Process([
'vendor/jolicode/castor/bin/castor',
'repack',
'--os', 'linux',
], cwd: $castorAppDirPath))->mustRun();

$phar = $castorAppDirPath . '/my-app.linux.phar';
$this->assertFileExists($phar);

(new Process([$phar], cwd: $castorAppDirPath))->mustRun();

$p = (new Process([$phar, 'hello'], cwd: $castorAppDirPath))->mustRun();
$this->assertSame('hello', $p->getOutput());

// Twice, because we want to be sure the phar is not corrupted after a
// run
$p = (new Process([$phar, 'hello'], cwd: $castorAppDirPath))->mustRun();
$this->assertSame('hello', $p->getOutput());
}

public static function setupRepackedCastorApp(string $castorAppDirName): string
{
$castorAppDirPath = sys_get_temp_dir() . '/' . $castorAppDirName;

$fs = new Filesystem();
$fs->remove($tmp);
$fs->mkdir($tmp);
$fs->dumpFile($tmp . '/castor.php', <<<'PHP'
$fs->remove($castorAppDirPath);
$fs->mkdir($castorAppDirPath);
$fs->dumpFile($castorAppDirPath . '/castor.php', <<<'PHP'
<?php
use Castor\Attribute\AsTask;
Expand All @@ -28,7 +52,7 @@ function hello(): void
PHP
);

$fs->dumpFile($tmp . '/composer.json', json_encode([
$fs->dumpFile($castorAppDirPath . '/composer.json', json_encode([
'repositories' => [
[
'type' => 'path',
Expand All @@ -41,27 +65,10 @@ function hello(): void
]));

(new Process(['composer', 'install'],
cwd: $tmp,
cwd: $castorAppDirPath,
env: ['COMPOSER_MIRROR_PATH_REPOS' => '1'],
))->mustRun();

(new Process([
'vendor/jolicode/castor/bin/castor',
'repack',
'--os', 'linux',
], cwd: $tmp))->mustRun();

$phar = $tmp . '/my-app.linux.phar';
$this->assertFileExists($phar);

(new Process([$phar], cwd: $tmp))->mustRun();

$p = (new Process([$phar, 'hello'], cwd: $tmp))->mustRun();
$this->assertSame('hello', $p->getOutput());

// Twice, because we want to be sure the phar is not corrupted after a
// run
$p = (new Process([$phar, 'hello'], cwd: $tmp))->mustRun();
$this->assertSame('hello', $p->getOutput());
return $castorAppDirPath;
}
}

0 comments on commit 34ab073

Please sign in to comment.