diff --git a/plugins/com.noccy.docker/DockerBuildCommand.php b/plugins/com.noccy.docker/Commands/DockerBuildCommand.php similarity index 91% rename from plugins/com.noccy.docker/DockerBuildCommand.php rename to plugins/com.noccy.docker/Commands/DockerBuildCommand.php index fa8c8c0..f1f73a4 100644 --- a/plugins/com.noccy.docker/DockerBuildCommand.php +++ b/plugins/com.noccy.docker/Commands/DockerBuildCommand.php @@ -1,6 +1,6 @@ setName("docker:stack:register") + ->setDescription("Register a stack"); + + $this->addArgument("options", InputArgument::IS_ARRAY, "key=value pairs of stack options"); + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $stacks = $this->getStackManager(); + + $opts = []; + foreach ($input->getArgument("options") as $opt) { + if (str_contains($opt, "=")) { + [$k,$v] = explode("=", $opt, 2); + $opts[$k] = $v; + } + } + + $root = $this->getEnvironment()->getProjectDirectory(); + $stacks->registerStack($root, $opts); + + return Command::SUCCESS; + } + + private function getStackManager(): StackManager + { + return new StackManager($this->getEnvironment()); + } +} diff --git a/plugins/com.noccy.docker/Commands/Stack/StatusCommand.php b/plugins/com.noccy.docker/Commands/Stack/StatusCommand.php new file mode 100644 index 0000000..161e77e --- /dev/null +++ b/plugins/com.noccy.docker/Commands/Stack/StatusCommand.php @@ -0,0 +1,43 @@ +setName("docker:stack:status") + ->setDescription("Show status on registered stacks"); + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $stackManager = $this->getStackManager(); + $stacks = $stackManager->getRegisteredStacks(); + + foreach ($stacks as $stack) { + $output->writeln("\u{2bbb} {$stack->getName()}"); + $table = $stack->getContainersTable($output); + $table->setStyle('compact'); + $table->setColumnWidths([ 30, 20, 20, 0 ]); + $table->render(); + $output->writeln(""); + } + + return Command::SUCCESS; + } + + + + private function getStackManager(): StackManager + { + return new StackManager($this->getEnvironment()); + } +} + diff --git a/plugins/com.noccy.docker/Commands/Stack/UnregisterCommand.php b/plugins/com.noccy.docker/Commands/Stack/UnregisterCommand.php new file mode 100644 index 0000000..dda307a --- /dev/null +++ b/plugins/com.noccy.docker/Commands/Stack/UnregisterCommand.php @@ -0,0 +1,35 @@ +setName("docker:stack:unregister") + ->setDescription("Unregister a stack"); + + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $stacks = $this->getStackManager(); + + $root = $this->getEnvironment()->getProjectDirectory(); + $stacks->removeStack($root); + + return Command::SUCCESS; + } + + private function getStackManager(): StackManager + { + return new StackManager($this->getEnvironment()); + } +} diff --git a/plugins/com.noccy.docker/Stack/Stack.php b/plugins/com.noccy.docker/Stack/Stack.php new file mode 100644 index 0000000..fd29ce5 --- /dev/null +++ b/plugins/com.noccy.docker/Stack/Stack.php @@ -0,0 +1,89 @@ +path = $path; + $this->options = $options; + } + + public function getPath(): string + { + return $this->path; + } + + public function getName(): string + { + return $this->options['name'] ?? basename($this->path); + } + + public function getComposeFile(): string + { + return $this->path . "/" . ($this->options['compose']??"docker-compose.yml"); + } + + public function getContainersTable(OutputInterface $output): Table + { + exec("docker-compose -f " . escapeshellarg($this->getComposeFile()) . " ps -q", $ids, $ret); + + if (count($ids) === 0) { + $json = []; + } else { + exec("docker inspect ".join(" ",$ids), $out, $ret); + $json = json_decode(join("", $out)); + } + + $table = new Table($output); + $table->setStyle("box"); + $table->setHeaders([ "Name", "Status", "Image", "Ports" ]); + foreach ($json as $container) { + $startedTs = preg_replace('/(\.([0-9]+)Z)$/', '+0100', $container->State->StartedAt); + $s = date_parse($startedTs); + $started = mktime($s['hour'], $s['minute'], $s['second'], $s['month'], $s['day'], $s['year']) + 3600; + if ($container->State->Dead) { + $status = "".self::BULLET." ".$container->State->Status; + } elseif ($container->State->Restarting) { + $status = "".self::BULLET." ".$container->State->Status; + } elseif ($container->State->Running) { + $elapsed = time() - $started; + if ($elapsed > 60) { + $em = floor($elapsed / 60); + $es = $elapsed - ($em * 60); + if ($em>60) { + $eh = floor($em / 60); + $em = $em - ($eh * 60); + $elapsed = sprintf("%dh%dm%ds", $eh, $em, $es); + } else { + $elapsed = sprintf("%dm%ds", $em, $es); + } + } else { + $elapsed = sprintf("%ds", $elapsed); + } + $status = "".self::BULLET." ".$container->State->Status." ({$elapsed})"; + } else { + $status = "".self::BULLET." ".$container->State->Status; + } + $ports = $container->Config->ExposedPorts??[]; + $ports = array_keys((array)$ports); + $table->addRow([ $container->Name, $status, $container->Config->Image, join(", ", $ports) ]); + } + + return $table; + } + + +} \ No newline at end of file diff --git a/plugins/com.noccy.docker/Stack/StackManager.php b/plugins/com.noccy.docker/Stack/StackManager.php new file mode 100644 index 0000000..84ede66 --- /dev/null +++ b/plugins/com.noccy.docker/Stack/StackManager.php @@ -0,0 +1,64 @@ +environment = $environment; + $this->readConfig(); + } + + private function readConfig() + { + $config = $this->environment->readConfig("com.noccy.docker/stacks.json", true); + if (!$config) { + return; + } + + $this->stacks = $config['stacks'] ?? []; + foreach ($this->stacks as $i=>$stack) { + $this->stackObjects[$i] = new Stack($stack['path'], $stack['options']); + } + } + + private function flushConfig() + { + $config = [ + 'stacks' => $this->stacks, + ]; + $this->environment->writeConfig("com.noccy.docker/stacks.json", $config, true); + } + + public function registerStack(string $path, array $options=[]) + { + $this->stacks[$path] = [ + 'path' => $path, + 'options' => $options + ]; + $this->flushConfig(); + + } + + public function removeStack(string $path) + { + unset($this->stacks[$path]); + $this->flushConfig(); + } + + public function getRegisteredStacks(): array + { + return $this->stackObjects; + } + +} \ No newline at end of file diff --git a/plugins/com.noccy.docker/sparkplug.php b/plugins/com.noccy.docker/sparkplug.php index 7178738..b2ca00b 100644 --- a/plugins/com.noccy.docker/sparkplug.php +++ b/plugins/com.noccy.docker/sparkplug.php @@ -27,15 +27,19 @@ class DockerPlug extends SparkPlug } if ($hasCompose || $hasBuild) { - register_command(new DockerUpCommand); - register_command(new DockerDownCommand); - register_command(new DockerStatusCommand); + register_command(new Commands\DockerUpCommand); + register_command(new Commands\DockerDownCommand); + register_command(new Commands\DockerStatusCommand); + register_command(new Commands\DockerDbExportCommand); } if ($hasBuild) { - register_command(new DockerBuildCommand); - register_command(new DockerExecCommand); + register_command(new Commands\DockerBuildCommand); + register_command(new Commands\DockerExecCommand); } - register_command(new DockerDbExportCommand); + + register_command(new Commands\Stack\StatusCommand); + register_command(new Commands\Stack\RegisterCommand); + register_command(new Commands\Stack\UnregisterCommand); } public function getComposeStack(): ?Stack diff --git a/plugins/com.noccy.pdo/Commands/PdoInspectCommand.php b/plugins/com.noccy.pdo/Commands/PdoInspectCommand.php new file mode 100644 index 0000000..2bf6b7b --- /dev/null +++ b/plugins/com.noccy.pdo/Commands/PdoInspectCommand.php @@ -0,0 +1,79 @@ +setName("pdo:inspect"); + $this->setDescription("Inspect the database, a table, or a row"); + $this->addOption("res", "r", InputOption::VALUE_REQUIRED, "Resource to query", "db"); + $this->addArgument("table", InputArgument::OPTIONAL, "Table name to inspect"); + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $sourceName = $input->getOption("res"); + $source = get_resource($sourceName); + if (!$source) { + $output->writeln("Invalid resource: {$source}"); + return Command::INVALID; + } + + $reflector = $source->getReflector(); + + + $tableName = $input->getArgument("table"); + if ($tableName) { + try { + $table = $reflector->createTableReflection($tableName); + if ($table) { + $this->dumpTable($tableName, $table, $output); + } + } catch (\Exception $e) { + $output->writeln("{$e->getMessage()}"); + } + } else { + try { + $database = $reflector->createDatabaseReflection(); + foreach ($database->getAllTables() as $tableName=>$table) { + $this->dumpTable($tableName, $table, $output); + } + } catch (\Exception $e) { + $output->writeln("{$e->getMessage()}"); + } + } + + return Command::SUCCESS; + } + + private function dumpTable(string $name, TableReflectionInterface $table, OutputInterface $output) + { + $output->writeln("{$name}"); + $t = new Table($output); + $t->setStyle('compact'); + $t->setHeaders([ "Name", "Type", "PK", "NULL", "Default" ]); + $t->setColumnWidth(0, 30); + $t->setColumnWidth(1, 30); + foreach ($table->getAllColumns() as $column) { + $t->addRow([ + $column->getName(), + $column->getType(), + $column->isPrimaryKey()?"Y":"-", + $column->isNullable()?"Y":"-", + $column->getDefaultValue(), + ]); + } + $t->render(); + $output->writeln(""); + } +} + diff --git a/plugins/com.noccy.pdo/PdoResource.php b/plugins/com.noccy.pdo/PdoResource.php index 15ca801..7663d0e 100644 --- a/plugins/com.noccy.pdo/PdoResource.php +++ b/plugins/com.noccy.pdo/PdoResource.php @@ -4,6 +4,9 @@ namespace SparkPlug\Com\Noccy\Pdo; use Spark\Resource\ResourceType; use PDO; +use SparkPlug\Com\Noccy\Pdo\Reflection\Reflector\MysqlReflector; +use SparkPlug\Com\Noccy\Pdo\Reflection\Reflector\ReflectorInterface; +use SparkPlug\Com\Noccy\Pdo\Reflection\Reflector\SqliteReflector; class PdoResource extends ResourceType { @@ -47,6 +50,23 @@ class PdoResource extends ResourceType return $this->pdo; } + public function getReflector(): ?ReflectorInterface + { + $uri = $this->options['uri']; + + if (!preg_match('|^(.+?):|', $uri, $m)) { + fprintf(STDERR, "error: Bad resource URI\n"); + return null; + } + switch ($m[1]) { + case 'mysql': + return new MysqlReflector($this); + case 'sqlite': + return new SqliteReflector($this); + } + + } + public function info() { return $this->options['uri']; diff --git a/plugins/com.noccy.pdo/Reflection/ColumnReflectionInterface.php b/plugins/com.noccy.pdo/Reflection/ColumnReflectionInterface.php new file mode 100644 index 0000000..e88868a --- /dev/null +++ b/plugins/com.noccy.pdo/Reflection/ColumnReflectionInterface.php @@ -0,0 +1,20 @@ +meta = $meta; + } + + public function isPrimaryKey(): bool + { + return str_contains($this->meta['Key']??null, "PRI"); + } + + public function isNullable(): bool + { + return ($this->meta['Null']??null) == "YES"; + } + + public function getName(): string + { + return $this->meta['Field']; + } + + public function getType(): string + { + return $this->meta['Type']; + } + + public function getDefaultValue(): mixed + { + return $this->meta['Default'] ?? null; + } + +} + diff --git a/plugins/com.noccy.pdo/Reflection/Reflector/MysqlDatabaseReflection.php b/plugins/com.noccy.pdo/Reflection/Reflector/MysqlDatabaseReflection.php new file mode 100644 index 0000000..4590a98 --- /dev/null +++ b/plugins/com.noccy.pdo/Reflection/Reflector/MysqlDatabaseReflection.php @@ -0,0 +1,42 @@ +getPDO(); + + $tableQuery = $pdo->prepare('show full tables'); + $tableQuery->execute(); + $tables = $tableQuery->fetchAll(PDO::FETCH_ASSOC); + + foreach ($tables as $table) { + $name = reset($table); + $this->tables[$name] = new MysqlTableReflection($db, $name); + } + + } + + public function getAllTables(): array + { + return $this->tables; + } + + public function getTable(string $name): ?TableReflectionInterface + { + return $this->tables[$name] ?? null; + } +} + diff --git a/plugins/com.noccy.pdo/Reflection/Reflector/MysqlReflector.php b/plugins/com.noccy.pdo/Reflection/Reflector/MysqlReflector.php new file mode 100644 index 0000000..2d4f53d --- /dev/null +++ b/plugins/com.noccy.pdo/Reflection/Reflector/MysqlReflector.php @@ -0,0 +1,30 @@ +db = $db; + } + + public function createDatabaseReflection(): DatabaseReflectionInterface + { + return new MysqlDatabaseReflection($this->db); + } + + public function createTableReflection(string $table): TableReflectionInterface + { + return new MysqlTableReflection($this->db, $table); + } + + +} + diff --git a/plugins/com.noccy.pdo/Reflection/Reflector/MysqlTableReflection.php b/plugins/com.noccy.pdo/Reflection/Reflector/MysqlTableReflection.php new file mode 100644 index 0000000..7c11be7 --- /dev/null +++ b/plugins/com.noccy.pdo/Reflection/Reflector/MysqlTableReflection.php @@ -0,0 +1,56 @@ +getPDO(); + + $columnQuery = $pdo->prepare('show fields from '.$table); + $columnQuery->execute(); + $columns = $columnQuery->fetchAll(PDO::FETCH_ASSOC); + + foreach ($columns as $column) { + $name = $column['Field']; + $this->columns[$name] = new MysqlColumnReflection($db, $column); + } + + /* + + SELECT table_schema, table_name, column_name, ordinal_position, data_type, + numeric_precision, column_type, column_default, is_nullable, column_comment + FROM information_schema.columns + WHERE (table_schema='schema_name' and table_name = 'table_name') + order by ordinal_position; + + OR + + show fields from 'table_name' + */ + } + + public function getAllColumns(): array + { + return $this->columns; + } + + public function getColumn(string $name): ?ColumnReflectionInterface + { + return $this->columns[$name] ?? null; + } + +} + diff --git a/plugins/com.noccy.pdo/Reflection/Reflector/ReflectorInterface.php b/plugins/com.noccy.pdo/Reflection/Reflector/ReflectorInterface.php new file mode 100644 index 0000000..3e4f799 --- /dev/null +++ b/plugins/com.noccy.pdo/Reflection/Reflector/ReflectorInterface.php @@ -0,0 +1,16 @@ +meta = $meta; + } + + public function isPrimaryKey(): bool + { + return (bool)$this->meta['pk'] ?? false; + } + + public function isNullable(): bool + { + return !($this->meta['notnull'] ?? 0); + } + + public function getName(): string + { + return $this->meta['name']; + } + + public function getType(): string + { + return $this->meta['type']; + } + + public function getDefaultValue(): mixed + { + return $this->meta['dflt_value'] ?? null; + } + +} + diff --git a/plugins/com.noccy.pdo/Reflection/Reflector/SqliteDatabaseReflection.php b/plugins/com.noccy.pdo/Reflection/Reflector/SqliteDatabaseReflection.php new file mode 100644 index 0000000..e5921ae --- /dev/null +++ b/plugins/com.noccy.pdo/Reflection/Reflector/SqliteDatabaseReflection.php @@ -0,0 +1,41 @@ +getPDO(); + + //$tableQuery = $pdo->prepare("SELECT name FROM sqlite_schema WHERE type IN ('table','view') AND name NOT LIKE 'sqlite_%' ORDER BY 1;"); + $tableQuery = $pdo->prepare("SELECT * FROM sqlite_schema ORDER BY 1;"); + $tableQuery->execute(); + $tables = $tableQuery->fetchAll(PDO::FETCH_ASSOC); + foreach ($tables as $tinfo) { + $tname = $tinfo['name']; + $this->tables[$tname] = $reflector->createTableReflection($tname); + } + } + + public function getAllTables(): array + { + return $this->tables; + } + + public function getTable(string $name): ?TableReflectionInterface + { + return $this->tables[$name] ?? null; + } +} + diff --git a/plugins/com.noccy.pdo/Reflection/Reflector/SqliteReflector.php b/plugins/com.noccy.pdo/Reflection/Reflector/SqliteReflector.php new file mode 100644 index 0000000..4425c9d --- /dev/null +++ b/plugins/com.noccy.pdo/Reflection/Reflector/SqliteReflector.php @@ -0,0 +1,30 @@ +db = $db; + } + + public function createDatabaseReflection(): DatabaseReflectionInterface + { + return new SqliteDatabaseReflection($this->db, $this); + } + + public function createTableReflection(string $table): TableReflectionInterface + { + return new SqliteTableReflection($this->db, $table); + } + + +} + diff --git a/plugins/com.noccy.pdo/Reflection/Reflector/SqliteTableReflection.php b/plugins/com.noccy.pdo/Reflection/Reflector/SqliteTableReflection.php new file mode 100644 index 0000000..c6b1c3b --- /dev/null +++ b/plugins/com.noccy.pdo/Reflection/Reflector/SqliteTableReflection.php @@ -0,0 +1,46 @@ +getPDO(); + + //$columnsQuery = $pdo->prepare("SELECT name FROM sqlite_schema WHERE type IN ('table','view') AND name NOT LIKE 'sqlite_%' ORDER BY 1;"); + $columnsQuery = $pdo->prepare("pragma table_info({$table})"); + $columnsQuery->execute(); + $columns = $columnsQuery->fetchAll(PDO::FETCH_ASSOC); + + if (count($columns) == 0) { + throw new \RuntimeException(sprintf("No such table %s in database", $table)); + } + + foreach ($columns as $column) { + $name = $column['name']; + $this->columns[$name] = new SqliteColumnReflection($db, $column); + } + } + + public function getAllColumns(): array + { + return $this->columns; + } + + public function getColumn(string $name): ?ColumnReflectionInterface + { + return $this->columns[$name] ?? null; + } +} + diff --git a/plugins/com.noccy.pdo/Reflection/TableReflectionInterface.php b/plugins/com.noccy.pdo/Reflection/TableReflectionInterface.php new file mode 100644 index 0000000..c6482bb --- /dev/null +++ b/plugins/com.noccy.pdo/Reflection/TableReflectionInterface.php @@ -0,0 +1,13 @@ +getConfigDirectory()); + + $filepath = $path . "/" . $filename; + $dirname = dirname($filepath); + if (!is_dir($dirname) || !file_exists($filepath)) { + return null; + } + + $json = file_get_contents($filepath); + + return json_decode($json, true); + } + + public function writeConfig(string $filename, $config, bool $global=false) + { + $path = ($global ? getenv("HOME")."/.config/spark" : $this->getConfigDirectory()); + $filepath = $path . "/" . $filename; + + $dirname = dirname($filepath); + if (!is_dir($dirname) && $global) { + mkdir($dirname, 0777, true); + } + + $json = json_encode($config, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES); + file_put_contents($filepath, $json); + } + public function loadEnvironment() { if ($this->loaded) {