diff --git a/CHANGELOG.md b/CHANGELOG.md index 86f1710..afa2db4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ Version format: `XX.YY.ZZ` (zero-padded semver). ## [Unreleased] +### Added +- `cli/client_provision.php` — end-to-end client onboarding: repo creation, secrets/variables injection, Grafana dashboard, monitoring endpoints (addresses #4) +- `templates/client-provision-example.json` — example config for client provisioning + ## [06.00.00] - 2026-05-25 ### Added diff --git a/cli/client_provision.php b/cli/client_provision.php new file mode 100644 index 0000000..472b349 --- /dev/null +++ b/cli/client_provision.php @@ -0,0 +1,534 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: moko-platform.CLI + * INGROUP: moko-platform + * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform + * PATH: /cli/client_provision.php + * VERSION: 01.00.00 + * BRIEF: Provision a new client environment end-to-end + */ + +declare(strict_types=1); + +final class ClientProvision +{ + private string $giteaUrl = 'https://git.mokoconsulting.tech'; + private string $giteaToken = ''; + private string $grafanaUrl = ''; + private string $grafanaToken = ''; + private string $configFile = ''; + private string $step = ''; + private bool $dryRun = false; + /** @var array */ + private array $config = []; + private string $org = ''; + private string $repoName = ''; + + public function run(): int + { + $this->parseArgs(); + + if ($this->configFile === '') { + $this->log('ERROR: --config is required.'); + $this->printUsage(); + return 1; + } + + if (!file_exists($this->configFile)) { + $this->log("ERROR: Not found: {$this->configFile}"); + return 1; + } + + $json = file_get_contents($this->configFile); + $this->config = json_decode($json, true); + + if (!is_array($this->config)) { + $this->log('ERROR: Invalid JSON in config file.'); + return 1; + } + + $this->giteaToken = $this->config['gitea_token'] + ?? getenv('GA_TOKEN') ?: ''; + $this->grafanaUrl = $this->config['grafana_url'] + ?? getenv('GRAFANA_URL') ?: ''; + $this->grafanaToken = $this->config['grafana_token'] + ?? getenv('GRAFANA_TOKEN') ?: ''; + $this->giteaUrl = $this->config['gitea_url'] + ?? $this->giteaUrl; + + if ($this->giteaToken === '') { + $this->log('ERROR: gitea_token or GA_TOKEN required.'); + return 1; + } + + $this->org = $this->config['org'] ?? ''; + $clientName = $this->config['name'] ?? ''; + + if ($this->org === '' || $clientName === '') { + $this->log('ERROR: "org" and "name" required in config.'); + return 1; + } + + $this->repoName = 'client-waas-' . $clientName; + + $this->log("=== Client Provisioning: {$clientName} ==="); + $this->log(" Org: {$this->org}"); + $this->log(" Repo: {$this->repoName}"); + + if ($this->dryRun) { + $this->log(' Mode: DRY RUN'); + } + + $this->log(''); + + $steps = [ + 'repo' => 'createRepo', + 'variables' => 'setVariables', + 'secrets' => 'setSecrets', + 'monitoring' => 'setupMonitoring', + 'summary' => 'printSummary', + ]; + + $exitCode = 0; + + foreach ($steps as $name => $method) { + if ($this->step !== '' && $this->step !== $name) { + continue; + } + + $result = $this->$method(); + + if ($result !== 0) { + $exitCode = 1; + } + } + + return $exitCode; + } + + private function createRepo(): int + { + $this->log('[1/5] Creating repository...'); + + $check = $this->giteaApi( + 'GET', + "/api/v1/repos/{$this->org}/{$this->repoName}" + ); + + if ($check['code'] === 200) { + $this->log(" SKIP: repo already exists"); + return 0; + } + + if ($this->dryRun) { + $this->log( + " WOULD CREATE: {$this->org}/{$this->repoName}" + ); + return 0; + } + + $payload = json_encode([ + 'owner' => $this->org, + 'name' => $this->repoName, + 'description' => ($this->config['name'] ?? '') . ' WaaS site', + 'private' => true, + 'git_content' => true, + 'topics' => true, + 'labels' => true, + ]); + + $resp = $this->giteaApi( + 'POST', + '/api/v1/repos/MokoConsulting/' + . 'Template-Client-WaaS/generate', + $payload + ); + + if ($resp['code'] < 200 || $resp['code'] >= 300) { + $this->log(" ERROR: HTTP {$resp['code']}"); + return 1; + } + + $this->log(' OK: Repo created'); + + $this->giteaApi( + 'POST', + "/api/v1/repos/{$this->org}/{$this->repoName}/branches", + json_encode([ + 'new_branch_name' => 'dev', + 'old_branch_name' => 'main', + ]) + ); + + $this->log(' OK: dev branch created'); + + return 0; + } + + private function setVariables(): int + { + $this->log('[2/5] Setting repo variables...'); + + $vars = $this->config['variables'] ?? []; + + if (empty($vars)) { + $this->log(' SKIP: No variables in config'); + return 0; + } + + $errors = 0; + $api = "/api/v1/repos/{$this->org}/{$this->repoName}" + . "/actions/variables"; + + foreach ($vars as $name => $value) { + if ($this->dryRun) { + $display = strlen($value) > 40 + ? substr($value, 0, 37) . '...' : $value; + $this->log(" WOULD SET: {$name} = {$display}"); + continue; + } + + $ok = $this->setOrCreateVariable($api, $name, $value); + + if ($ok) { + $this->log(" OK: {$name}"); + } else { + $this->log(" ERROR: {$name}"); + $errors++; + } + } + + return $errors > 0 ? 1 : 0; + } + + private function setSecrets(): int + { + $this->log('[3/5] Setting repo secrets...'); + + $secrets = $this->config['secrets'] ?? []; + + if (empty($secrets)) { + $this->log(' SKIP: No secrets in config'); + return 0; + } + + $errors = 0; + $api = "/api/v1/repos/{$this->org}/{$this->repoName}" + . "/actions/secrets"; + + foreach ($secrets as $name => $value) { + if (str_starts_with($value, '@')) { + $keyPath = substr($value, 1); + + if (!file_exists($keyPath)) { + $this->log(" ERROR: {$name} file not found: {$keyPath}"); + $errors++; + continue; + } + + $value = file_get_contents($keyPath); + } + + if ($this->dryRun) { + $this->log(" WOULD SET: {$name} (len: " . strlen($value) . ")"); + continue; + } + + $resp = $this->giteaApi( + 'PUT', + "{$api}/{$name}", + json_encode(['data' => $value]) + ); + + if ($resp['code'] >= 200 && $resp['code'] < 300) { + $this->log(" OK: {$name}"); + } else { + $this->log(" ERROR: {$name} (HTTP {$resp['code']})"); + $errors++; + } + } + + return $errors > 0 ? 1 : 0; + } + + private function setupMonitoring(): int + { + $this->log('[4/5] Setting up monitoring...'); + + $mon = $this->config['monitoring'] ?? []; + + if (empty($mon)) { + $this->log(' SKIP: No monitoring config'); + return 0; + } + + $dashFile = $mon['grafana_dashboard'] ?? ''; + + if ( + $dashFile !== '' && $this->grafanaUrl !== '' + && $this->grafanaToken !== '' + ) { + $this->pushGrafanaDashboard( + $dashFile, + $mon['grafana_folder'] ?? 'Clients' + ); + } + + $urls = $mon['urls'] ?? []; + $domains = $mon['domains'] ?? []; + $api = "/api/v1/repos/{$this->org}/{$this->repoName}" + . "/actions/variables"; + + if (!empty($urls)) { + $urlStr = implode("\n", $urls); + + if ($this->dryRun) { + $this->log(" WOULD SET: MONITORED_URLS"); + } else { + $this->setOrCreateVariable($api, 'MONITORED_URLS', $urlStr); + $this->log(' OK: MONITORED_URLS'); + } + } + + if (!empty($domains)) { + $domainStr = implode("\n", $domains); + + if ($this->dryRun) { + $this->log(" WOULD SET: MONITORED_DOMAINS"); + } else { + $this->setOrCreateVariable($api, 'MONITORED_DOMAINS', $domainStr); + $this->log(' OK: MONITORED_DOMAINS'); + } + } + + return 0; + } + + private function pushGrafanaDashboard(string $file, string $folder): void + { + if (!file_exists($file)) { + $this->log(" WARN: Dashboard not found: {$file}"); + return; + } + + if ($this->dryRun) { + $this->log(" WOULD PUSH: dashboard to \"{$folder}\""); + return; + } + + $dashboard = json_decode(file_get_contents($file), true); + + if (!is_array($dashboard)) { + $this->log(' ERROR: Invalid dashboard JSON'); + return; + } + + $folderId = $this->resolveGrafanaFolder($folder); + $dashboard['id'] = null; + + $resp = $this->grafanaApi( + 'POST', + '/api/dashboards/db', + json_encode([ + 'dashboard' => $dashboard, + 'folderId' => $folderId, + 'overwrite' => true, + ]) + ); + + if ($resp['code'] === 200) { + $data = json_decode($resp['body'], true); + $this->log(" OK: Dashboard (uid: " . ($data['uid'] ?? '?') . ")"); + } else { + $this->log(" ERROR: Dashboard push (HTTP {$resp['code']})"); + } + } + + private function resolveGrafanaFolder(string $title): int + { + $resp = $this->grafanaApi('GET', '/api/folders'); + + if ($resp['code'] !== 200) { + return 0; + } + + $folders = json_decode($resp['body'], true); + + if (!is_array($folders)) { + return 0; + } + + foreach ($folders as $f) { + if (strcasecmp($f['title'] ?? '', $title) === 0) { + return (int) ($f['id'] ?? 0); + } + } + + return 0; + } + + private function printSummary(): int + { + $vars = $this->config['variables'] ?? []; + $secrets = $this->config['secrets'] ?? []; + $clientName = $this->config['name'] ?? ''; + + $this->log(''); + $this->log('[5/5] Provisioning summary'); + $this->log(str_repeat('=', 60)); + $this->log(" Repo: {$this->giteaUrl}/{$this->org}/{$this->repoName}"); + $this->log(' Variables: ' . count($vars) . ' set'); + $this->log(' Secrets: ' . count($secrets) . ' set'); + $this->log(''); + $this->log('Next steps:'); + $this->log(' 1. Clone and customize the Joomla template'); + $this->log(' 2. Push to dev to trigger dev deployment'); + $this->log(' 3. Merge dev -> main for production release'); + $this->log(str_repeat('=', 60)); + + return 0; + } + + private function setOrCreateVariable( + string $api, + string $name, + string $value + ): bool { + $resp = $this->giteaApi( + 'PUT', + "{$api}/{$name}", + json_encode(['value' => $value]) + ); + + if ($resp['code'] === 404) { + $resp = $this->giteaApi( + 'POST', + $api, + json_encode(['name' => $name, 'value' => $value]) + ); + } + + return $resp['code'] >= 200 && $resp['code'] < 300; + } + + private function parseArgs(): void + { + $args = $_SERVER['argv'] ?? []; + $count = count($args); + + for ($i = 1; $i < $count; $i++) { + switch ($args[$i]) { + case '--config': + $this->configFile = $args[++$i] ?? ''; + break; + case '--step': + $this->step = $args[++$i] ?? ''; + break; + case '--dry-run': + $this->dryRun = true; + break; + case '--help': + case '-h': + $this->printUsage(); + exit(0); + default: + $this->log("WARNING: Unknown arg: {$args[$i]}"); + break; + } + } + } + + private function printUsage(): void + { + $this->log('Usage: client_provision.php --config [options]'); + $this->log(''); + $this->log('Provision a new client environment end-to-end.'); + $this->log(''); + $this->log('Options:'); + $this->log(' --config Client config JSON'); + $this->log(' --step Run one step: repo, variables, secrets, monitoring, summary'); + $this->log(' --dry-run Preview without changes'); + $this->log(' --help, -h Show this help'); + $this->log(''); + $this->log('Environment variables:'); + $this->log(' GA_TOKEN Gitea API token'); + $this->log(' GRAFANA_URL Grafana instance URL'); + $this->log(' GRAFANA_TOKEN Grafana API token'); + } + + private function giteaApi( + string $method, + string $endpoint, + ?string $body = null + ): array { + return $this->httpRequest( + $this->giteaUrl . $endpoint, + $method, + "token {$this->giteaToken}", + $body + ); + } + + private function grafanaApi( + string $method, + string $endpoint, + ?string $body = null + ): array { + return $this->httpRequest( + $this->grafanaUrl . $endpoint, + $method, + "Bearer {$this->grafanaToken}", + $body + ); + } + + private function httpRequest( + string $url, + string $method, + string $auth, + ?string $body = null + ): array { + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ + 'Content-Type: application/json', + 'Accept: application/json', + "Authorization: {$auth}", + ]); + + if ($body !== null) { + curl_setopt($ch, CURLOPT_POSTFIELDS, $body); + } + + $responseBody = curl_exec($ch); + $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + + if (curl_errno($ch)) { + $error = curl_error($ch); + curl_close($ch); + return ['code' => 0, 'body' => "cURL error: {$error}"]; + } + + curl_close($ch); + + return ['code' => $httpCode, 'body' => $responseBody]; + } + + private function log(string $message): void + { + fwrite(STDERR, $message . PHP_EOL); + } +} + +$app = new ClientProvision(); +exit($app->run()); diff --git a/templates/client-provision-example.json b/templates/client-provision-example.json new file mode 100644 index 0000000..39bbfa9 --- /dev/null +++ b/templates/client-provision-example.json @@ -0,0 +1,35 @@ +{ + "name": "exampleclient", + "org": "ExampleClient", + "gitea_url": "https://git.mokoconsulting.tech", + + "variables": { + "DEV_SYNC_HOST": "dev.exampleclient.com", + "DEV_SYNC_PORT": "22", + "DEV_SYNC_USER": "exampleclient", + "DEV_SYNC_PATH": "/home/exampleclient/dev.exampleclient.com", + "DEV_SITE_URL": "https://dev.exampleclient.com", + "LIVE_SSH_HOST": "iad1-shared-b7-01.dreamhost.com", + "LIVE_SSH_PORT": "22", + "LIVE_SSH_USER": "exampleclient", + "LIVE_SYNC_PATH": "/home/exampleclient/exampleclient.com", + "RS_FTP_PATH_SUFFIX": "exampleclient.com" + }, + + "secrets": { + "DEV_SYNC_KEY": "@keys/exampleclient-dev.pem", + "LIVE_SSH_KEY": "@keys/exampleclient-live.pem" + }, + + "monitoring": { + "urls": [ + "https://exampleclient.com", + "https://dev.exampleclient.com" + ], + "domains": [ + "exampleclient.com" + ], + "grafana_dashboard": "monitoring/grafana/client-joomla-dashboard.json", + "grafana_folder": "Clients" + } +}