2021-04-18 12:41:13 -07:00
|
|
|
// Copyright (c) 2015-2021 MinIO, Inc.
|
|
|
|
//
|
|
|
|
// This file is part of MinIO Object Storage stack
|
|
|
|
//
|
|
|
|
// This program is free software: you can redistribute it and/or modify
|
|
|
|
// it under the terms of the GNU Affero General Public License as published by
|
|
|
|
// the Free Software Foundation, either version 3 of the License, or
|
|
|
|
// (at your option) any later version.
|
|
|
|
//
|
|
|
|
// This program is distributed in the hope that it will be useful
|
|
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
|
|
// GNU Affero General Public License for more details.
|
|
|
|
//
|
|
|
|
// You should have received a copy of the GNU Affero General Public License
|
|
|
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
2016-08-30 19:22:27 -07:00
|
|
|
|
|
|
|
package cmd
|
|
|
|
|
|
|
|
import (
|
2020-08-04 14:55:53 -07:00
|
|
|
"context"
|
2022-01-24 11:28:45 -08:00
|
|
|
"errors"
|
2017-06-17 11:20:12 -07:00
|
|
|
"fmt"
|
2020-01-14 18:45:17 -08:00
|
|
|
"net/http"
|
|
|
|
"net/url"
|
2023-07-26 11:31:40 -07:00
|
|
|
"path/filepath"
|
2019-10-30 00:04:39 -07:00
|
|
|
"sync"
|
2016-08-30 19:22:27 -07:00
|
|
|
"time"
|
|
|
|
|
2020-05-13 20:25:29 -07:00
|
|
|
"github.com/dustin/go-humanize"
|
2021-06-01 14:59:40 -07:00
|
|
|
xhttp "github.com/minio/minio/internal/http"
|
|
|
|
"github.com/minio/minio/internal/logger"
|
2016-08-30 19:22:27 -07:00
|
|
|
)
|
|
|
|
|
2020-07-17 10:08:04 -07:00
|
|
|
var printEndpointError = func() func(Endpoint, error, bool) {
|
2019-10-30 00:04:39 -07:00
|
|
|
var mutex sync.Mutex
|
2020-07-17 10:08:04 -07:00
|
|
|
printOnce := make(map[Endpoint]map[string]int)
|
2018-02-15 17:45:57 -08:00
|
|
|
|
2020-07-17 10:08:04 -07:00
|
|
|
return func(endpoint Endpoint, err error, once bool) {
|
2018-09-13 21:42:50 -07:00
|
|
|
reqInfo := (&logger.ReqInfo{}).AppendTags("endpoint", endpoint.String())
|
2020-04-09 09:30:02 -07:00
|
|
|
ctx := logger.SetReqInfo(GlobalContext, reqInfo)
|
2019-10-30 00:04:39 -07:00
|
|
|
mutex.Lock()
|
|
|
|
defer mutex.Unlock()
|
2020-07-17 10:08:04 -07:00
|
|
|
|
2018-02-15 17:45:57 -08:00
|
|
|
m, ok := printOnce[endpoint]
|
|
|
|
if !ok {
|
2020-07-17 10:08:04 -07:00
|
|
|
m = make(map[string]int)
|
2018-02-15 17:45:57 -08:00
|
|
|
printOnce[endpoint] = m
|
2020-07-17 10:08:04 -07:00
|
|
|
if once {
|
2023-05-13 01:41:54 +08:00
|
|
|
m[err.Error()]++
|
2024-04-04 13:04:40 +01:00
|
|
|
peersLogAlwaysIf(ctx, err)
|
2020-07-17 10:08:04 -07:00
|
|
|
return
|
|
|
|
}
|
2017-12-28 18:32:48 +01:00
|
|
|
}
|
2020-07-17 10:08:04 -07:00
|
|
|
// Once is set and we are here means error was already
|
|
|
|
// printed once.
|
|
|
|
if once {
|
2018-02-15 17:45:57 -08:00
|
|
|
return
|
2017-12-28 18:32:48 +01:00
|
|
|
}
|
2020-07-17 10:08:04 -07:00
|
|
|
// once not set, check if same error occurred 3 times in
|
|
|
|
// a row, then make sure we print it to call attention.
|
|
|
|
if m[err.Error()] > 2 {
|
2024-04-04 13:04:40 +01:00
|
|
|
peersLogAlwaysIf(ctx, fmt.Errorf("Following error has been printed %d times.. %w", m[err.Error()], err))
|
2020-07-17 10:08:04 -07:00
|
|
|
// Reduce the count to introduce further delay in printing
|
|
|
|
// but let it again print after the 2th attempt
|
|
|
|
m[err.Error()]--
|
|
|
|
m[err.Error()]--
|
|
|
|
}
|
|
|
|
m[err.Error()]++
|
2017-12-28 18:32:48 +01:00
|
|
|
}
|
2018-02-15 17:45:57 -08:00
|
|
|
}()
|
2016-08-30 19:22:27 -07:00
|
|
|
|
2021-06-05 17:10:32 +01:00
|
|
|
// Cleans up tmp directory of the local disk.
|
2022-05-12 23:24:58 +01:00
|
|
|
func bgFormatErasureCleanupTmp(diskPath string) {
|
2021-06-05 17:10:32 +01:00
|
|
|
// Need to move temporary objects left behind from previous run of minio
|
2021-06-16 16:19:18 -07:00
|
|
|
// server to a unique directory under `minioMetaTmpBucket-old` to clean
|
2021-06-05 17:10:32 +01:00
|
|
|
// up `minioMetaTmpBucket` for the current run.
|
|
|
|
//
|
2021-06-16 16:19:18 -07:00
|
|
|
// /disk1/.minio.sys/tmp-old/
|
2021-06-05 17:10:32 +01:00
|
|
|
// |__ 33a58b40-aecc-4c9f-a22f-ff17bfa33b62
|
|
|
|
// |__ e870a2c1-d09c-450c-a69c-6eaa54a89b3e
|
|
|
|
//
|
|
|
|
// In this example, `33a58b40-aecc-4c9f-a22f-ff17bfa33b62` directory contains
|
|
|
|
// temporary objects from one of the previous runs of minio server.
|
2022-01-24 11:28:45 -08:00
|
|
|
tmpID := mustGetUUID()
|
|
|
|
tmpOld := pathJoin(diskPath, minioMetaTmpBucket+"-old", tmpID)
|
2021-06-05 17:10:32 +01:00
|
|
|
if err := renameAll(pathJoin(diskPath, minioMetaTmpBucket),
|
2023-09-13 08:14:36 -07:00
|
|
|
tmpOld, diskPath); err != nil && !errors.Is(err, errFileNotFound) {
|
2024-04-04 13:04:40 +01:00
|
|
|
storageLogIf(GlobalContext, fmt.Errorf("unable to rename (%s -> %s) %w, drive may be faulty, please investigate",
|
2021-06-05 17:10:32 +01:00
|
|
|
pathJoin(diskPath, minioMetaTmpBucket),
|
|
|
|
tmpOld,
|
|
|
|
osErrToFileErr(err)))
|
2016-11-23 15:48:10 -08:00
|
|
|
}
|
2019-01-26 01:33:28 +04:00
|
|
|
|
2023-09-13 08:14:36 -07:00
|
|
|
if err := mkdirAll(pathJoin(diskPath, minioMetaTmpDeletedBucket), 0o777, diskPath); err != nil {
|
2024-04-04 13:04:40 +01:00
|
|
|
storageLogIf(GlobalContext, fmt.Errorf("unable to create (%s) %w, drive may be faulty, please investigate",
|
2021-06-05 17:10:32 +01:00
|
|
|
pathJoin(diskPath, minioMetaTmpBucket),
|
|
|
|
err))
|
2018-03-15 13:55:23 -07:00
|
|
|
}
|
2022-01-24 11:28:45 -08:00
|
|
|
|
2023-07-26 11:31:40 -07:00
|
|
|
// Delete all temporary files created for DirectIO write check
|
|
|
|
files, _ := filepath.Glob(filepath.Join(diskPath, ".writable-check-*.tmp"))
|
|
|
|
for _, file := range files {
|
2023-08-01 10:54:26 -07:00
|
|
|
go removeAll(file)
|
2023-07-26 11:31:40 -07:00
|
|
|
}
|
|
|
|
|
2022-10-31 15:27:50 +01:00
|
|
|
// Remove the entire folder in case there are leftovers that didn't get cleaned up before restart.
|
|
|
|
go removeAll(pathJoin(diskPath, minioMetaTmpBucket+"-old"))
|
2023-08-01 10:54:26 -07:00
|
|
|
|
2022-01-24 11:28:45 -08:00
|
|
|
// Renames and schedules for purging all bucket metacache.
|
2022-05-12 23:24:58 +01:00
|
|
|
go renameAllBucketMetacache(diskPath)
|
2018-03-15 13:55:23 -07:00
|
|
|
}
|
|
|
|
|
2018-04-03 23:58:48 -05:00
|
|
|
// Following error message is added to fix a regression in release
|
|
|
|
// RELEASE.2018-03-16T22-52-12Z after migrating v1 to v2 to v3. This
|
|
|
|
// migration failed to capture '.This' field properly which indicates
|
|
|
|
// the disk UUID association. Below error message is returned when
|
|
|
|
// we see this situation in format.json, for more info refer
|
|
|
|
// https://github.com/minio/minio/issues/5667
|
2020-06-12 20:04:01 -07:00
|
|
|
var errErasureV3ThisEmpty = fmt.Errorf("Erasure format version 3 has This field empty")
|
2018-04-03 23:58:48 -05:00
|
|
|
|
2021-01-07 19:16:18 -08:00
|
|
|
// isServerResolvable - checks if the endpoint is resolvable
|
2020-01-14 18:45:17 -08:00
|
|
|
// by sending a naked HTTP request with liveness checks.
|
2021-01-25 10:01:27 -08:00
|
|
|
func isServerResolvable(endpoint Endpoint, timeout time.Duration) error {
|
2020-01-14 18:45:17 -08:00
|
|
|
serverURL := &url.URL{
|
|
|
|
Scheme: endpoint.Scheme,
|
|
|
|
Host: endpoint.Host,
|
2020-10-30 12:20:28 -07:00
|
|
|
Path: pathJoin(healthCheckPathPrefix, healthCheckLivenessPath),
|
2020-01-14 18:45:17 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
httpClient := &http.Client{
|
2022-03-17 16:20:10 -07:00
|
|
|
Transport: globalInternodeTransport,
|
2020-01-14 18:45:17 -08:00
|
|
|
}
|
|
|
|
|
2021-01-25 10:01:27 -08:00
|
|
|
ctx, cancel := context.WithTimeout(GlobalContext, timeout)
|
2023-05-04 19:28:33 +01:00
|
|
|
defer cancel()
|
2020-08-04 14:55:53 -07:00
|
|
|
|
2020-09-08 14:22:04 -07:00
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, serverURL.String(), nil)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
2023-07-28 01:16:53 -07:00
|
|
|
// Indicate that the liveness check for a peer call
|
|
|
|
req.Header.Set(xhttp.MinIOPeerCall, "true")
|
2023-05-04 19:28:33 +01:00
|
|
|
|
2020-09-08 14:22:04 -07:00
|
|
|
resp, err := httpClient.Do(req)
|
2020-01-14 18:45:17 -08:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
2020-10-30 12:20:28 -07:00
|
|
|
xhttp.DrainBody(resp.Body)
|
2020-01-14 18:45:17 -08:00
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2020-06-12 20:04:01 -07:00
|
|
|
// connect to list of endpoints and load all Erasure disk formats, validate the formats are correct
|
2018-04-03 23:58:48 -05:00
|
|
|
// and are in quorum, if no formats are found attempt to initialize all of them for the first
|
|
|
|
// time. additionally make sure to close all the disks used in this attempt.
|
2024-05-19 01:06:49 -07:00
|
|
|
func connectLoadInitFormats(verboseLogging bool, firstDisk bool, storageDisks []StorageAPI, endpoints Endpoints, poolCount, setCount, setDriveCount int, deploymentID string) (format *formatErasureV3, err error) {
|
|
|
|
// Attempt to load all `format.json` from all disks.
|
|
|
|
formatConfigs, sErrs := loadFormatErasureAll(storageDisks, false)
|
2020-01-14 18:45:17 -08:00
|
|
|
|
2024-05-19 01:06:49 -07:00
|
|
|
if err := checkDiskFatalErrs(sErrs); err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
2020-04-03 18:06:31 -07:00
|
|
|
|
2024-05-19 01:06:49 -07:00
|
|
|
for i, err := range sErrs {
|
|
|
|
if err != nil && !errors.Is(err, errXLBackend) && !errors.Is(err, errUnformattedDisk) {
|
2022-05-30 10:58:37 -07:00
|
|
|
if errors.Is(err, errDiskNotFound) && verboseLogging {
|
2022-10-28 14:11:20 -07:00
|
|
|
if globalEndpoints.NEndpoints() > 1 {
|
|
|
|
logger.Error("Unable to connect to %s: %v", endpoints[i], isServerResolvable(endpoints[i], time.Second))
|
|
|
|
} else {
|
|
|
|
logger.Fatal(err, "Unable to connect to %s: %v", endpoints[i], isServerResolvable(endpoints[i], time.Second))
|
|
|
|
}
|
2021-05-15 12:56:58 -07:00
|
|
|
} else {
|
2022-10-28 14:11:20 -07:00
|
|
|
if globalEndpoints.NEndpoints() > 1 {
|
|
|
|
logger.Error("Unable to use the drive %s: %v", endpoints[i], err)
|
|
|
|
} else {
|
|
|
|
logger.Fatal(errInvalidArgument, "Unable to use the drive %s: %v", endpoints[i], err)
|
|
|
|
}
|
2020-01-14 18:45:17 -08:00
|
|
|
}
|
2019-09-27 16:47:12 -07:00
|
|
|
}
|
|
|
|
}
|
2018-04-03 23:58:48 -05:00
|
|
|
|
|
|
|
// Pre-emptively check if one of the formatted disks
|
|
|
|
// is invalid. This function returns success for the
|
|
|
|
// most part unless one of the formats is not consistent
|
2020-06-12 20:04:01 -07:00
|
|
|
// with expected Erasure format. For example if a user is
|
|
|
|
// trying to pool FS backend into an Erasure set.
|
2021-01-29 11:40:55 -08:00
|
|
|
if err = checkFormatErasureValues(formatConfigs, storageDisks, setDriveCount); err != nil {
|
2024-05-19 01:06:49 -07:00
|
|
|
return nil, err
|
2018-04-03 23:58:48 -05:00
|
|
|
}
|
|
|
|
|
2018-04-12 15:43:38 -07:00
|
|
|
// All disks report unformatted we should initialized everyone.
|
2024-02-14 10:37:34 -08:00
|
|
|
if shouldInitErasureDisks(sErrs) && firstDisk {
|
2021-01-06 09:35:47 -08:00
|
|
|
logger.Info("Formatting %s pool, %v set(s), %v drives per set.",
|
|
|
|
humanize.Ordinal(poolCount), setCount, setDriveCount)
|
2020-01-17 15:39:07 -08:00
|
|
|
|
2019-04-02 23:20:13 +05:30
|
|
|
// Initialize erasure code format on disks
|
2024-02-11 23:21:56 -08:00
|
|
|
format, err = initFormatErasure(GlobalContext, storageDisks, setCount, setDriveCount, deploymentID, sErrs)
|
2019-04-02 23:20:13 +05:30
|
|
|
if err != nil {
|
2024-05-19 01:06:49 -07:00
|
|
|
return nil, err
|
2019-04-02 23:20:13 +05:30
|
|
|
}
|
2020-05-13 20:25:29 -07:00
|
|
|
|
2024-05-19 01:06:49 -07:00
|
|
|
return format, nil
|
2019-12-23 16:31:03 -08:00
|
|
|
}
|
|
|
|
|
2024-02-14 10:37:34 -08:00
|
|
|
// Return error when quorum unformatted disks - indicating we are
|
|
|
|
// waiting for first server to be online.
|
|
|
|
unformattedDisks := quorumUnformattedDisks(sErrs)
|
|
|
|
if unformattedDisks && !firstDisk {
|
2024-05-19 01:06:49 -07:00
|
|
|
return nil, errNotFirstDisk
|
2024-02-14 10:37:34 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
// Return error when quorum unformatted disks but waiting for rest
|
|
|
|
// of the servers to be online.
|
|
|
|
if unformattedDisks && firstDisk {
|
2024-05-19 01:06:49 -07:00
|
|
|
return nil, errFirstDiskWait
|
2024-02-14 10:37:34 -08:00
|
|
|
}
|
|
|
|
|
2020-06-12 20:04:01 -07:00
|
|
|
format, err = getFormatErasureInQuorum(formatConfigs)
|
2018-04-03 23:58:48 -05:00
|
|
|
if err != nil {
|
2024-05-19 01:06:49 -07:00
|
|
|
var drivesNotFound int
|
|
|
|
for _, format := range formatConfigs {
|
|
|
|
if format != nil {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
drivesNotFound++
|
|
|
|
}
|
|
|
|
return nil, fmt.Errorf("%w (offline-drives=%d/%d)", err, drivesNotFound, len(formatConfigs))
|
2018-04-03 23:58:48 -05:00
|
|
|
}
|
|
|
|
|
2018-07-18 20:17:35 -07:00
|
|
|
if format.ID == "" {
|
2024-05-19 01:06:49 -07:00
|
|
|
return nil, errors.New("deployment ID missing from disk format, unable to start the server")
|
2018-07-18 20:17:35 -07:00
|
|
|
}
|
|
|
|
|
2024-05-19 01:06:49 -07:00
|
|
|
return format, nil
|
2018-04-03 23:58:48 -05:00
|
|
|
}
|
|
|
|
|
2018-02-15 17:45:57 -08:00
|
|
|
// Format disks before initialization of object layer.
|
2024-05-19 01:06:49 -07:00
|
|
|
func waitForFormatErasure(firstDisk bool, endpoints Endpoints, poolCount, setCount, setDriveCount int, deploymentID string) (storageDisks []StorageAPI, format *formatErasureV3, err error) {
|
2020-08-26 19:29:35 -07:00
|
|
|
if len(endpoints) == 0 || setCount == 0 || setDriveCount == 0 {
|
2020-03-27 14:48:30 -07:00
|
|
|
return nil, nil, errInvalidArgument
|
2016-11-02 16:51:06 +01:00
|
|
|
}
|
2018-02-15 17:45:57 -08:00
|
|
|
|
2016-12-07 19:22:00 +01:00
|
|
|
// prepare getElapsedTime() to calculate elapsed time since we started trying formatting disks.
|
|
|
|
// All times are rounded to avoid showing milli, micro and nano seconds
|
|
|
|
formatStartTime := time.Now().Round(time.Second)
|
|
|
|
getElapsedTime := func() string {
|
|
|
|
return time.Now().Round(time.Second).Sub(formatStartTime).String()
|
|
|
|
}
|
|
|
|
|
2024-01-25 12:45:46 -08:00
|
|
|
var (
|
|
|
|
tries int
|
|
|
|
verbose bool
|
|
|
|
)
|
|
|
|
|
2024-05-19 01:06:49 -07:00
|
|
|
// Initialize all storage disks
|
|
|
|
storageDisks, errs := initStorageDisksWithErrors(endpoints, storageOpts{cleanUp: true, healthCheck: true})
|
|
|
|
|
|
|
|
if err := checkDiskFatalErrs(errs); err != nil {
|
|
|
|
return nil, nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
defer func() {
|
|
|
|
if err == nil && format != nil {
|
|
|
|
// Assign globalDeploymentID() on first run for the
|
|
|
|
// minio server managing the first disk
|
|
|
|
globalDeploymentIDPtr.Store(&format.ID)
|
|
|
|
|
|
|
|
// Set the deployment ID here to avoid races.
|
|
|
|
xhttp.SetDeploymentID(format.ID)
|
|
|
|
xhttp.SetMinIOVersion(Version)
|
|
|
|
}
|
|
|
|
}()
|
|
|
|
|
|
|
|
format, err = connectLoadInitFormats(verbose, firstDisk, storageDisks, endpoints, poolCount, setCount, setDriveCount, deploymentID)
|
2022-01-20 13:03:15 -08:00
|
|
|
if err == nil {
|
|
|
|
return storageDisks, format, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
tries++ // tried already once
|
|
|
|
|
2020-01-08 22:36:54 +01:00
|
|
|
// Wait on each try for an update.
|
2023-05-11 17:41:33 -07:00
|
|
|
ticker := time.NewTicker(1 * time.Second)
|
2020-01-08 22:36:54 +01:00
|
|
|
defer ticker.Stop()
|
2022-01-20 13:03:15 -08:00
|
|
|
|
2016-11-02 23:27:36 +01:00
|
|
|
for {
|
2023-05-11 17:41:33 -07:00
|
|
|
// Only log once every 10 iterations, then reset the tries count.
|
2024-01-25 12:45:46 -08:00
|
|
|
verbose = tries >= 10
|
|
|
|
if verbose {
|
2023-05-11 17:41:33 -07:00
|
|
|
tries = 1
|
|
|
|
}
|
2022-02-04 12:21:21 -08:00
|
|
|
|
2024-05-19 01:06:49 -07:00
|
|
|
format, err = connectLoadInitFormats(verbose, firstDisk, storageDisks, endpoints, poolCount, setCount, setDriveCount, deploymentID)
|
2023-05-11 17:41:33 -07:00
|
|
|
if err == nil {
|
2020-03-27 14:48:30 -07:00
|
|
|
return storageDisks, format, nil
|
2023-05-11 17:41:33 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
tries++
|
2024-05-19 01:06:49 -07:00
|
|
|
switch {
|
|
|
|
case errors.Is(err, errNotFirstDisk):
|
2023-05-11 17:41:33 -07:00
|
|
|
// Fresh setup, wait for first server to be up.
|
|
|
|
logger.Info("Waiting for the first server to format the drives (elapsed %s)\n", getElapsedTime())
|
2024-05-19 01:06:49 -07:00
|
|
|
case errors.Is(err, errFirstDiskWait):
|
2023-05-11 17:41:33 -07:00
|
|
|
// Fresh setup, wait for other servers to come up.
|
|
|
|
logger.Info("Waiting for all other servers to be online to format the drives (elapses %s)\n", getElapsedTime())
|
2024-05-19 01:06:49 -07:00
|
|
|
case errors.Is(err, errErasureReadQuorum):
|
2023-05-11 17:41:33 -07:00
|
|
|
// no quorum available continue to wait for minimum number of servers.
|
|
|
|
logger.Info("Waiting for a minimum of %d drives to come online (elapsed %s)\n",
|
|
|
|
len(endpoints)/2, getElapsedTime())
|
2024-05-19 01:06:49 -07:00
|
|
|
case errors.Is(err, errErasureWriteQuorum):
|
2023-05-11 17:41:33 -07:00
|
|
|
// no quorum available continue to wait for minimum number of servers.
|
|
|
|
logger.Info("Waiting for a minimum of %d drives to come online (elapsed %s)\n",
|
|
|
|
(len(endpoints)/2)+1, getElapsedTime())
|
2024-05-19 01:06:49 -07:00
|
|
|
case errors.Is(err, errErasureV3ThisEmpty):
|
2023-05-11 17:41:33 -07:00
|
|
|
// need to wait for this error to be healed, so continue.
|
|
|
|
default:
|
|
|
|
// For all other unhandled errors we exit and fail.
|
|
|
|
return nil, nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
select {
|
|
|
|
case <-ticker.C:
|
2018-02-06 15:07:17 -08:00
|
|
|
case <-globalOSSignalCh:
|
2020-03-27 14:48:30 -07:00
|
|
|
return nil, nil, fmt.Errorf("Initializing data volumes gracefully stopped")
|
2016-11-02 23:27:36 +01:00
|
|
|
}
|
|
|
|
}
|
2016-08-30 19:22:27 -07:00
|
|
|
}
|