PageRenderTime 43ms CodeModel.GetById 13ms RepoModel.GetById 0ms app.codeStats 0ms

/scripts/update.php

http://github.com/jquery/testswarm
PHP | 477 lines | 370 code | 55 blank | 52 comment | 45 complexity | 5eec8479cf5ee05b80a8a9e69ca9410e MD5 | raw file
  1. <?php
  2. /**
  3. * Update database.
  4. *
  5. * @author Timo Tijhof
  6. * @since 1.0.0
  7. * @package TestSwarm
  8. */
  9. define( 'SWARM_ENTRY', 'SCRIPT' );
  10. require_once __DIR__ . '/../inc/init.php';
  11. class DBUpdateScript extends MaintenanceScript {
  12. // Versions that involve database changes
  13. protected static $updates = array(
  14. '1.0.0-alpha',
  15. );
  16. protected function init() {
  17. $this->setDescription(
  18. 'Update the TestSwarm database from a past state to the current version.'
  19. . ' Depending on the version, some data can not be preserved. More information'
  20. . ' will be provided and a confirmation will be required if that is the case.'
  21. );
  22. $this->registerOption( 'quick', 'boolean', 'Skips questions and run the updater unconditionally.' );
  23. }
  24. protected function execute() {
  25. global $swarmInstallDir;
  26. $currentVersionFile = "$swarmInstallDir/config/version.ini";
  27. if ( !is_readable( $currentVersionFile ) ) {
  28. throw new SwarmException( 'version.ini is missing or unreadable.' );
  29. }
  30. $currentVersion = trim( file_get_contents( $currentVersionFile ) );
  31. if ( $this->getOption( 'quick' ) ) {
  32. $this->doDatabaseUpdates();
  33. return;
  34. }
  35. $this->out( 'From which version are you upgrading? (leave empty or use --quick option to skip this)' );
  36. $originVersion = $this->cliInput();
  37. if ( !$originVersion ) {
  38. $originVersion = '(auto...)';
  39. }
  40. // 1 => 1.0.0
  41. $originVersion = explode( '.', $originVersion );
  42. if ( !isset( $originVersion[1] ) ) {
  43. $originVersion[] = '0';
  44. }
  45. if ( !isset( $originVersion[2] ) ) {
  46. $originVersion[] = '0';
  47. }
  48. $originVersion = implode( '.', $originVersion );
  49. $this->out(
  50. "Update origin: $originVersion\n"
  51. . "Current software version: $currentVersion"
  52. );
  53. $scheduledUpdates = array();
  54. $scheduledUpdatesStr = '';
  55. $prev = $originVersion;
  56. foreach ( self::$updates as $updateTarget ) {
  57. if ( version_compare( $updateTarget, $originVersion, '>' ) ) {
  58. $scheduledUpdates[] = $updateTarget;
  59. $scheduledUpdatesStr .= "* $prev -> $updateTarget\n";
  60. $prev = $updateTarget;
  61. }
  62. }
  63. if ( !count( $scheduledUpdates ) ) {
  64. $this->out( "No updates found for $originVersion." );
  65. $this->out( 'Do you want to run the updater anyway (use --quick to skip this)? (Y/N)' );
  66. $quick = $this->cliInput();
  67. if ( $quick === 'Y' ) {
  68. $this->doDatabaseUpdates();
  69. }
  70. return;
  71. }
  72. $this->out( "Update paths:\n" . $scheduledUpdatesStr );
  73. $this->doDatabaseUpdates();
  74. }
  75. /**
  76. * The actual database updates
  77. * Friendly reminder from http://dev.mysql.com/doc/refman/5.1/en/alter-table.html
  78. * - Column name must be mentioned twice in ALTER TABLE CHANGE
  79. * - Definition must be complete
  80. * So 'CHANGE foo BIGINT' on a 'foo INT UNSIGNED DEFAULT 1' will remove
  81. * the default and unsigned property.
  82. * Except for PRIMARY KEY or UNIQUE properties, those must never be
  83. * part of a CHANGE clause.
  84. */
  85. protected function doDatabaseUpdates() {
  86. if ( $this->getContext()->dbLock() ) {
  87. $this->error( 'Database is currently locked, please remove ./cache/database.lock before updating.' );
  88. }
  89. $db = $this->getContext()->getDB();
  90. $this->out( 'Setting database.lock, other requests may not access the database during the update.' );
  91. $this->getContext()->dbLock( true );
  92. $this->out( 'Running tests on the database to detect which updates are needed.' );
  93. /**
  94. * 0.2.0 -> 1.0.0-alpha (patch-new-ua-runresults.sql)
  95. * useragents and run_client table removed, many column changes, new runresults table.
  96. */
  97. // If the previous version was before 1.0.0 we won't offer an update, because most
  98. // changes in 1.0.0 can't be simulated without human intervention. The changes are not
  99. // backwards compatible. Instead do a few quick checks to verify this is in fact a
  100. // pre-1.0.0 database, then ask the user for a re-install from scratch
  101. // (except for the users table).
  102. $has_run_client = $db->tableExists( 'run_client' );
  103. $has_users_request = $db->fieldExists( 'users', 'request' );
  104. $clients_useragent_id = $db->fieldInfo( 'clients', 'useragent_id' );
  105. if ( !$clients_useragent_id ) {
  106. $this->unknownDatabaseState( 'clients.useragent_id not found' );
  107. return;
  108. }
  109. $mysql_type_varchar = 253;
  110. if ( !$has_run_client
  111. && !$has_users_request
  112. // https://dev.mysql.com/doc/internals/en/myisam-column-attributes.html
  113. // https://secure.php.net/mysqli_fetch_field_direct#85771
  114. && $clients_useragent_id->type === $mysql_type_varchar
  115. ) {
  116. $this->out( '... run_client table already dropped' );
  117. $this->out( '... users.request already dropped' );
  118. $this->out( '... client.useragent_id is up to date' );
  119. } else {
  120. $this->out(
  121. "\n"
  122. . "It appears this database is from before 1.0.0. No update exists for those versions.\n"
  123. . "The updater could re-install TestSwarm (optionally preserving user accounts)\n"
  124. . "THIS WILL DELETE ALL DATA.\nContinue? (Y/N)" );
  125. $reinstall = $this->cliInput();
  126. if ( $reinstall !== 'Y' ) {
  127. // Nothing left to do. Remove database.lock and abort the script
  128. $this->getContext()->dbLock( false );
  129. return;
  130. }
  131. $this->out( "Import user names and tokens from the old database after re-installing?\n"
  132. . "(Note: password and seed cannot be restored due to incompatibility in the database.\n"
  133. . ' Instead the auth token will be used as the the new password) (Y/N)' );
  134. $reimportUsers = $this->cliInput();
  135. // Drop all known TestSwarm tables in the database
  136. // (except users, handled separately)
  137. foreach ( array(
  138. 'run_client', // Removed in 1.0.0
  139. 'clients',
  140. 'run_useragent',
  141. 'useragents', // Removed in 1.0.0
  142. 'runs',
  143. 'jobs',
  144. ) as $dropTable ) {
  145. $this->outRaw( "Dropping $dropTable table..." );
  146. $exists = $db->tableExists( $dropTable );
  147. if ( $exists ) {
  148. $dropped = $db->query( 'DROP TABLE ' . $db->addIdentifierQuotes( $dropTable ) );
  149. $this->out( ' ' . ($dropped ? 'OK' : 'FAILED' ) );
  150. } else {
  151. $this->out( 'SKIPPED (didn\'t exist)' );
  152. }
  153. }
  154. // Handle users table (reimport or drop as well)
  155. $userRows = array();
  156. if ( $reimportUsers === 'Y' ) {
  157. $this->out( 'Upgrading users table' );
  158. $this->outRaw( 'Fetching current users...' );
  159. $has_users = $db->tableExists( 'users' );
  160. if ( !$has_users ) {
  161. $this->out( 'SKIPPED (users table didn\'t exist)' );
  162. } else {
  163. $userRows = $db->getRows( 'SELECT * FROM users' );
  164. $this->out( 'OK' );
  165. }
  166. }
  167. $this->outRaw( 'Dropping users table...' );
  168. $dropped = $db->query( 'DROP TABLE users' );
  169. $this->out( ' ' . ($dropped ? 'OK' : 'FAILED') );
  170. // Create new tables
  171. $this->outRaw( 'Creating new tables... (this may take a few minutes)' );
  172. global $swarmInstallDir;
  173. $fullSchemaFile = "$swarmInstallDir/config/tables.sql";
  174. if ( !is_readable( $fullSchemaFile ) ) {
  175. $this->error( 'Can\'t read schema file' );
  176. }
  177. $fullSchemaSql = file_get_contents( $fullSchemaFile );
  178. $executed = $db->batchQueryFromFile( $fullSchemaSql );
  179. if ( !$executed ) {
  180. $this->error( 'Creating new tables failed' );
  181. }
  182. $this->out( 'OK' );
  183. if ( $reimportUsers === 'Y' ) {
  184. $this->out( 'Re-importing ' . count( $userRows ) . ' users...' );
  185. foreach ( $userRows as $userRow ) {
  186. $this->outRaw( '- creating user "' . $userRow->name . '"... ' );
  187. if ( empty( $userRow->password ) || empty( $userRow->seed ) || empty( $userRow->auth ) ) {
  188. $this->out( 'SKIPPED: Not a project account but a swarm client.' );
  189. continue;
  190. }
  191. try {
  192. $signupAction = SignupAction::newFromContext( $this->getContext() );
  193. // Password stored in the old database is a hash of the old seed (of type 'double'
  194. // and the actual password. We can't create this user with the same password because
  195. // sha1 is not supposed to be decodable.
  196. // I tried overriding the created row after the creation with the old seed and password,
  197. // but that didn't work because the old seed doesn't fit in the new seed field (of binary(40)).
  198. // When inserted, mysql transforms it into something else and sha1(seed + password) will no
  199. // longer match the hash. So instead create the new user with the auth token as password.
  200. $signupAction->doCreateUser( $userRow->name, $userRow->auth );
  201. $err = $signupAction->getError();
  202. if ( !$err ) {
  203. $this->outRaw( 'OK. Restoring auth token... ' );
  204. $data = $signupAction->getData();
  205. $updated = $db->query(str_queryf(
  206. 'UPDATE users
  207. SET
  208. auth = %s
  209. WHERE id = %u',
  210. $userRow->auth,
  211. $data['userID']
  212. ));
  213. $this->out( $updated ? 'OK.' : 'FAILED.' );
  214. } else {
  215. $this->out( "FAILED. SignupAction error. {$err['info']}" );
  216. }
  217. } catch ( Exception $e ) {
  218. $this->out( "FAILED. Unexpected exception thrown while creating account. {$e->getMessage()}" );
  219. }
  220. }
  221. } // End of users re-import
  222. } // End of patch-new-ua-runresults.sql
  223. /**
  224. * 1.0.0-alpha (patch-users-projects-conversion.sql)
  225. * users table removed, new projects table, various column changes.
  226. */
  227. $has_users = $db->tableExists( 'users' );
  228. $has_clients_user_id = $db->fieldInfo( 'clients', 'user_id' );
  229. $has_jobs_user_id = $db->fieldInfo( 'jobs', 'user_id' );
  230. $has_projects = $db->tableExists( 'projects' );
  231. $has_clients_name = $db->fieldInfo( 'clients', 'name' );
  232. $has_jobs_project_id = $db->fieldInfo( 'jobs', 'project_id' );
  233. $has_clients_useragent_id = $db->fieldInfo( 'clients', 'useragent_id' );
  234. if ( !$has_users && !$has_clients_user_id && !$has_jobs_user_id ) {
  235. $this->out( '... users table already dropped' );
  236. $this->out( '... clients.user_id already dropped' );
  237. $this->out( '... jobs.user_id already dropped' );
  238. } else {
  239. // Verify that the entire database is in the 1.0.0-alpha2012 state,
  240. // not just part of it.
  241. foreach ( array(
  242. 'users table' => $has_users,
  243. 'clients.user_id' => $has_clients_user_id,
  244. 'jobs.user_id' => $has_jobs_user_id,
  245. 'projects table' => ! $has_projects,
  246. 'clients.name' => ! $has_clients_name,
  247. 'jobs.project_id' => ! $has_jobs_project_id,
  248. 'clients.useragent_id' => $has_clients_useragent_id,
  249. ) as $label => $isAsExpected)
  250. if ( !$isAsExpected ) {
  251. $this->unknownDatabaseState( $label . ' not found' );
  252. return;
  253. }
  254. $this->out( 'Schema changes before users-projects-conversion migration...' );
  255. $this->out( '... creating projects table' );
  256. $db->query(
  257. "CREATE TABLE `projects` (
  258. `id` varchar(255) binary NOT NULL PRIMARY KEY,
  259. `display_title` varchar(255) binary NOT NULL,
  260. `site_url` blob NOT NULL default '',
  261. `password` tinyblob NOT NULL,
  262. `auth_token` tinyblob NOT NULL,
  263. `updated` binary(14) NOT NULL,
  264. `created` binary(14) NOT NULL
  265. ) ENGINE=InnoDB DEFAULT CHARSET=utf8;"
  266. );
  267. $this->out( '... adding clients.name' );
  268. $db->query(
  269. "ALTER TABLE clients
  270. ADD `name` varchar(255) binary NOT NULL AFTER `id`"
  271. );
  272. $this->out( '... adding jobs.project_id' );
  273. $db->query(
  274. "ALTER TABLE jobs
  275. ADD `project_id` varchar(255) binary NOT NULL AFTER `name`"
  276. );
  277. $this->out( '... dropping constraint fk_clients_user_id' );
  278. $db->query(
  279. "ALTER TABLE clients
  280. DROP FOREIGN KEY fk_clients_user_id"
  281. );
  282. $this->out( '... dropping constraint fk_jobs_user_id' );
  283. $db->query(
  284. "ALTER TABLE jobs
  285. DROP FOREIGN KEY fk_jobs_user_id"
  286. );
  287. $this->out( '... dropping constraint fk_runs_job_id' );
  288. $db->query(
  289. "ALTER TABLE runs
  290. DROP FOREIGN KEY fk_runs_job_id"
  291. );
  292. $this->out( '... dropping constraint fk_run_useragent_run_id' );
  293. $db->query(
  294. "ALTER TABLE run_useragent
  295. DROP FOREIGN KEY fk_run_useragent_run_id"
  296. );
  297. $this->out( '... dropping constraint fk_runresults_client_id' );
  298. $db->query(
  299. "ALTER TABLE runresults
  300. DROP FOREIGN KEY fk_runresults_client_id"
  301. );
  302. $this->out( '... dropping index idx_users_name' );
  303. $db->query(
  304. "ALTER TABLE users
  305. DROP INDEX idx_users_name"
  306. );
  307. $this->out( '... dropping index idx_clients_user_useragent_updated' );
  308. $db->query(
  309. "ALTER TABLE clients
  310. DROP INDEX idx_clients_user_useragent_updated"
  311. );
  312. $this->out( '... dropping index idx_jobs_user' );
  313. $db->query(
  314. "ALTER TABLE jobs
  315. DROP INDEX idx_jobs_user"
  316. );
  317. $this->out( 'Migrating old content into new schema...' );
  318. $this->out( '... fetching users table' );
  319. $userRows = $db->getRows( 'SELECT * FROM users' ) ?: array();
  320. $this->out( '... found ' . count( $userRows ) . ' users' );
  321. foreach ( $userRows as $userRow ) {
  322. $this->out( '... creating project "' . $userRow->name . '"' );
  323. if ( !trim( $userRow->seed ) || !trim( $userRow->password ) || !trim( $userRow->auth ) ) {
  324. // Client.php used to create rows in the users table with blanks
  325. // in these "required" fields. MySQL expands the emptyness to the full
  326. // 40-width of the column. Hence the trim().
  327. $this->out( ' SKIPPED: Not a project account but a swarm client.' );
  328. continue;
  329. }
  330. // Validate project id
  331. if ( !LoginAction::isValidName( $userRow->name ) ) {
  332. $this->out( ' SKIPPED: User name not a valid project id. Must match: ' . LoginAction::getNameValidationRegex() );
  333. continue;
  334. }
  335. if ( !$db->getOne(str_queryf( 'SELECT 1 FROM jobs WHERE user_id=%u', $userRow->id )) ) {
  336. $this->out( ' SKIPPED: Account has 0 jobs' );
  337. continue;
  338. }
  339. $isInserted = $db->query(str_queryf(
  340. 'INSERT INTO projects
  341. (id, display_title, site_url, password, auth_token, updated, created)
  342. VALUES(%s, %s, %s, %s, %s, %s, %s);',
  343. $userRow->name,
  344. $userRow->name,
  345. '',
  346. LoginAction::generatePasswordHashForUserrow( $userRow ),
  347. sha1( $userRow->auth ),
  348. swarmdb_dateformat( SWARM_NOW ),
  349. $userRow->created
  350. ));
  351. if ( !$isInserted ) {
  352. $this->out( ' FAILED: Failed to insert row into projects table.' );
  353. continue;
  354. }
  355. $this->out( '... updating references for project "' . $userRow->name . '"' );
  356. $isUpdated = $db->query(str_queryf(
  357. 'UPDATE clients
  358. SET name=%s
  359. WHERE user_id=%u',
  360. $userRow->name,
  361. $userRow->id
  362. ));
  363. if ( !$isUpdated ) {
  364. $this->out( ' FAILED: Failed to update rows in clients table.' );
  365. continue;
  366. }
  367. $isUpdated = $db->query(str_queryf(
  368. 'UPDATE jobs
  369. SET project_id=%s
  370. WHERE user_id=%u',
  371. $userRow->name,
  372. $userRow->id
  373. ));
  374. if ( !$isUpdated ) {
  375. $this->out( ' FAILED: Failed to update rows in jobs table.' );
  376. continue;
  377. }
  378. }
  379. $this->out( 'Schema changes after users-projects-conversion migration...' );
  380. $this->out( '... changing clients.useragent_id' );
  381. $db->query(
  382. "ALTER TABLE clients
  383. CHANGE COLUMN `useragent_id` `useragent_id` varchar(255) NOT NULL"
  384. );
  385. $this->out( '... dropping clients.user_id' );
  386. $db->query(
  387. "ALTER TABLE clients
  388. DROP COLUMN `user_id`"
  389. );
  390. $this->out( '... dropping jobs.user_id' );
  391. $db->query(
  392. "ALTER TABLE jobs
  393. DROP COLUMN `user_id`"
  394. );
  395. $this->out( '... dropping users table' );
  396. $db->query(
  397. "DROP TABLE users"
  398. );
  399. $this->out( '... adding index idx_clients_name_ua_created' );
  400. $db->query(
  401. "ALTER TABLE clients
  402. ADD INDEX idx_clients_name_ua_created (name, useragent_id, created);" );
  403. $this->out( '... adding index idx_jobs_project_created' );
  404. $db->query(
  405. "ALTER TABLE jobs
  406. ADD INDEX idx_jobs_project_created (project_id, created);" );
  407. } // End of patch-users-projects-conversion.sql
  408. $this->getContext()->dbLock( false );
  409. $this->out( "Removed database.lock.\nNo more updates." );
  410. }
  411. protected function unknownDatabaseState( $error = '' ) {
  412. if ( $error !== '' ) {
  413. $error = "\n\nLast failed check before abort: $error";
  414. }
  415. $this->error( "\nThe database was found in a state not known in any version.\n"
  416. . "This could be the result of a previous update run being aborted mid-update,\n"
  417. . "in that case the automatic update script cannot help you.\n"
  418. . "Please verify your local settings. Note that this is not an installer! $error" );
  419. }
  420. }
  421. $script = DBUpdateScript::newFromContext( $swarmContext );
  422. $script->run();