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 cccd54c commit 387b186
Show file tree
Hide file tree
Showing 10 changed files with 392 additions and 32 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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
* Add `wait_for_docker_container()` function to wait for a docker container to be ready
* Add `AsSymfonyTask` attribute to map Symfony Command
* Add a `debug` command
* Add a `compile` command that puts together a customizable PHP binary with a repacked castor app into one executable file
* Add `Context->name` property (automatically set by the application)
* Edited the duration of update check from `60 days` to `24 hours`
* Revise the usage of the terms `command` and `task` for consistency through code and docs.
Expand Down
49 changes: 49 additions & 0 deletions doc/going-further/compile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Compiling your application into a standalone binary

[Putting your Castor application into a PHAR archive](repack.md) can be a good way to easily share and use it in various environments.

However, you need to ensure that PHP is installed and configured correctly in all the environments where you want to use your Castor app.
This can be a hassle, especially if you don't have control over the environments.

To simplify things, Castor's `compile` command goes further and puts together a customizable PHP binary with your PHAR archive to make one executable file that can be used in any setting.

## Pre-requisites

Follow the `repack` command [pre-requistes](repack.md#pre-requisites) instructions

> [!WARNING]
> Compiling is not supported yet on Windows.
## Running the Compile Command

To compile your Castor application, navigate to your project directory and run:

```bash
vendor/bin/castor compile
```

### Options

Make sure to show the command description to see all the available options:
```bash
vendor/bin/castor compile -h
``````
### Behavior

The `compile` command performs several steps:

1. Executes the `repack` command to build your PHAR archive.
2. Downloads or uses an existing Static PHP CLI tool to compile PHP and the PHAR archive into a binary.
3. If required, it automatically installs dependencies and compiles PHP with the specified extensions.
4. Combines the compiled PHP and your PHAR file into a single executable.

## Post-Compilation

Once the compilation is finished, your Castor application is transformed into a standalone binary called `my-app` by default (you can use the `--app-name=` option to change it).
This binary is now ready to be distributed and run in environments that do not have PHP installed.

You can simply run it like any other executable:

```bash
./my-app
```
1 change: 1 addition & 0 deletions doc/going-further/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@

* [Listening to events](events.md)
* [Repacking your application in a new phar](repack.md)
* [Compiling your application in a standalone binary](compile.md)

## Examples

Expand Down
4 changes: 4 additions & 0 deletions doc/going-further/repack.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
You have created a Castor application, with many tasks, and you want to
distribute it as a single phar file? Castor can help you with that.

## Pre-requisites

In your project, install Castor as a dependency:

```bash
Expand All @@ -18,6 +20,8 @@ configuration. See the [PHP
documentation](https://www.php.net/manual/en/phar.configuration.php#ini.phar.readonly) to disabled
`phar.readonly`.

## Running the Repack Command

Then, run the repack command to create the new phar:

```
Expand Down
5 changes: 4 additions & 1 deletion 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 @@ -45,6 +46,7 @@ public static function create(): SymfonyApplication
$cacheDir = PlatformUtil::getCacheDirectory();
$cache = new FilesystemAdapter(directory: $cacheDir);
$logger = new Logger('castor', [], [new ProcessProcessor()]);
$fs = new Filesystem();

/** @var SymfonyApplication */
// @phpstan-ignore-next-line
Expand All @@ -56,7 +58,7 @@ public static function create(): SymfonyApplication
new ExpressionLanguage($contextRegistry),
new StubsGenerator($logger),
$logger,
new Filesystem(),
$fs,
$httpClient,
$cache,
new WaitForHelper($httpClient, $logger),
Expand All @@ -67,6 +69,7 @@ public static function create(): SymfonyApplication

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

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

namespace Castor\Console\Command;

use Castor\PathHelper;
use Castor\PlatformUtil;
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\Process\Process;
use Symfony\Contracts\HttpClient\HttpClientInterface;

/**
* @internal
*/
class CompileCommand extends Command
{
public function __construct(
private readonly HttpClientInterface $httpClient,
private readonly Filesystem $fs
) {
parent::__construct();
}

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');
$os = 'darwin' === $os ? 'macos' : $os;

if ('windows' === $os) {
throw new \InvalidArgumentException('Standalone binary compilation is not yet supported for Windows.');
}

$arch = $input->getOption('arch');
if (!\in_array($arch, ['x86_64', 'aarch64'])) {
throw new \InvalidArgumentException('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 = PlatformUtil::getCacheDirectory() . '/castor-php-static-compiler/spc';
$spcBinaryDir = \dirname($spcBinaryPath);

$this->setupSPC(
$spcBinaryDir,
$spcBinaryPath,
$io,
$os,
$arch,
$output
);

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

if (!$this->fs->exists($spcBinaryDir . '/buildroot/bin/micro.sfx') || $forcePHPRebuild) {
$this->installPHPBuildTools($spcBinaryPath, $spcBinaryDir, $io);

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

$this->downloadPHPSourceDeps(
$spcBinaryPath,
$phpExtensions,
$phpVersion,
$spcBinaryDir,
$io
);

$this->buildPHP(
$spcBinaryPath,
$phpExtensions,
$arch,
$spcBinaryDir,
$io
);
}

// __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);

$this->mergePHPandPHARIntoSingleExecutable(
$spcBinaryPath,
$pharFilePath,
$castorAppSourceDir . '/' . $appName,
$spcBinaryDir,
$io
);

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();
}

private function installPHPBuildTools(string $spcBinaryPath, string $spcBinaryDir, SymfonyStyle $io): void
{
$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));
}

private function downloadPHPSourceDeps(string $spcBinaryPath, mixed $phpExtensions, mixed $phpVersion, string $spcBinaryDir, SymfonyStyle $io): void
{
$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));
}

private function buildPHP(string $spcBinaryPath, mixed $phpExtensions, mixed $arch, string $spcBinaryDir, SymfonyStyle $io): void
{
$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));
}

private function mergePHPandPHARIntoSingleExecutable(string $spcBinaryPath, string $pharFilePath, string $appBinaryFilePath, string $spcBinaryDir, SymfonyStyle $io): void
{
$mergePHPandPHARProcess = new Process(
[
$spcBinaryPath,
'micro:combine', $pharFilePath,
'--output=' . $appBinaryFilePath,
],
cwd: $spcBinaryDir
);

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

private function setupSPC(string $spcBinaryDir, string $spcBinaryPath, SymfonyStyle $io, mixed $os, mixed $arch, OutputInterface $output): void
{
$this->fs->mkdir($spcBinaryDir, 0o755);

if ($this->fs->exists($spcBinaryPath)) {
$io->text(sprintf('Using the static-php-cli (spc) tool from "%s"', $spcBinaryPath));
} else {
$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);
}
}
}
Loading

0 comments on commit 387b186

Please sign in to comment.