Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
54.76% covered (warning)
54.76%
69 / 126
25.00% covered (danger)
25.00%
1 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
MigrateFileRepoLayout
54.76% covered (warning)
54.76%
69 / 126
25.00% covered (danger)
25.00%
1 / 4
126.80
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 execute
50.89% covered (warning)
50.89%
57 / 112
0.00% covered (danger)
0.00%
0 / 1
113.33
 getRepo
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 runBatch
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
1<?php
2/**
3 * Copy all files in FileRepo to an originals container using SHA1 paths.
4 *
5 * @license GPL-2.0-or-later
6 * @file
7 * @ingroup Maintenance
8 */
9
10use MediaWiki\FileRepo\File\File;
11use MediaWiki\FileRepo\File\FileSelectQueryBuilder;
12use MediaWiki\FileRepo\File\LocalFile;
13use MediaWiki\FileRepo\FileBackendDBRepoWrapper;
14use MediaWiki\FileRepo\LocalRepo;
15use MediaWiki\Maintenance\Maintenance;
16use Wikimedia\FileBackend\FileBackend;
17
18// @codeCoverageIgnoreStart
19require_once __DIR__ . '/Maintenance.php';
20// @codeCoverageIgnoreEnd
21
22/**
23 * Copy all files in FileRepo to an originals container using SHA1 paths.
24 *
25 * This script should be run while the repo is still set to the old layout.
26 *
27 * @ingroup Maintenance
28 */
29class MigrateFileRepoLayout extends Maintenance {
30    public function __construct() {
31        parent::__construct();
32        $this->addDescription( 'Copy files in repo to a different layout.' );
33        $this->addOption( 'oldlayout', "Old layout; one of 'name' or 'sha1'", true, true );
34        $this->addOption( 'newlayout', "New layout; one of 'name' or 'sha1'", true, true );
35        $this->addOption( 'since', "Copy only files from after this timestamp", false, true );
36        $this->setBatchSize( 50 );
37    }
38
39    public function execute() {
40        $oldLayout = $this->getOption( 'oldlayout' );
41        if ( !in_array( $oldLayout, [ 'name', 'sha1' ] ) ) {
42            $this->fatalError( "Invalid old layout." );
43        }
44        $newLayout = $this->getOption( 'newlayout' );
45        if ( !in_array( $newLayout, [ 'name', 'sha1' ] ) ) {
46            $this->fatalError( "Invalid new layout." );
47        }
48        $since = $this->getOption( 'since' );
49
50        $repo = $this->getRepo();
51
52        $be = $repo->getBackend();
53        if ( $be instanceof FileBackendDBRepoWrapper ) {
54            // avoid path translations for this script
55            $be = $be->getInternalBackend();
56        }
57
58        $dbw = $repo->getPrimaryDB();
59
60        $origBase = $be->getContainerStoragePath( "{$repo->getName()}-original" );
61        $startTime = wfTimestampNow();
62
63        // Do current and archived versions...
64        $conds = [];
65        if ( $since ) {
66            $conds[] = $dbw->expr( 'img_timestamp', '>=', $dbw->timestamp( $since ) );
67        }
68
69        $batchSize = $this->getBatchSize();
70        $batch = [];
71        $lastName = '';
72        do {
73            $res = FileSelectQueryBuilder::newForFile( $dbw )
74                ->where( $dbw->expr( 'img_name', '>', $lastName ) )
75                ->andWhere( $conds )
76                ->orderBy( 'img_name' )
77                ->limit( $batchSize )
78                ->caller( __METHOD__ )->fetchResultSet();
79
80            foreach ( $res as $row ) {
81                $lastName = $row->img_name;
82                /** @var LocalFile $file */
83                $file = $repo->newFile( $row->img_name );
84                // Check in case SHA1 rows are not populated for some files
85                $sha1 = $row->img_sha1 !== '' ? $row->img_sha1 : $file->getSha1();
86
87                if ( $sha1 === '' ) {
88                    $this->error( "Image SHA-1 not known for {$row->img_name}." );
89                } else {
90                    if ( $oldLayout === 'sha1' ) {
91                        $spath = "{$origBase}/{$sha1[0]}/{$sha1[1]}/{$sha1[2]}/{$sha1}";
92                    } else {
93                        $spath = $file->getPath();
94                    }
95
96                    if ( $newLayout === 'sha1' ) {
97                        $dpath = "{$origBase}/{$sha1[0]}/{$sha1[1]}/{$sha1[2]}/{$sha1}";
98                    } else {
99                        $dpath = $file->getPath();
100                    }
101
102                    $status = $be->prepare( [
103                        'dir' => dirname( $dpath ), 'bypassReadOnly' => true ] );
104                    if ( !$status->isOK() ) {
105                        $this->error( $status );
106                    }
107
108                    $batch[] = [ 'op' => 'copy', 'overwrite' => true,
109                        'src' => $spath, 'dst' => $dpath, 'img' => $row->img_name ];
110                }
111
112                foreach ( $file->getHistory() as $ofile ) {
113                    $sha1 = $ofile->getSha1();
114                    if ( $sha1 === '' ) {
115                        $this->error( "Image SHA-1 not set for {$ofile->getArchiveName()}." );
116                        continue;
117                    }
118
119                    if ( $oldLayout === 'sha1' ) {
120                        $spath = "{$origBase}/{$sha1[0]}/{$sha1[1]}/{$sha1[2]}/{$sha1}";
121                    } elseif ( $ofile->isDeleted( File::DELETED_FILE ) ) {
122                        $spath = $be->getContainerStoragePath( "{$repo->getName()}-deleted" ) .
123                            '/' . $repo->getDeletedHashPath( $sha1 ) .
124                            $sha1 . '.' . $ofile->getExtension();
125                    } else {
126                        $spath = $ofile->getPath();
127                    }
128
129                    if ( $newLayout === 'sha1' ) {
130                        $dpath = "{$origBase}/{$sha1[0]}/{$sha1[1]}/{$sha1[2]}/{$sha1}";
131                    } else {
132                        $dpath = $ofile->getPath();
133                    }
134
135                    $status = $be->prepare( [
136                        'dir' => dirname( $dpath ), 'bypassReadOnly' => true ] );
137                    if ( !$status->isOK() ) {
138                        $this->error( $status );
139                    }
140                    $batch[] = [ 'op' => 'copy', 'overwrite' => true,
141                        'src' => $spath, 'dst' => $dpath, 'img' => $ofile->getArchiveName() ];
142                }
143
144                if ( count( $batch ) >= $batchSize ) {
145                    $this->runBatch( $batch, $be );
146                    $batch = [];
147                }
148            }
149        } while ( $res->numRows() );
150
151        if ( count( $batch ) ) {
152            $this->runBatch( $batch, $be );
153        }
154
155        // Do deleted versions...
156        $conds = [];
157        if ( $since ) {
158            $conds[] = $dbw->expr( 'fa_deleted_timestamp', '>=', $dbw->timestamp( $since ) );
159        }
160
161        $batch = [];
162        $lastId = 0;
163        do {
164            $res = $dbw->newSelectQueryBuilder()
165                ->select( [ 'fa_storage_key', 'fa_id', 'fa_name' ] )
166                ->from( 'filearchive' )
167                ->where( $dbw->expr( 'fa_id', '>', $lastId ) )
168                ->andWhere( $conds )
169                ->orderBy( 'fa_id' )
170                ->limit( $batchSize )
171                ->caller( __METHOD__ )->fetchResultSet();
172
173            foreach ( $res as $row ) {
174                $lastId = $row->fa_id;
175                $sha1Key = $row->fa_storage_key;
176                if ( $sha1Key === '' ) {
177                    $this->error( "Image SHA-1 not set for file #{$row->fa_id} (deleted)." );
178                    continue;
179                }
180                $sha1 = substr( $sha1Key, 0, strpos( $sha1Key, '.' ) );
181
182                if ( $oldLayout === 'sha1' ) {
183                    $spath = "{$origBase}/{$sha1[0]}/{$sha1[1]}/{$sha1[2]}/{$sha1}";
184                } else {
185                    $spath = $be->getContainerStoragePath( "{$repo->getName()}-deleted" ) .
186                        '/' . $repo->getDeletedHashPath( $sha1Key ) . $sha1Key;
187                }
188
189                if ( $newLayout === 'sha1' ) {
190                    $dpath = "{$origBase}/{$sha1[0]}/{$sha1[1]}/{$sha1[2]}/{$sha1}";
191                } else {
192                    $dpath = $be->getContainerStoragePath( "{$repo->getName()}-deleted" ) .
193                        '/' . $repo->getDeletedHashPath( $sha1Key ) . $sha1Key;
194                }
195
196                $status = $be->prepare( [
197                    'dir' => dirname( $dpath ), 'bypassReadOnly' => true ] );
198                if ( !$status->isOK() ) {
199                    $this->error( $status );
200                }
201
202                $batch[] = [ 'op' => 'copy', 'src' => $spath, 'dst' => $dpath,
203                    'overwriteSame' => true, 'img' => "(ID {$row->fa_id}{$row->fa_name}" ];
204
205                if ( count( $batch ) >= $batchSize ) {
206                    $this->runBatch( $batch, $be );
207                    $batch = [];
208                }
209            }
210        } while ( $res->numRows() );
211
212        if ( count( $batch ) ) {
213            $this->runBatch( $batch, $be );
214        }
215
216        $this->output( "Done (started $startTime)\n" );
217    }
218
219    protected function getRepo(): LocalRepo {
220        return $this->getServiceContainer()->getRepoGroup()->getLocalRepo();
221    }
222
223    /**
224     * @param array[] $ops
225     * @param FileBackend $be
226     */
227    protected function runBatch( array $ops, FileBackend $be ) {
228        $this->output( "Migrating file batch:\n" );
229        foreach ( $ops as $op ) {
230            $this->output( "\"{$op['img']}\" (dest: {$op['dst']})\n" );
231        }
232
233        $status = $be->doOperations( $ops, [ 'bypassReadOnly' => true ] );
234        if ( !$status->isOK() ) {
235            $this->error( $status );
236        }
237
238        $this->output( "Batch done\n\n" );
239    }
240}
241
242// @codeCoverageIgnoreStart
243$maintClass = MigrateFileRepoLayout::class;
244require_once RUN_MAINTENANCE_IF_MAIN;
245// @codeCoverageIgnoreEnd