Passed
Push — test ( d48a8e...8face6 )
by Tom
02:59
created

UnPackager   A

Complexity

Total Complexity 22

Size/Duplication

Total Lines 193
Duplicated Lines 0 %

Test Coverage

Coverage 100%

Importance

Changes 1
Bugs 0 Features 1
Metric Value
eloc 75
c 1
b 0
f 1
dl 0
loc 193
ccs 82
cts 82
cp 1
rs 10
wmc 22

8 Methods

Rating   Name   Duplication   Size   Complexity  
A verifyFileHash() 0 16 4
A getLocalBinary() 0 17 3
A preparePackage() 0 18 1
A extractFromTgzFile() 0 16 3
A __construct() 0 5 1
A fromDirectories() 0 6 1
A extract() 0 24 3
A download() 0 17 6
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
        $src = fopen($package['uri'], 'rb');
191 3
        $dest = fopen(libFs::rm($base), 'wb');
192 3
        $src && $dest && stream_copy_to_stream($src, $dest, self::BYTES_80MB);
193 3
        $src && fclose($src);
194 3
        $dest && fclose($dest);
195
196 3
        $hash = hash_file('sha256', $base);
197 3
        $pkgLocalIn = sprintf('%s.%s', $base, $hash);
198 3
        if (LibFs::IsReadableFile($pkgLocalIn)) {
199 1
            throw new \UnexpectedValueException(sprintf('Download collision: %s', $pkgLocalIn));
200
        }
201 3
        LibFs::Rename($base, $pkgLocalIn);
202 3
    }
203
204 3
    private function extract(array $package)
205
    {
206 3
        $binLocal = $package['prep']['bin_local'];
207
208 3
        if (LibFs::isReadableFile($binLocal)) {
209 1
            $this->verifyFileHash($binLocal, $package['binary_sha256']);
210
211 1
            return;
212
        }
213
214 3
        $pkgLocal = $package['prep']['pkg_local'];
215 3
        $base = $package['prep']['bin_base'];
216
217 3
        $this->extractFromTgzFile($pkgLocal, $package['binary'], $base);
218
219 3
        $hash = hash_file('sha256', $base);
220 3
        $binLocal = sprintf('%s.%s', $base, $hash);
221 3
        if (LibFs::isReadableFile($binLocal)) {
222 1
            LibFs::rm($base);
223
224 1
            throw new \UnexpectedValueException(sprintf('Extraction collision: "%s"', $binLocal));
225
        }
226
227 3
        LibFs::Rename($base, $binLocal);
228 3
    }
229
}
230