Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 54 additions & 3 deletions src/Commands/ProtocolStart.php
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,6 @@ private function startDirect(string $dir): int
// duplicate stop+start cycle that kills what we just started.
$this->provisionSlaveWatchers($dir, $ctx);
$this->startDevServices($dir, $ctx);
$this->runPostStartHooks($dir, $ctx);
$this->runSecurityAudit($dir, $ctx);
$this->runSoc2Check($dir, $ctx);
$this->verifyHealth($dir, $ctx);
Expand All @@ -248,6 +247,12 @@ private function startDirect(string $dir): int
$statusArgs = new ArrayInput(['--dir' => $dir]);
$this->getApplication()->find('status')->run($statusArgs, $this->output);

// Wait for container readiness with Fibonacci backoff (up to 2 min)
// before running post_start hooks — all other steps must have
// completed and displayed their output first.
$this->waitForContainerReady($dir);
$this->runPostStartHooks($dir, $ctx);

return Command::SUCCESS;
}

Expand Down Expand Up @@ -680,32 +685,78 @@ private function startDevServices(string $dir, array $ctx): void
}
}

private function waitForContainerReady(string $dir): void
{
$runner = $this->runner;
$runner->run('Waiting for container readiness', function() use ($runner, $dir) {
$runner->log("dir={$dir}");

// Log what docker sees right now
$containerName = ContainerName::resolveFromDir($dir);
$runner->log("resolveFromDir container=" . ($containerName ?: '(none)'));
if (!$containerName) {
$names = Docker::getContainerNamesFromDockerComposeFile($dir);
$runner->log("compose containers=" . json_encode($names));
}

// List running docker containers for context
$ps = Shell::run("docker ps --format '{{.Names}} {{.Status}}' 2>&1");
$runner->log("docker ps:\n" . ($ps ?: '(empty)'));

$ready = Lifecycle::waitForContainer($dir, function($msg) use ($runner) {
$runner->log($msg);
});
if (!$ready) {
throw new \RuntimeException('Container did not become ready within ' . Lifecycle::MAX_WAIT . ' seconds');
}
}, 'READY');
}

