Passed
Push — test ( ccd17d...b5a64e )
by Tom
02:39
created

UnPackager::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 1

Importance

Changes 1
Bugs 0 Features 1
Metric Value
cc 1
eloc 3
c 1
b 0
f 1
nc 1
nop 3
dl 0
loc 5
ccs 4
cts 4
cp 1
crap 1
rs 10
1
<?php
2
3
/* this file is part of pipelines */
4
5
namespace Ktomk\Pipelines\Runner\Docker\Binary;
6
7
use Ktomk\Pipelines\Cli\Exec;
8
use Ktomk\Pipelines\Lib;
9
use Ktomk\Pipelines\LibFs;
10
use Ktomk\Pipelines\Runner\Directories;
11
12
/**
13
 * Class DockerBinaryUnpackager
14
 *
15
 * Knows how to handle a docker client binary package structure to provide
16
 * the docker client binary as pathname.
17
 *
18
 *   Binaries are packaged in the docker project below https://download.docker.com/linux/static/stable/...
19
 * in versioned .tgz files (e.g. x86_64/docker-19.03.1.tgz).
20
 *
21
 *   The docker binary is a single file inside such a .tgz file, which es getting extracted and then
22
 * is called the binary in the local store.
23
 *
24
 *   Both the download of the .tgz file as well as the extraction of the docker binary from it is
25
 * verified against a checksum each so that downloading and extracting can be verified as successful.
26
 *
27
 *   Binaries are stored in pipelines XDG_DATA_HOME in the static-docker folder, e.g.
28
 * ~/.local/share/pipelines/static-docker
29
 *
30
 *   Before downloading, it is checked if the .tgz package is already cached. The cache is in the
31
 * XDG_CACHE_HOME, e.g. ~/.cache/pipelines/package-docker
32
 *
33
 * @package Ktomk\Pipelines\Runner\Docker
34
 */
