├── .gitignore ├── composer.phar ├── phar-composer.phar ├── composer.json ├── Makefile ├── README.md ├── LICENSE ├── composer.lock └── ninespot.php /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | */.DS_Store 3 | /vendor/ 4 | ninespot.phar 5 | -------------------------------------------------------------------------------- /composer.phar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imos/ninespot/master/composer.phar -------------------------------------------------------------------------------- /phar-composer.phar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imos/ninespot/master/phar-composer.phar -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "imos/ninespot", 3 | "description": "Launch an EC2 instance on demand.", 4 | "type": "project", 5 | "bin": [ 6 | "ninespot.php" 7 | ], 8 | "authors": [ 9 | { 10 | "name": "imos", 11 | "email": "github@imoz.jp" 12 | } 13 | ], 14 | "require": { 15 | "league/climate": "^3.2", 16 | "pear/console_commandline": "^1.2" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | build: vendor/autoload.php 2 | php -d phar.readonly=Off phar-composer.phar build . 3 | chmod +x ninespot.phar 4 | .PHONY: build 5 | 6 | install: build 7 | sudo mkdir -p /usr/local/bin 8 | sudo cp ninespot.phar /usr/local/bin/ninespot 9 | .PHONY: install 10 | 11 | vendor/autoload.php: composer.json 12 | php composer.phar install 13 | 14 | clean: 15 | rm -rf vendor 16 | rm -rf ninespot.phar 17 | .PHONY: clean 18 | 19 | update: 20 | php composer.phar update 21 | .PHONY: update 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ninespot 2 | Launch a cloud machine on demand. 3 | 4 | ## How to use 5 | Please configure the default project using `gcloud config` beforehand. 6 | 7 | ```sh 8 | # Creates a disk image. This should be persistent. 9 | $ ninespot build --image=ubuntu 10 | # Prepare a machine. 11 | $ ninespot start --cpu=4 12 | # Runs "ls -lA". 13 | $ ninespot ls -lA 14 | # Delete a machine. Data on the disk should be kept. 15 | $ ninespot stop 16 | # Destroy a disk. 17 | $ ninespot destroy 18 | ``` 19 | 20 | Run `ninespot --help` for more details. 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Kentaro IMAJO 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /composer.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_readme": [ 3 | "This file locks the dependencies of your project to a known state", 4 | "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", 5 | "This file is @generated automatically" 6 | ], 7 | "content-hash": "b31b6853cd7b4534f4eddbe08ffcc123", 8 | "packages": [ 9 | { 10 | "name": "league/climate", 11 | "version": "3.2.1", 12 | "source": { 13 | "type": "git", 14 | "url": "https://github.com/thephpleague/climate.git", 15 | "reference": "b103fc8faa3780c802cc507d5f0ff534ecc94fb5" 16 | }, 17 | "dist": { 18 | "type": "zip", 19 | "url": "https://api.github.com/repos/thephpleague/climate/zipball/b103fc8faa3780c802cc507d5f0ff534ecc94fb5", 20 | "reference": "b103fc8faa3780c802cc507d5f0ff534ecc94fb5", 21 | "shasum": "" 22 | }, 23 | "require": { 24 | "php": ">=5.4.0", 25 | "seld/cli-prompt": "~1.0" 26 | }, 27 | "require-dev": { 28 | "mikey179/vfsstream": "~1.4", 29 | "mockery/mockery": "~0.9", 30 | "phpunit/phpunit": "~4.6" 31 | }, 32 | "type": "library", 33 | "autoload": { 34 | "psr-4": { 35 | "League\\CLImate\\": "src/" 36 | } 37 | }, 38 | "notification-url": "https://packagist.org/downloads/", 39 | "license": [ 40 | "MIT" 41 | ], 42 | "authors": [ 43 | { 44 | "name": "Joe Tannenbaum", 45 | "email": "hey@joe.codes", 46 | "homepage": "http://joe.codes/", 47 | "role": "Developer" 48 | } 49 | ], 50 | "description": "PHP's best friend for the terminal. CLImate allows you to easily output colored text, special formats, and more.", 51 | "keywords": [ 52 | "cli", 53 | "colors", 54 | "command", 55 | "php", 56 | "terminal" 57 | ], 58 | "time": "2016-04-04T20:24:59+00:00" 59 | }, 60 | { 61 | "name": "pear/console_commandline", 62 | "version": "v1.2.2", 63 | "source": { 64 | "type": "git", 65 | "url": "https://github.com/pear/Console_CommandLine.git", 66 | "reference": "7a8afa50bdc8dbfdc0cf394f1101106e8b8f8e67" 67 | }, 68 | "dist": { 69 | "type": "zip", 70 | "url": "https://api.github.com/repos/pear/Console_CommandLine/zipball/7a8afa50bdc8dbfdc0cf394f1101106e8b8f8e67", 71 | "reference": "7a8afa50bdc8dbfdc0cf394f1101106e8b8f8e67", 72 | "shasum": "" 73 | }, 74 | "require": { 75 | "ext-dom": "*", 76 | "ext-xml": "*", 77 | "pear/pear_exception": "^1.0.0", 78 | "php": ">=5.3.0" 79 | }, 80 | "require-dev": { 81 | "phpunit/phpunit": "*" 82 | }, 83 | "type": "library", 84 | "autoload": { 85 | "psr-0": { 86 | "Console": "./" 87 | } 88 | }, 89 | "notification-url": "https://packagist.org/downloads/", 90 | "include-path": [ 91 | "" 92 | ], 93 | "license": [ 94 | "MIT" 95 | ], 96 | "authors": [ 97 | { 98 | "name": "Richard Quadling", 99 | "email": "rquadling@gmail.com" 100 | }, 101 | { 102 | "name": "David Jean Louis", 103 | "email": "izimobil@gmail.com" 104 | } 105 | ], 106 | "description": "A full featured command line options and arguments parser.", 107 | "homepage": "https://github.com/pear/Console_CommandLine", 108 | "keywords": [ 109 | "console" 110 | ], 111 | "time": "2016-07-14T06:00:57+00:00" 112 | }, 113 | { 114 | "name": "pear/pear_exception", 115 | "version": "v1.0.0", 116 | "source": { 117 | "type": "git", 118 | "url": "https://github.com/pear/PEAR_Exception.git", 119 | "reference": "8c18719fdae000b690e3912be401c76e406dd13b" 120 | }, 121 | "dist": { 122 | "type": "zip", 123 | "url": "https://api.github.com/repos/pear/PEAR_Exception/zipball/8c18719fdae000b690e3912be401c76e406dd13b", 124 | "reference": "8c18719fdae000b690e3912be401c76e406dd13b", 125 | "shasum": "" 126 | }, 127 | "require": { 128 | "php": ">=4.4.0" 129 | }, 130 | "require-dev": { 131 | "phpunit/phpunit": "*" 132 | }, 133 | "type": "class", 134 | "extra": { 135 | "branch-alias": { 136 | "dev-master": "1.0.x-dev" 137 | } 138 | }, 139 | "autoload": { 140 | "psr-0": { 141 | "PEAR": "" 142 | } 143 | }, 144 | "notification-url": "https://packagist.org/downloads/", 145 | "include-path": [ 146 | "." 147 | ], 148 | "license": [ 149 | "BSD-2-Clause" 150 | ], 151 | "authors": [ 152 | { 153 | "name": "Helgi Thormar", 154 | "email": "dufuz@php.net" 155 | }, 156 | { 157 | "name": "Greg Beaver", 158 | "email": "cellog@php.net" 159 | } 160 | ], 161 | "description": "The PEAR Exception base class.", 162 | "homepage": "https://github.com/pear/PEAR_Exception", 163 | "keywords": [ 164 | "exception" 165 | ], 166 | "time": "2015-02-10T20:07:52+00:00" 167 | }, 168 | { 169 | "name": "seld/cli-prompt", 170 | "version": "1.0.3", 171 | "source": { 172 | "type": "git", 173 | "url": "https://github.com/Seldaek/cli-prompt.git", 174 | "reference": "a19a7376a4689d4d94cab66ab4f3c816019ba8dd" 175 | }, 176 | "dist": { 177 | "type": "zip", 178 | "url": "https://api.github.com/repos/Seldaek/cli-prompt/zipball/a19a7376a4689d4d94cab66ab4f3c816019ba8dd", 179 | "reference": "a19a7376a4689d4d94cab66ab4f3c816019ba8dd", 180 | "shasum": "" 181 | }, 182 | "require": { 183 | "php": ">=5.3" 184 | }, 185 | "type": "library", 186 | "extra": { 187 | "branch-alias": { 188 | "dev-master": "1.x-dev" 189 | } 190 | }, 191 | "autoload": { 192 | "psr-4": { 193 | "Seld\\CliPrompt\\": "src/" 194 | } 195 | }, 196 | "notification-url": "https://packagist.org/downloads/", 197 | "license": [ 198 | "MIT" 199 | ], 200 | "authors": [ 201 | { 202 | "name": "Jordi Boggiano", 203 | "email": "j.boggiano@seld.be" 204 | } 205 | ], 206 | "description": "Allows you to prompt for user input on the command line, and optionally hide the characters they type", 207 | "keywords": [ 208 | "cli", 209 | "console", 210 | "hidden", 211 | "input", 212 | "prompt" 213 | ], 214 | "time": "2017-03-18T11:32:45+00:00" 215 | } 216 | ], 217 | "packages-dev": [], 218 | "aliases": [], 219 | "minimum-stability": "stable", 220 | "stability-flags": [], 221 | "prefer-stable": false, 222 | "prefer-lowest": false, 223 | "platform": [], 224 | "platform-dev": [] 225 | } 226 | -------------------------------------------------------------------------------- /ninespot.php: -------------------------------------------------------------------------------- 1 | options[$key]) ? $flag->options[$key] : NULL; 50 | } 51 | 52 | public static function GetArguments() { return self::GetSingleton()->args; } 53 | public static function GetCommand() { return self::GetSingleton()->command; } 54 | 55 | function __construct() { 56 | $parser = new \Console_CommandLine([ 57 | 'description' => 'Run a command on an on-demand instance.', 58 | 'version' => '0.0.1', 59 | 'force_posix' => TRUE]); 60 | $parser->addArgument( 61 | 'args', 62 | ['multiple' => TRUE, 63 | 'optional' => TRUE, 64 | 'description' => 'Command-line arguments to run on an instance.']); 65 | $parser->addOption('debug', 66 | ['short_name' => '-d', 67 | 'long_name' => '--debug', 68 | 'action' => 'StoreTrue', 69 | 'default' => FALSE, 70 | 'description' => 'Debug mode.']); 71 | $parser->addOption('instance', 72 | ['short_name' => '-i', 73 | 'long_name' => '--instance', 74 | 'action' => 'StoreString', 75 | 'default' => 'default', 76 | 'description' => 'Instance name to manage.']); 77 | $parser->addOption('dry_run', 78 | ['short_name' => '-n', 79 | 'long_name' => '--dry-run', 80 | 'action' => 'StoreTrue', 81 | 'default' => FALSE, 82 | 'description' => 'Dry-run mode.']); 83 | $build = $parser->addCommand( 84 | 'build', 85 | ['description' => 'build a disk image.']); 86 | $build->addOption('force', 87 | ['short_name' => '-f', 88 | 'long_name' => '--force', 89 | 'action' => 'StoreTrue', 90 | 'default' => FALSE, 91 | 'description' => 'Forcefully build an image.']); 92 | $build->addOption('disk', 93 | ['short_name' => '-d', 94 | 'long_name' => '--disk', 95 | 'action' => 'StoreString', 96 | 'default' => 'auto', 97 | 'description' => 'Disk size.']); 98 | $build->addOption('image', 99 | ['short_name' => '-i', 100 | 'long_name' => '--image', 101 | 'action' => 'StoreString', 102 | 'default' => 'ubuntu', 103 | 'description' => 'Disk image.']); 104 | $build->addOption('zone', 105 | ['short_name' => '-z', 106 | 'long_name' => '--zone', 107 | 'action' => 'StoreString', 108 | 'default' => 'default']); 109 | $destroy = $parser->addCommand( 110 | 'destroy', 111 | ['description' => 'Delete an instance and a disk image.']); 112 | $start = $parser->addCommand( 113 | 'start', 114 | ['description' => 'Attach a machine.']); 115 | $start->addOption('cpu', 116 | ['short_name' => '-c', 117 | 'long_name' => '--cpu', 118 | 'action' => 'StoreInt', 119 | 'default' => 0, 120 | 'description' => 'Number of CPUs.']); 121 | $start->addOption('memory', 122 | ['short_name' => '-m', 123 | 'long_name' => '--memory', 124 | 'action' => 'StoreString', 125 | 'default' => '0B', 126 | 'description' => 'Memory size.']); 127 | $start->addOption('gpu', 128 | ['short_name' => '-g', 129 | 'long_name' => '--gpu', 130 | 'action' => 'StoreInt', 131 | 'default' => 0, 132 | 'description' => 'Number of GPUs.']); 133 | $start->addOption('preemptible', 134 | ['short_name' => '-p', 135 | 'long_name' => '--preemptible', 136 | 'action' => 'StoreTrue', 137 | 'default' => FALSE, 138 | 'description' => 'Request a preemptible machine.']); 139 | $stop = $parser->addCommand( 140 | 'stop', 141 | ['description' => 'Detach a machine.']); 142 | $sleep = $parser->addCommand( 143 | 'sleep', 144 | ['description' => 'Sleep a machine.']); 145 | $copy_files = $parser->addCommand( 146 | 'copy-files', 147 | ['description' => 'Copy files.']); 148 | $copy_files->addArgument( 149 | 'args', 150 | ['multiple' => TRUE, 151 | 'optional' => TRUE, 152 | 'description' => 'Files.']); 153 | $rsync = $parser->addCommand( 154 | 'rsync', 155 | ['description' => 'Copy files.']); 156 | $rsync->addArgument( 157 | 'args', 158 | ['multiple' => TRUE, 159 | 'optional' => TRUE, 160 | 'description' => 'Files.']); 161 | try { 162 | $result = $parser->parse(); 163 | $this->args = $result->args['args']; 164 | $this->options = $result->options; 165 | if ($result->command_name !== FALSE) { 166 | $this->command = $result->command_name; 167 | if (isset($result->command->args['args'])) { 168 | $this->args = array_merge( 169 | $this->args, $result->command->args['args']); 170 | } 171 | $this->options += $result->command->options; 172 | } 173 | Log::Debug('Command: ' . json_encode($this->command)); 174 | Log::Debug('Args: ' . json_encode($this->args)); 175 | Log::Debug('Options: ' . json_encode($this->options)); 176 | } catch (\Exception $e) { 177 | Log::Fatal($e->getMessage()); 178 | } 179 | } 180 | 181 | protected static function GetSingleton() { 182 | static $singleton = NULL; 183 | if (is_null($singleton)) { 184 | $singleton = new self; 185 | } 186 | return $singleton; 187 | } 188 | 189 | private $command = FALSE; 190 | private $args = []; 191 | private $options = []; 192 | } 193 | 194 | class Log { 195 | public static function Debug($message) 196 | { self::GetSingleton()->Emit(0, $message); } 197 | public static function Info($message) 198 | { self::GetSingleton()->Emit(1, $message); } 199 | public static function Warning($message) 200 | { self::GetSingleton()->Emit(2, $message); } 201 | public static function Error($message) 202 | { self::GetSingleton()->Emit(3, $message); } 203 | public static function Fatal($message) 204 | { self::GetSingleton()->Emit(4, $message); } 205 | 206 | protected function Emit($log_level, $message) { 207 | if ($log_level < $this->log_level) { return; } 208 | if ($this->log_level == 0) { 209 | $backtrace = debug_backtrace(0, 10); 210 | list($micros, $seconds) = 211 | array_map('floatval', explode(' ', microtime())); 212 | $message = date('md H:i:s', $seconds) . '.' . 213 | sprintf("%06d", floor($micros * 1e6)) . ' ' . 214 | getmypid() . ' ' . 215 | basename($backtrace[1]['file']) . 216 | ':' . $backtrace[1]['line'] . '] ' . $message; 217 | $message = ['I', 'I', 'W', 'E', 'F'][$log_level] . $message; 218 | } 219 | switch ($log_level) { 220 | case 0: $this->climate->info($message); break; 221 | case 1: $this->climate->info($message); break; 222 | case 2: $this->climate->warning($message); break; 223 | case 3: $this->climate->error($message); break; 224 | case 4: $this->climate->error($message); exit(1); 225 | } 226 | } 227 | 228 | function __construct() { 229 | $this->climate = new \League\CLImate\CLImate; 230 | } 231 | 232 | protected static function GetSingleton() { 233 | static $singleton = NULL; 234 | if (is_null($singleton)) { 235 | $singleton = new self; 236 | if (Flag::GetCommand() === FALSE) { 237 | $singleton->log_level = 2; 238 | } 239 | if (Flag::Get('debug')) { 240 | $singleton->log_level = 0; 241 | } 242 | } 243 | return $singleton; 244 | } 245 | 246 | private $climate = NULL; 247 | private $log_level = 1; 248 | } 249 | 250 | class Cache extends \SQLite3 { 251 | public static function Get($key, $expiration = 3600, $jitter = 0.5) { 252 | $cache_util = self::GetSingleton(); 253 | $stmt = $cache_util->prepare( 254 | 'SELECT * FROM cache WHERE cache_id = :cache_id'); 255 | $stmt->bindValue(':cache_id', $key, SQLITE3_TEXT); 256 | $result = $stmt->execute()->fetchArray(); 257 | if ($result === FALSE || $result['cache_expiration'] < time() || 258 | $result['cache_created'] + 259 | rand(ceil($expiration * (1 - $jitter)), $expiration) < time()) { 260 | return NULL; 261 | } 262 | return json_decode($result['cache_data'], TRUE); 263 | } 264 | 265 | public static function Set($key, $value, $expiration = 24 * 60 * 60) { 266 | $cache_util = self::GetSingleton(); 267 | if (is_null($value)) { 268 | $stmt = $cache_util->prepare( 269 | 'DELETE FROM cache WHERE cache_id = :cache_id'); 270 | $stmt->bindValue(':cache_id', $key, SQLITE3_TEXT); 271 | $stmt->execute(); 272 | } else { 273 | $stmt = $cache_util->prepare(' 274 | REPLACE INTO cache( 275 | cache_id, cache_data, cache_expiration, cache_created) 276 | VALUES(:cache_id, :cache_data, :cache_expiration, :cache_created)'); 277 | $stmt->bindValue(':cache_id', $key, SQLITE3_TEXT); 278 | $stmt->bindValue(':cache_data', json_encode($value), SQLITE3_BLOB); 279 | $stmt->bindValue( 280 | ':cache_expiration', time() + $expiration, SQLITE3_INTEGER); 281 | $stmt->bindValue(':cache_created', time(), SQLITE3_INTEGER); 282 | $stmt->execute(); 283 | } 284 | } 285 | 286 | function __construct() { 287 | $path = getenv('HOME') . '/.cache/ninespot.db'; 288 | @mkdir(dirname($path), 0755, TRUE); 289 | $this->open($path); 290 | $this->exec('CREATE TABLE IF NOT EXISTS cache( 291 | cache_id TEXT PRIMARY KEY, 292 | cache_data BLOB, 293 | cache_expiration INTEGER, 294 | cache_created INTEGER)'); 295 | $this->exec('CREATE INDEX IF NOT EXISTS 296 | cache_expiration ON cache(cache_expiration)'); 297 | $stmt = $this->prepare( 298 | 'DELETE FROM cache WHERE cache_expiration < :current_time'); 299 | $stmt->bindValue(':current_time', time(), SQLITE3_INTEGER); 300 | $stmt->execute(); 301 | } 302 | 303 | protected static function GetSingleton() { 304 | static $singleton = NULL; 305 | if (is_null($singleton)) $singleton = new self; 306 | return $singleton; 307 | } 308 | } 309 | 310 | class Gcloud { 311 | public static function Execute($command, $dry_run = FALSE) { 312 | if (is_array($command)) { 313 | $command = implode(' ', array_map('escapeshellarg', $command)); 314 | } 315 | $return = -1; 316 | if ($dry_run) { 317 | echo "gcloud $command\n"; 318 | return NULL; 319 | } 320 | Log::Debug("Executing gcloud command: gcloud $command"); 321 | exec("gcloud --format=json $command", $output, $return); 322 | if ($return != 0) { 323 | throw new \Exception("Failed to execute command: $command\n"); 324 | } 325 | if ($output == '') { 326 | return NULL; 327 | } 328 | return json_decode(implode("\n", $output), TRUE); 329 | } 330 | 331 | public static function ListImages() { 332 | $result = []; 333 | foreach (self::ListImagesInternal() as $image) { 334 | $result[$image['name']] = [ 335 | 'name' => $image['name'], 336 | 'project' => 337 | Util::GetMatched('%projects/([^/]+)%', $image['selfLink']), 338 | 'size' => intval($image['diskSizeGb']) * 1024 * 1024 * 1024]; 339 | if (isset($image['family'])) { 340 | $result[$image['family']] = $result[$image['name']]; 341 | } 342 | } 343 | ksort($result); 344 | $versions = []; 345 | foreach ($result as $name => $value) { 346 | if (preg_match('%^(\w+)-(\d+)(|-lts|-stable)$%', 347 | $name, $match)) { 348 | $version = intval($match[2]); 349 | if (strlen($match[3]) > 0) { $version += 1000000; } 350 | if (isset($versions[$match[1]]) && $versions[$match[1]] > $version) { 351 | continue; 352 | } 353 | $result[$match[1]] = $value; 354 | $versions[$match[1]] = $version; 355 | } 356 | } 357 | ksort($result); 358 | return $result; 359 | } 360 | 361 | public static function ListZones() { 362 | $result = []; 363 | foreach (self::ListRegionsInternal() as $region) { 364 | foreach ($region['zones'] as $zone_address) { 365 | $zone = Util::GetMatched('%zones/([^/]+)%', $zone_address); 366 | $result[$zone] = ['name' => $zone]; 367 | } 368 | } 369 | ksort($result); 370 | foreach ($result as $name => $value) { 371 | if (preg_match('%^(.*)-a$%', $name, $match)) { 372 | $result[$match[1]] = $value; 373 | } 374 | } 375 | return $result; 376 | } 377 | 378 | public static function ListMachineTypes($zone) { 379 | $result = []; 380 | foreach (self::ListMachineTypesInternal() as $machine_type) { 381 | if ($machine_type['zone'] != $zone) continue; 382 | $result[$machine_type['name']] = [ 383 | 'name' => $machine_type['name'], 384 | 'cpu' => $machine_type['isSharedCpu'] 385 | ? 0 : $machine_type['guestCpus'], 386 | 'memory' => $machine_type['memoryMb'] * 1024 * 1024, 387 | 'score' => $machine_type['guestCpus'] * 0.0333174 + 388 | $machine_type['memoryMb'] / 1024 * 0.004446]; 389 | } 390 | uasort($result, function($a, $b) { 391 | $d = $a['score'] - $b['score']; 392 | if ($d > 0) return 1; 393 | if ($d < 0) return -1; 394 | return 0; 395 | }); 396 | return $result; 397 | } 398 | 399 | public static function GetInstanceInfo($instance, $expiration = 5 * 60) { 400 | $instance_info = 401 | Cache::Get('instances/' . $instance . ':instance', $expiration); 402 | if (is_null($instance_info)) { 403 | $instances = self::Execute([ 404 | 'compute', 'instances', 'list', '--filter=name~' . $instance]); 405 | $instance_info = NULL; 406 | if (count($instances) > 1) { 407 | Log::Fatal('Instance "' . $instance . '" is duplicated.'); 408 | } else if (count($instances) == 1) { 409 | $instance_info = $instances[0]; 410 | } 411 | self::SetInstanceInfo($instance, $instance_info); 412 | } 413 | return $instance_info; 414 | } 415 | 416 | public static function SetInstanceInfo($instance, $instance_info = NULL) { 417 | Cache::Set('instances/' . $instance . ':instance', $instance_info, 5 * 60); 418 | } 419 | 420 | public static function GetZone($instance, $expiration = 60 * 60) { 421 | $zone = Cache::Get('instances/' . $instance . ':zone', $expiration); 422 | if (is_null($zone)) { 423 | $disks = self::Execute([ 424 | 'compute', 'disks', 'list', '--filter=name~' . $instance]); 425 | if (count($disks) > 1) { 426 | Log::Fatal('Instance "' . $instance . '" is duplicated.'); 427 | } else if (count($disks) == 1) { 428 | $zone = Util::GetMatched('%/zones/([^/]+)%', $disks[0]['zone']); 429 | } 430 | self::SetZone($instance, $zone); 431 | } 432 | return $zone; 433 | } 434 | 435 | public static function SetZone($instance, $zone = NULL) { 436 | self::ClearFeed($instance); 437 | Cache::Set('instances/' . $instance . ':zone', $zone, 60 * 60); 438 | } 439 | 440 | public static function Feed($instance) { 441 | if (Flag::Get('dry_run')) return TRUE; 442 | $cache_key = 'instances/' . $instance . ':feed'; 443 | if (Cache::Get($cache_key, 120) === TRUE) { 444 | return TRUE; 445 | } 446 | Log::Info('Feeding a machine: ' . $instance); 447 | exec('gcloud compute --quiet ssh ' . $instance . 448 | ' --ssh-flag=-q --zone=' . self::GetZone($instance) . 449 | ' -- sudo touch /var/run/ninespot.lock >/dev/null 2>/dev/null', 450 | $output, $return); 451 | if ($return == 0) { 452 | Cache::Set($cache_key, TRUE, 300); 453 | } else { 454 | Log::Info('Failed to feed a machine: ' . $instance); 455 | } 456 | return $return == 0; 457 | } 458 | 459 | public static function ClearFeed($instance) { 460 | if (Flag::Get('dry_run')) return; 461 | Cache::Set('instances/' . $instance . ':feed', NULL); 462 | } 463 | 464 | public static function MachineExists($instance) { 465 | if (Flag::Get('dry_run')) return TRUE; 466 | $instances = self::Execute( 467 | ['compute', 'instances', 'list', '--filter=name~' . $instance]); 468 | return count($instances) > 0; 469 | } 470 | 471 | // private: 472 | private static function ListImagesInternal() { 473 | return self::GetCacheOrExecute( 474 | 'list-images', ['compute', 'images', 'list'], 7 * 24 * 60 * 60); 475 | } 476 | 477 | private static function ListRegionsInternal() { 478 | return self::GetCacheOrExecute( 479 | 'list-regions', ['compute', 'regions', 'list'], 7 * 24 * 60 * 60); 480 | } 481 | 482 | private static function ListMachineTypesInternal() { 483 | return self::GetCacheOrExecute( 484 | 'list-machine-types', ['compute', 'machine-types', 'list'], 485 | 7 * 24 * 60 * 60); 486 | } 487 | 488 | private static function GetCacheOrExecute( 489 | $key, $command, $expiration = 3600) { 490 | $data = Cache::Get($key, $expiration); 491 | if ($data === NULL) { 492 | $data = self::Execute($command); 493 | Cache::Set($key, $data, $expiration * 2); 494 | } 495 | return $data; 496 | } 497 | } 498 | 499 | class NinespotBuild { 500 | public function __construct() { 501 | Log::Debug('Build mode.'); 502 | $this->instance = Flag::Get('instance'); 503 | Log::Info('Instance name is ' . $this->instance . '.'); 504 | $this->image = $this->GetImage(); 505 | Log::Info('Image template is ' . 506 | $this->image['project'] . '/' . $this->image['name'] . '.'); 507 | $this->zone = $this->GetZone(); 508 | Log::Info('Zone is ' . $this->zone['name'] . '.'); 509 | $this->disk_size = $this->GetDiskSize(); 510 | Log::Info('Disk size is ' . 511 | ceil($this->disk_size / 1024 / 1024 / 1024) . 'GB.'); 512 | Log::Debug('Build parameters: ' . json_encode($this)); 513 | } 514 | 515 | public function Execute() { 516 | $existing_zone = Gcloud::GetZone($this->instance, 0); 517 | if (!is_null($existing_zone)) { 518 | if ($existing_zone == $this->zone['name']) { 519 | Log::Fatal('Disk already exists: ' . $this->instance); 520 | } else { 521 | Log::Fatal( 522 | 'Disk already exists in a different zone: ' . $existing_zone); 523 | } 524 | } 525 | try { 526 | Gcloud::Execute( 527 | ['compute', 'disks', 'create', $this->instance, 528 | '--type=pd-ssd', '--image=' . $this->image['name'], 529 | '--image-project=' . $this->image['project'], 530 | '--zone=' . $this->zone['name'], 531 | '--size=' . ceil($this->disk_size / 1024 / 1024 / 1024) . 'GB'], 532 | Flag::Get('dry_run')); 533 | } catch (\Exception $e) { 534 | Log::Fatal('Failed to build a disk: ' . $this->instance); 535 | } 536 | if (!Flag::Get('dry_run')) { 537 | Gcloud::SetZone($this->instance, $this->zone['name']); 538 | } 539 | return 0; 540 | } 541 | 542 | // private: 543 | private function GetImage() { 544 | $images = Gcloud::ListImages(); 545 | if (!isset($images[Flag::Get('image')])) { 546 | Log::Info('image must be one of: ' . 547 | implode(', ', array_keys($images))); 548 | Log::Fatal('No such image: ' . Flag::Get('image')); 549 | } 550 | return $images[Flag::Get('image')]; 551 | } 552 | 553 | private function GetZone() { 554 | $zones = Gcloud::ListZones(); 555 | if (!isset($zones[Flag::Get('zone')])) { 556 | Log::Info('zone must be one of: ' . 557 | implode(', ', array_keys($zones))); 558 | Log::Fatal('No such zone: ' . Flag::Get('zone')); 559 | } 560 | return $zones[Flag::Get('zone')]; 561 | } 562 | 563 | private function GetDiskSize() { 564 | $size = Util::ParseSize(Flag::Get('disk')); 565 | if (is_null($size)) { 566 | Log::Info( 567 | 'Disk size is automatically determined by the template image size.'); 568 | return $this->image['size']; 569 | } 570 | if ($size < $this->image['size']) { 571 | Log::Fatal('Disk size is smaller than the template image size: ' . 572 | $size . ' bytes < ' . $this->image['size'] . ' bytes.'); 573 | } 574 | return $size; 575 | } 576 | 577 | public $instance = NULL; 578 | public $image = NULL; 579 | public $zone = NULL; 580 | public $disk_size = 0; 581 | } 582 | 583 | class NinespotDestroy { 584 | public function __construct() { 585 | Log::Debug('Destroy mode.'); 586 | $this->instance = Flag::Get('instance'); 587 | } 588 | 589 | public function Execute() { 590 | $zone = Gcloud::GetZone($this->instance); 591 | try { 592 | Gcloud::Execute( 593 | ['compute', 'disks', 'delete', '--quiet', 594 | $this->instance, '--zone=' . $zone], 595 | Flag::Get('dry_run')); 596 | } catch (\Exception $e) { 597 | Log::Fatal('Failed to destroy a disk: ' . $this->instance); 598 | } 599 | if (!Flag::Get('dry_run')) { 600 | Gcloud::SetZone($this->instance, NULL); 601 | } 602 | } 603 | 604 | public $instance = NULL; 605 | } 606 | 607 | class NinespotStart { 608 | public function __construct() { 609 | Log::Debug('Start mode.'); 610 | $this->instance = Flag::Get('instance'); 611 | Log::Info('Instance name is ' . $this->instance . '.'); 612 | $this->is_preemptible = Flag::Get('is_preemptible'); 613 | Log::Info('Instance is ' . 614 | ($this->is_preemptible ? 'preemptible' : 'not preemptible') . 615 | '.'); 616 | $this->cpu = Flag::Get('cpu'); 617 | Log::Info('Minimum number of CPUs is ' . $this->cpu . '.'); 618 | $this->memory = $this->GetMemorySize(); 619 | Log::Info('Minimum size of memory is ' . $this->memory . ' bytes.'); 620 | $this->gpu = Flag::Get('gpu'); 621 | Log::Info('Number of GPUs is ' . $this->gpu . '.'); 622 | $this->zone = Gcloud::GetZone($this->instance); 623 | Log::Info('Instance\'s zone is ' . $this->zone . '.'); 624 | $this->machine_type = $this->GetMachineType($this->zone); 625 | if (is_null($this->machine_type)) { 626 | Log::Fatal( 627 | 'No machine satisfying the requirements is available in the zone.'); 628 | } 629 | Log::Info('Selected machine type is ' . $this->machine_type['name'] . 630 | ' (# of CPUs: ' . $this->machine_type['cpu'] . 631 | ', Memory: ' . round($this->machine_type['memory'] 632 | / 1024 / 1024 /1024, 3) . 'GB).'); 633 | } 634 | 635 | public function Execute() { 636 | try { 637 | $startup_script = << /etc/cron.d/ninespot 640 | echo 'PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin' >> /etc/cron.d/ninespot 641 | echo '* * * * * root if find /var/run/crond.pid -mmin +10 | grep /var/run/crond.pid && find /var/run/ninespot.lock -mmin +10 | grep /var/run/ninespot.lock; then shutdown now; fi' >> /etc/cron.d/ninespot 642 | EOM; 643 | $args = ['compute', 'instances', 'create', $this->instance]; 644 | $args[] = '--zone=' . $this->zone; 645 | $args[] = '--machine-type=' . $this->machine_type['name']; 646 | if ($this->is_preemptible) { 647 | $args[] = '--preemptible'; 648 | } 649 | $args[] = '--metadata=startup-script=' . $startup_script; 650 | $args[] = '--disk=name=' . $this->instance . 651 | ',device-name=' . $this->instance . ',mode=rw,boot=yes'; 652 | if ($this->gpu > 0) { 653 | $args[] = '--accelerator=type=nvidia-tesla-k80,count=' . $this->gpu; 654 | $args[] = '--maintenance-policy=TERMINATE'; 655 | } 656 | Gcloud::Execute($args, Flag::Get('dry_run')); 657 | } catch (\Exception $e) { 658 | Log::Fatal('Failed to create a machine: ' . $this->instance); 659 | } 660 | if (!Flag::Get('dry_run')) { 661 | Gcloud::SetZone($this->instance, NULL); 662 | } 663 | } 664 | 665 | private function GetMachineType($zone) { 666 | foreach (Gcloud::ListMachineTypes($zone) as $machine_type) { 667 | if ($machine_type['cpu'] >= $this->cpu && 668 | $machine_type['memory'] >= $this->memory) { 669 | return $machine_type; 670 | } 671 | } 672 | return NULL; 673 | } 674 | 675 | private function GetMemorySize() { 676 | $size = Util::ParseSize(Flag::Get('memory')); 677 | return is_null($size) ? 0 : $size; 678 | } 679 | 680 | public $instance = NULL; 681 | public $cpu = 0; 682 | public $memory = 0; 683 | public $gpu = 0; 684 | public $zone = NULL; 685 | } 686 | 687 | class NinespotStop { 688 | public function __construct() { 689 | Log::Debug('Stop mode.'); 690 | $this->instance = Flag::Get('instance'); 691 | Log::Info('Instance name is ' . $this->instance . '.'); 692 | $this->zone = Gcloud::GetZone($this->instance); 693 | Log::Info('Instance\'s zone is ' . $this->zone . '.'); 694 | } 695 | 696 | public function Execute() { 697 | try { 698 | Gcloud::ClearFeed($this->instance); 699 | Gcloud::Execute( 700 | ['compute', 'instances', 'delete', '--quiet', $this->instance, 701 | '--zone=' . $this->zone, '--keep-disks=all'], 702 | Flag::Get('dry_run')); 703 | Gcloud::ClearFeed($this->instance); 704 | } catch (\Exception $e) { 705 | Log::Fatal('Failed to delete a machine: ' . $this->instance); 706 | } 707 | } 708 | 709 | public $instance = NULL; 710 | public $zone = NULL; 711 | } 712 | 713 | class NinespotCopy { 714 | public function __construct() { 715 | Log::Debug('Copy mode.'); 716 | foreach (Flag::GetArguments() as $argument) { 717 | if (preg_match('%^(?:[^@]*@)?([^:]*):%', $argument, $match)) { 718 | $this->instance = $match[1]; 719 | break; 720 | } 721 | } 722 | if ($this->instance == NULL) { 723 | Log::Fatal('Instance is required.'); 724 | } 725 | Log::Info('Instance name is ' . $this->instance . '.'); 726 | $this->zone = Gcloud::GetZone($this->instance); 727 | Log::Info('Instance\'s zone is ' . $this->zone . '.'); 728 | } 729 | 730 | public function Execute() { 731 | try { 732 | if (!Gcloud::Feed($this->instance)) { 733 | Log::Fatal('No such instance: ' . $this->instance); 734 | } 735 | Gcloud::Execute( 736 | array_merge(['compute', 'scp', '--recurse', '--zone=' . $this->zone], 737 | Flag::GetArguments()), 738 | Flag::Get('dry_run')); 739 | } catch (\Exception $e) { 740 | Log::Fatal('Failed to copy files: ' . $this->instance); 741 | } 742 | } 743 | 744 | public $instance = NULL; 745 | public $zone = NULL; 746 | } 747 | 748 | class NinespotRsync { 749 | public function __construct() { 750 | Log::Debug('Rsync mode.'); 751 | $arguments = []; 752 | foreach (Flag::GetArguments() as $argument) { 753 | if (preg_match('%^((?:[^@]*@)?)([^:]*)(:.*$)%', $argument, $match)) { 754 | $instance = $argument[2]; 755 | $instance_info = Gcloud::GetInstanceInfo($instance); 756 | $ip_address = 757 | $instance_info['networkInterfaces'][0]['accessConfigs'][0]['natIP'] 758 | ?: NULL; 759 | if ($ip_address == NULL) { 760 | Log::Fatal("IP address is not unknown: $instance"); 761 | } 762 | $arguments[] = "{$match[1]}{$ip_address}{$match[3]}"; 763 | } else { 764 | $arguments[] = $argument; 765 | } 766 | } 767 | $this->arguments = $arguments; 768 | Log::Info('Arguments are ' . implode(' ', $this->arguments) . '.'); 769 | } 770 | 771 | public function Execute() { 772 | $arguments = array_map('escapeshellarg', 773 | array_merge(['rsync', '-az', '--delete', 774 | '-e', 'ssh -i ~/.ssh/google_compute_engine'], 775 | $this->arguments)); 776 | exec(implode(' ', $arguments), $output, $return); 777 | } 778 | 779 | public $arguments = NULL; 780 | } 781 | 782 | class NinespotSleep { 783 | public function __construct() { 784 | Log::Debug('Sleep mode.'); 785 | $this->instance = Flag::Get('instance'); 786 | Log::Info('Instance name is ' . $this->instance . '.'); 787 | $this->zone = Gcloud::GetZone($this->instance); 788 | Log::Info('Instance\'s zone is ' . $this->zone . '.'); 789 | } 790 | 791 | public function Execute() { 792 | try { 793 | Gcloud::ClearFeed($this->instance); 794 | Gcloud::Execute( 795 | ['compute', 'instances', 'stop', '--quiet', $this->instance, 796 | '--zone=' . $this->zone], 797 | Flag::Get('dry_run')); 798 | Gcloud::ClearFeed($this->instance); 799 | } catch (\Exception $e) { 800 | Log::Fatal('Failed to sleep a machine: ' . $this->instance); 801 | } 802 | } 803 | 804 | public $instance = NULL; 805 | public $zone = NULL; 806 | } 807 | 808 | class NinespotRun { 809 | public function __construct() { 810 | Log::Debug('Run mode.'); 811 | $this->instance = Flag::Get('instance'); 812 | Log::Info('Instance name is ' . $this->instance . '.'); 813 | $this->zone = Gcloud::GetZone($this->instance); 814 | Log::Info('Instance\'s zone is ' . $this->zone . '.'); 815 | } 816 | 817 | public function Execute() { 818 | if (!Gcloud::Feed($this->instance)) { 819 | if (!Gcloud::MachineExists($this->instance)) { 820 | Log::Fatal('No such machine exists: ' . $this->instance); 821 | } 822 | $success = FALSE; 823 | for ($i = 0; $i < 2; $i++) { 824 | $this->Start(); 825 | if (Gcloud::Feed($this->instance)) { 826 | $success = TRUE; 827 | break; 828 | } 829 | sleep(10); 830 | } 831 | if (!$success) { 832 | Log::Fatal('Failed to boot a machine: ' . $this->instance); 833 | } 834 | } 835 | $ppid = getmypid(); 836 | $pid = \pcntl_fork(); 837 | if ($pid < 0) { 838 | Log::Fatal('Failed to fork.'); 839 | } 840 | if ($pid == 0) { 841 | fclose(STDIN); 842 | Log::Debug('Child process started.'); 843 | while (posix_kill($ppid, 0)) { 844 | Gcloud::Feed($this->instance); 845 | sleep(10); 846 | } 847 | exit(0); 848 | } 849 | try { 850 | $command = array_merge( 851 | ['gcloud', 'compute', 'ssh', $this->instance, 852 | '--ssh-flag=-q', 853 | '--zone=' . $this->zone], 854 | count(Flag::GetArguments()) > 0 855 | ? ['--', implode(' ', Flag::GetArguments())] : []); 856 | if (Flag::Get('dry_run')) { 857 | echo implode(' ', array_map('escapeshellarg', $command)) . "\n"; 858 | } else { 859 | Log::Debug('Executing: ' . 860 | implode(' ', array_map('escapeshellarg', $command))); 861 | pcntl_exec('/usr/bin/env', $command); 862 | } 863 | } catch (\Exception $e) { 864 | Log::Fatal('Failed to run a command on: ' . $this->instance); 865 | } 866 | } 867 | 868 | public function Start() { 869 | Log::Info('Booting a machine: ' . $this->instance); 870 | exec('gcloud compute --quiet instances start ' . $this->instance . 871 | ' --zone=' . $this->zone . ' 2>/dev/null', 872 | $output, $return); 873 | return $return == 0; 874 | } 875 | 876 | public $instance = NULL; 877 | public $zone = NULL; 878 | } 879 | 880 | class Ninespot { 881 | public static function Main() { 882 | switch (Flag::GetCommand()) { 883 | case 'build': return (new NinespotBuild())->Execute(); 884 | case 'destroy': return (new NinespotDestroy())->Execute(); 885 | case 'start': return (new NinespotStart())->Execute(); 886 | case 'stop': return (new NinespotStop())->Execute(); 887 | case 'sleep': return (new NinespotSleep())->Execute(); 888 | case 'copy-files': return (new NinespotCopy())->Execute(); 889 | case 'rsync': return (new NinespotRsync())->Execute(); 890 | default: return (new NinespotRun())->Execute(); 891 | } 892 | } 893 | } 894 | 895 | exit(Ninespot::Main()); 896 | --------------------------------------------------------------------------------