private function runPostStartHooks(string $dir, array $ctx): void
{
$runner = $this->runner;
$runner->run('Post-start hooks', function() use ($runner, $dir, $ctx) {
$runner->log("dir={$dir} strategy={$ctx['strategy']}");

// Check protocol.json exists and is readable
$protocolJson = rtrim($dir, '/') . '/protocol.json';
$runner->log("protocol.json exists=" . (is_file($protocolJson) ? 'yes' : 'no'));
if (is_file($protocolJson)) {
$raw = file_get_contents($protocolJson);
$decoded = json_decode($raw, true);
$runner->log("protocol.json lifecycle keys=" . json_encode(
array_keys($decoded['lifecycle'] ?? [])
));
}

$hookKey = 'lifecycle.post_start';
if ($ctx['strategy'] === 'none') {
$devHooks = Json::read('lifecycle.post_start_dev', null, $dir);
$runner->log("lifecycle.post_start_dev raw=" . json_encode($devHooks));
if (is_array($devHooks)) {
$hookKey = 'lifecycle.post_start_dev';
$runner->log("strategy=none, using {$hookKey}");
}
}

$postStart = Json::read($hookKey, [], $dir);
$runner->log("{$hookKey} raw=" . json_encode($postStart));

if (empty($postStart) || !is_array($postStart)) {
$runner->log("No {$hookKey} hooks configured");
$runner->log("No {$hookKey} hooks configured — skipping");
return;
}

$envFile = null;
$bgEnv = rtrim($dir, '/') . '/.env.deployment';
if (is_file($bgEnv)) {
$envFile = $bgEnv;
$runner->log("envFile={$envFile}");
} else {
$runner->log("no .env.deployment found");
}

$runner->log("Running " . count($postStart) . " {$hookKey} hook(s) in {$dir}");
$runner->log("Dispatching " . count($postStart) . " {$hookKey} hook(s)");
Lifecycle::runPostStart($dir, function($msg) use ($runner) {
$runner->log($msg);
}, $envFile, $hookKey);
Expand Down
11 changes: 9 additions & 2 deletions src/Commands/ProtocolStop.php
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ protected function configure(): void
// configure an argument
->addArgument('project', InputArgument::OPTIONAL, 'Project name (for slave nodes, run from anywhere)')
->addOption('dir', 'd', InputOption::VALUE_OPTIONAL, 'Directory Path', null)
->addOption('force', 'f', InputOption::VALUE_NONE, 'Force stop, ignoring any existing lock')
// ...
;
}
Expand All @@ -96,8 +97,14 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$output->writeln('');

if (!$this->lock()) {
$output->writeln('The command is already running in another process.');
return Command::SUCCESS;
if ($input->getOption('force')) {
$output->writeln('<comment>Forcing lock override...</comment>');
$this->release();
$this->lock();
} else {
$output->writeln('The command is already running in another process. Use --force (-f) to override.');
return Command::SUCCESS;
}
}

$dir = $this->resolveDirectory();
Expand Down
13 changes: 10 additions & 3 deletions src/Commands/ProtocolUpdate.php
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,17 @@ protected function execute(InputInterface $input, OutputInterface $output): int
Shell::passthru("git -C " . escapeshellarg($protocoldir) . " fetch --all --tags");

if ($input->getOption('nightly')) {
// Nightly: reset to branch tip
$branch = Git::branch($protocoldir) ?: 'main';
// Nightly: checkout the branch and pull the tip.
// Git::branch() returns garbage in detached HEAD (e.g. from a
// previous tag checkout), so detect that and default to main.
$rawBranch = trim(Shell::run(
"git -C " . escapeshellarg($protocoldir) . " symbolic-ref --short HEAD 2>/dev/null"
));
$branch = $rawBranch ?: 'main';

$output->writeln("<comment>Updating to nightly ({$remote}/{$branch})</comment>");
Shell::passthru("git -C " . escapeshellarg($protocoldir) . " reset --hard {$remote}/{$branch}");
Shell::passthru("git -C " . escapeshellarg($protocoldir) . " checkout " . escapeshellarg($branch));
Shell::passthru("git -C " . escapeshellarg($protocoldir) . " reset --hard " . escapeshellarg("{$remote}/{$branch}"));
} else {
// Release: find the latest semver tag
$latestTag = trim(Shell::run(
Expand Down
71 changes: 52 additions & 19 deletions src/Commands/ReleaseCreate.php
Original file line number Diff line number Diff line change
Expand Up @@ -122,22 +122,33 @@ protected function execute(InputInterface $input, OutputInterface $output): int
// Check clean working tree
$status = trim(Shell::run("git -C " . escapeshellarg($repo_dir) . " status --porcelain 2>/dev/null"));
if ($status) {
$output->writeln('<fg=yellow>Warning: Working tree is not clean.</>');
$output->writeln('<error>Working tree is not clean. Commit your changes before creating a release.</error>');
$output->writeln('');
$output->writeln($status);
$output->writeln('');
return Command::FAILURE;
}

$helper = $this->getHelper('question');
$question = new ConfirmationQuestion('Commit all changes before creating the release? [y/N] ', false);
if ($helper->ask($input, $output, $question)) {
Shell::run("git -C " . escapeshellarg($repo_dir) . " add -A");
Shell::run("git -C " . escapeshellarg($repo_dir) . " commit -m " . escapeshellarg("Pre-release cleanup for {$version}"));
$output->writeln(' - Committed all changes');
$output->writeln('');
} else {
$output->writeln('<error>Aborting. Commit or stash your changes first.</error>');
return Command::FAILURE;
}
// Fetch remote and check if local is behind
$remote = Git::remoteName($repo_dir) ?: 'origin';
$branch = Git::branch($repo_dir);
Shell::run("git -C " . escapeshellarg($repo_dir) . " fetch " . escapeshellarg($remote) . " 2>&1");

$behind = (int) trim(Shell::run(
"git -C " . escapeshellarg($repo_dir)
. " rev-list --count HEAD.." . escapeshellarg("{$remote}/{$branch}") . " 2>/dev/null"
));
if ($behind > 0) {
$output->writeln("<error>Local branch '{$branch}' is {$behind} commit(s) behind {$remote}/{$branch}.</error>");
$output->writeln('<error>Pull the latest changes before creating a release.</error>');
return Command::FAILURE;
}

$ahead = (int) trim(Shell::run(
"git -C " . escapeshellarg($repo_dir)
. " rev-list --count " . escapeshellarg("{$remote}/{$branch}") . "..HEAD 2>/dev/null"
));
if ($ahead > 0) {
$output->writeln("<comment>Local branch is {$ahead} commit(s) ahead — these will be pushed with the release.</comment>");
}

// Check tag doesn't exist
Expand All @@ -163,12 +174,34 @@ protected function execute(InputInterface $input, OutputInterface $output): int

// Push
if (!$input->getOption('no-push')) {
$remote = Git::remoteName($repo_dir) ?: 'origin';
$branch = Git::branch($repo_dir);

Shell::passthru("git -C " . escapeshellarg($repo_dir) . " push " . escapeshellarg($remote) . " " . escapeshellarg($branch));
Shell::passthru("git -C " . escapeshellarg($repo_dir) . " push " . escapeshellarg($remote) . " " . escapeshellarg($version));
$output->writeln(" - Pushed to {$remote}");
// Push branch first — abort entirely if rejected
$branchPushResult = Shell::run(
"git -C " . escapeshellarg($repo_dir) . " push " . escapeshellarg($remote) . " " . escapeshellarg($branch) . " 2>&1",
$branchPushExit
);
if ($branchPushExit !== 0) {
$output->writeln('<error>Branch push rejected. Removing local tag and undoing VERSION commit.</error>');
$output->writeln($branchPushResult);
// Rollback: delete local tag and undo the VERSION commit
Shell::run("git -C " . escapeshellarg($repo_dir) . " tag -d " . escapeshellarg($version) . " 2>/dev/null");
Shell::run("git -C " . escapeshellarg($repo_dir) . " reset --soft HEAD~1 2>/dev/null");
Shell::run("git -C " . escapeshellarg($repo_dir) . " checkout -- VERSION 2>/dev/null");
$output->writeln('<comment>Rolled back local tag and VERSION commit. Pull the latest changes and try again.</comment>');
return Command::FAILURE;
}
$output->writeln(" - Pushed branch to {$remote}");

// Push tag
$tagPushResult = Shell::run(
"git -C " . escapeshellarg($repo_dir) . " push " . escapeshellarg($remote) . " " . escapeshellarg($version) . " 2>&1",
$tagPushExit
);
if ($tagPushExit !== 0) {
$output->writeln('<error>Tag push failed.</error>');
$output->writeln($tagPushResult);
return Command::FAILURE;
}
$output->writeln(" - Pushed tag {$version} to {$remote}");

// Create GitHub Release
$draft = $input->getOption('draft');
Expand Down
107 changes: 105 additions & 2 deletions src/Helpers/Lifecycle.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,73 @@
namespace Gitcd\Helpers;

use Gitcd\Utils\Json;
use Gitcd\Helpers\Docker;
use Gitcd\Helpers\ContainerName;

class Lifecycle
{
/** Maximum total wait time for container readiness (seconds). */
public const MAX_WAIT = 120;

/**
* Wait for the container to be running using Fibonacci backoff.
*
* Sequence: 1, 1, 2, 3, 5, 8, 13, 21, 34, 55 …
* Gives up after MAX_WAIT seconds total elapsed.
*
* @return bool true if container became ready, false on timeout
*/
public static function waitForContainer(string $repoDir, ?callable $logger = null): bool
{
$containerName = ContainerName::resolveFromDir($repoDir);
if (!$containerName) {
// Fallback: grab names from compose file
$names = Docker::getContainerNamesFromDockerComposeFile($repoDir);
$containerName = $names[0] ?? null;
}

if (!$containerName) {
if ($logger) $logger("No container name resolved — skipping readiness wait");
return true;
}

if ($logger) $logger("Waiting for container '{$containerName}' to be ready (max " . self::MAX_WAIT . "s)…");

$elapsed = 0;
$a = 1;
$b = 1;

while ($elapsed < self::MAX_WAIT) {
if (Docker::isDockerContainerRunning($containerName)) {
if ($logger) $logger("Container '{$containerName}' is running after {$elapsed}s");
return true;
}

$delay = min($a, self::MAX_WAIT - $elapsed);
if ($delay <= 0) {
break;
}

if ($logger) $logger("Container not ready, retrying in {$delay}s (elapsed {$elapsed}s)…");
sleep($delay);
$elapsed += $delay;

// Fibonacci step
$next = $a + $b;
$a = $b;
$b = $next;
}

// Final check after the last sleep
if (Docker::isDockerContainerRunning($containerName)) {
if ($logger) $logger("Container '{$containerName}' is running after {$elapsed}s");
return true;
}

if ($logger) $logger("Container '{$containerName}' not ready after {$elapsed}s — giving up");
return false;
}

/**
* Run post_start lifecycle hooks from protocol.json.
*
Expand All @@ -23,22 +87,61 @@ class Lifecycle
public static function runPostStart(string $repoDir, ?callable $logger = null, ?string $envFile = null, string $hookKey = 'lifecycle.post_start'): void
{
$hooks = Json::read($hookKey, [], $repoDir);

if ($logger) {
$logger("hookKey={$hookKey} repoDir={$repoDir}");
$logger("raw hooks value: " . json_encode($hooks));
}

if (!is_array($hooks) || empty($hooks)) {
if ($logger) $logger("No hooks to run (empty or not an array)");
return;
}

// Log container resolution so we can see what exec will target
$containerName = ContainerName::resolveActive($repoDir);
if ($logger) {
$logger("resolveActive container=" . ($containerName ?: '(none)'));
if (!$containerName) {
$fallbackNames = Docker::getContainerNamesFromDockerComposeFile($repoDir);
$logger("fallback compose containers=" . json_encode($fallbackNames));
}
$isRunning = $containerName ? Docker::isDockerContainerRunning($containerName) : false;
$logger("container running=" . ($isRunning ? 'yes' : 'no'));
}

foreach ($hooks as $i => $hook) {
$hook = trim($hook);
if ($hook === '') {
if ($logger) $logger("[hook {$i}] skipped (empty string)");
continue;
}

// All hooks run inside the active container via protocol exec
$cmd = "protocol exec -T -d " . escapeshellarg(rtrim($repoDir, '/'))
. " " . escapeshellarg($hook) . " 2>&1";

if ($logger) $logger("[hook {$i}] exec: {$hook}");
Shell::run($cmd);
if ($logger) $logger("[hook {$i}] cmd: {$cmd}");

$output = Shell::run($cmd, $exitCode);

if ($logger) {
$logger("[hook {$i}] exit_code={$exitCode}");
if ($output !== null && trim($output) !== '') {
// Log output line by line to keep log readable
foreach (explode("\n", trim($output)) as $line) {
$logger("[hook {$i}] output: {$line}");
}
} else {
$logger("[hook {$i}] output: (empty)");
}
}

if ($exitCode !== 0 && $logger) {
$logger("[hook {$i}] WARNING: hook exited with non-zero code {$exitCode}");
}
}

if ($logger) $logger("All {$hookKey} hooks complete");
}
}
2 changes: 1 addition & 1 deletion src/Helpers/ReleaseWatcher.php
Original file line number Diff line number Diff line change
Expand Up @@ -464,7 +464,7 @@ private function protocolStopStart(string $stopDir, string $startDir): void
$stopArg = escapeshellarg($stopDir);
$startArg = escapeshellarg($startDir);

$cmd = "nohup sh -c 'php {$phpBin} stop --dir={$stopArg} && php {$phpBin} start --dir={$startArg}'"
$cmd = "nohup sh -c 'php {$phpBin} stop --force --dir={$stopArg} && php {$phpBin} start --force --dir={$startArg}'"
. " >> " . escapeshellarg($logFile) . " 2>&1 </dev/null &";

Log::context('watcher', [
Expand Down
Loading