35
class UnPackager
36
{
37
    const BYTES_80MB = 83886080;
38
39
    /**
40
     * @var Exec
41
     */
42
    private $exec;
43
44
    /**
45
     * @var string
46
     */
47
    private $packageDirectory;
48
49
    /**
50
     * @var string
51
     */
52
    private $binariesDirectory;
53
54
    /**
55
     * Create binary unpackager based on default directories.
56
     *
57
     * @param Exec $exec
58
     * @param Directories $directories
59
     * @return UnPackager
60
     */
61 5
    public static function fromDirectories(Exec $exec, Directories $directories)
62
    {
63 5
        $packageDirectory = $directories->getBaseDirectory('XDG_CACHE_HOME', 'package-docker');
64 5
        $binariesDirectory = $directories->getBaseDirectory('XDG_DATA_HOME', 'static-docker');
65
66 5
        return new self($exec, $packageDirectory, $binariesDirectory);
67
    }
68
69
    /**
70
     * BinaryUnPackager constructor.
71
     *
72
     * @param Exec $exec unpackager is using shell commands w/ tar for unpacking
73
     * @param string $packageDirectory where to store package downloads to (e.g. HTTP cache)
74
     * @param string $binariesDirectory where to store binaries to (can be kept ongoing to run pipelines)
75
     */
76 7
    public function __construct(Exec $exec, $packageDirectory, $binariesDirectory)
77
    {
78 7
        $this->exec = $exec;
79 7
        $this->packageDirectory = $packageDirectory;
80 7
        $this->binariesDirectory = $binariesDirectory;
81 7
    }
82
83
    /**
84
     * Get binary path from local store.
85
     *
86
     * @param array $package
87
     * @return string
88
     */
89 3
    public function getLocalBinary(array $package)
90
    {
91 3
        $package = $this->preparePackage($package);
92
93 3
        $binLocal = $package['prep']['bin_local'];
94 3
        $pkgLocal = $package['prep']['pkg_local'];
95
96 3
        if (!LibFs::isReadableFile($binLocal) && !LibFs::isReadableFile($pkgLocal)) {
97 3
            $this->download($package);
98
        }
99
100 3
        $this->extract($package);
101
102 3
        $message = sprintf('Verify and rename or remove the file to get it downloaded again from %s', $package['uri']);
103 3
        $this->verifyFileHash($binLocal, $package['binary_sha256'], $message);
104
105 3
        return $binLocal;
106
    }
107
108
    /**
109
     * @param array $package
110
     * @return array
111
     */
112 3
    public function preparePackage(array $package)
113
    {
114 3
        $cache = libFs::mkDir($this->packageDirectory);
115 3
        $share = libFs::mkDir($this->binariesDirectory);
116
117 3
        $pkgBase = sprintf('%s/%s', $cache, basename($package['uri']));
118 3
        $binBase = sprintf('%s/%s', $share, $package['name']);
119
120 3
        $package['prep'] = array(
121 3
            'cache' => $cache,
122 3
            'pkg_base' => $pkgBase,
123 3
            'pkg_local' => sprintf('%s.%s', $pkgBase, $package['sha256']),
124 3
            'share' => $share,
125 3
            'bin_base' => $binBase,
126 3
            'bin_local' => sprintf('%s.%s', $binBase, $package['binary_sha256']),
127
        );
128
129 3
        return $package;
130
    }
131
132
    /**
133
     * @param string $tgz path to tar-gz (.tgz) file
134
     * @param string $path path in package to extract from
135
     * @param string $dest path to extract to
136
     */
137 4
    public function extractFromTgzFile($tgz, $path, $dest)
138
    {
139 4
        if (!LibFs::isReadableFile($tgz)) {
140 1
            throw new \UnexpectedValueException(sprintf('Not a readable file: %s', $tgz));
141
        }
142
143 4
        LibFs::rm($dest);
144 4
        $status = $this->exec->pass(
145 4
            sprintf('> %s tar', Lib::quoteArg($dest)),
146 4
            array('-xOzf', $tgz, $path)
147
        );
148
149 4
        if (0 !== $status) {
150 1
            LibFs::rm($dest);
151
152 1
            throw new \UnexpectedValueException(sprintf('Nonzero tar exit status: %d', $status));
153
        }
154 4
    }
155
156
    /**
157
     * verify sha256 has of a file
158
     *
159
     * @param string $file
160
     * @param string $hash sha256 hash of the file
161
     * @param string $message [optional] additional error message information
162
     */
163 4
    public function verifyFileHash($file, $hash, $message = '')
164
    {
165 4
        if (!LibFs::isReadableFile($file)) {
166 1
            throw new \UnexpectedValueException(sprintf('not a readable file: "%s"', $file));
167
        }
168
169 4
        $actual = hash_file('sha256', $file);
170 4
        if ($actual !== $hash) {
171 1
            $buffer = sprintf(
172 1
                'sha256 checksum mismatch: "%s" for file "%s".',
173 1
                $hash,
174 1
                $file
175
            );
176 1
            strlen($message) && $buffer .= ' ' . $message;
177
178 1
            throw new \UnexpectedValueException($buffer);
179
        }
180 4
    }
181
182
    /**
183
     * @param array $package
184
     */
185 3
    private function download(array $package)
186
    {
187 3
        $base = $package['prep']['pkg_base'];
188
189
        // Download the package if not in the http-cache
190 3
        stream_copy_to_stream(
191 3
            $src = fopen($package['uri'], 'rb'),
0 ignored issues
show
Bug introduced by
It seems like $src = fopen($package['uri'], 'rb') can also be of type false; however, parameter $source of stream_copy_to_stream() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

191
            /** @scrutinizer ignore-type */ $src = fopen($package['uri'], 'rb'),
Loading history...
192 3
            $dest = fopen(libFs::rm($base), 'wb'),
0 ignored issues
show
Bug introduced by
It seems like $dest = fopen(Ktomk\Pipe...LibFs::rm($base), 'wb') can also be of type false; however, parameter $dest of stream_copy_to_stream() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

192
            /** @scrutinizer ignore-type */ $dest = fopen(libFs::rm($base), 'wb'),
Loading history...
193 3
            self::BYTES_80MB
194
        );
195 3
        fclose($src);
0 ignored issues
show
Bug introduced by
It seems like $src can also be of type false; however, parameter $handle of fclose() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

195
        fclose(/** @scrutinizer ignore-type */ $src);
Loading history...
196 3
        fclose($dest);
197 3
        $hash = hash_file('sha256', $base);
198 3
        $pkgLocalIn = sprintf('%s.%s', $base, $hash);
199 3
        if (LibFs::IsReadableFile($pkgLocalIn)) {
200 1
            throw new \UnexpectedValueException(sprintf('Download collision: %s', $pkgLocalIn));
201
        }
202 3
        LibFs::Rename($base, $pkgLocalIn);
203 3
    }
204
205 3
    private function extract(array $package)
206
    {
207 3
        $binLocal = $package['prep']['bin_local'];
208
209 3
        if (LibFs::isReadableFile($binLocal)) {
210 1
            $this->verifyFileHash($binLocal, $package['binary_sha256']);
211
212 1
            return;
213
        }
214
215 3
        $pkgLocal = $package['prep']['pkg_local'];
216 3
        $base = $package['prep']['bin_base'];
217
218 3
        $this->extractFromTgzFile($pkgLocal, $package['binary'], $base);
219
220 3
        $hash = hash_file('sha256', $base);
221 3
        $binLocal = sprintf('%s.%s', $base, $hash);
222 3
        if (LibFs::isReadableFile($binLocal)) {
223 1
            LibFs::rm($base);
224
225 1
            throw new \UnexpectedValueException(sprintf('Extraction collision: "%s"', $binLocal));
226
        }
227
228 3
        LibFs::Rename($base, $binLocal);
229 3
    }
230
}
231