2018-06-11 13:13:19 +00:00
package stacks
import (
2021-02-23 20:18:05 +00:00
"fmt"
2018-06-11 13:13:19 +00:00
"net/http"
2019-10-07 03:12:21 +00:00
"path"
2018-06-20 17:55:00 +00:00
"strconv"
2021-01-11 23:38:49 +00:00
"time"
2018-06-11 13:13:19 +00:00
"github.com/asaskevich/govalidator"
2021-08-17 01:12:07 +00:00
"github.com/pkg/errors"
2018-09-10 10:01:38 +00:00
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
2021-01-11 23:38:49 +00:00
portainer "github.com/portainer/portainer/api"
2019-03-21 01:20:14 +00:00
"github.com/portainer/portainer/api/filesystem"
2021-08-17 01:12:07 +00:00
gittypes "github.com/portainer/portainer/api/git/types"
2019-03-21 01:20:14 +00:00
"github.com/portainer/portainer/api/http/security"
2018-06-11 13:13:19 +00:00
)
type composeStackFromFileContentPayload struct {
2021-02-23 03:21:39 +00:00
// Name of the stack
Name string ` example:"myStack" validate:"required" `
// Content of the Stack file
StackFileContent string ` example:"version: 3\n services:\n web:\n image:nginx" validate:"required" `
2021-09-20 00:14:22 +00:00
// A list of environment(endpoint) variables used during stack deployment
2021-02-23 03:21:39 +00:00
Env [ ] portainer . Pair ` example:"" `
2018-06-11 13:13:19 +00:00
}
func ( payload * composeStackFromFileContentPayload ) Validate ( r * http . Request ) error {
if govalidator . IsNull ( payload . Name ) {
2020-07-07 21:57:52 +00:00
return errors . New ( "Invalid stack name" )
2018-06-11 13:13:19 +00:00
}
2021-03-14 19:08:31 +00:00
2018-06-11 13:13:19 +00:00
if govalidator . IsNull ( payload . StackFileContent ) {
2020-07-07 21:57:52 +00:00
return errors . New ( "Invalid stack file content" )
2018-06-11 13:13:19 +00:00
}
return nil
}
2019-11-12 23:41:42 +00:00
func ( handler * Handler ) createComposeStackFromFileContent ( w http . ResponseWriter , r * http . Request , endpoint * portainer . Endpoint , userID portainer . UserID ) * httperror . HandlerError {
2018-06-11 13:13:19 +00:00
var payload composeStackFromFileContentPayload
err := request . DecodeAndValidateJSONPayload ( r , & payload )
if err != nil {
2021-03-14 19:08:31 +00:00
return & httperror . HandlerError { StatusCode : http . StatusBadRequest , Message : "Invalid request payload" , Err : err }
2018-06-11 13:13:19 +00:00
}
2021-03-14 19:08:31 +00:00
payload . Name = handler . ComposeStackManager . NormalizeStackName ( payload . Name )
2021-09-29 23:58:10 +00:00
isUnique , err := handler . checkUniqueStackNameInDocker ( endpoint , payload . Name , 0 , false )
2018-06-11 13:13:19 +00:00
if err != nil {
2021-02-23 20:18:05 +00:00
return & httperror . HandlerError { http . StatusInternalServerError , "Unable to check for name collision" , err }
2018-06-11 13:13:19 +00:00
}
2021-02-23 20:18:05 +00:00
if ! isUnique {
errorMessage := fmt . Sprintf ( "A stack with the name '%s' is already running" , payload . Name )
return & httperror . HandlerError { http . StatusConflict , errorMessage , errors . New ( errorMessage ) }
2018-06-11 13:13:19 +00:00
}
2020-05-20 05:23:15 +00:00
stackID := handler . DataStore . Stack ( ) . GetNextIdentifier ( )
2018-06-11 13:13:19 +00:00
stack := & portainer . Stack {
2021-01-11 23:38:49 +00:00
ID : portainer . StackID ( stackID ) ,
Name : payload . Name ,
Type : portainer . DockerComposeStack ,
EndpointID : endpoint . ID ,
EntryPoint : filesystem . ComposeFileDefaultName ,
Env : payload . Env ,
Status : portainer . StackStatusActive ,
CreationDate : time . Now ( ) . Unix ( ) ,
2018-06-11 13:13:19 +00:00
}
2018-06-20 17:55:00 +00:00
stackFolder := strconv . Itoa ( int ( stack . ID ) )
projectPath , err := handler . FileService . StoreStackFileFromBytes ( stackFolder , stack . EntryPoint , [ ] byte ( payload . StackFileContent ) )
2018-06-11 13:13:19 +00:00
if err != nil {
2021-03-14 19:08:31 +00:00
return & httperror . HandlerError { StatusCode : http . StatusInternalServerError , Message : "Unable to persist Compose file on disk" , Err : err }
2018-06-11 13:13:19 +00:00
}
stack . ProjectPath = projectPath
doCleanUp := true
defer handler . cleanUp ( stack , & doCleanUp )
config , configErr := handler . createComposeDeployConfig ( r , stack , endpoint )
if configErr != nil {
return configErr
}
err = handler . deployComposeStack ( config )
if err != nil {
2021-03-14 19:08:31 +00:00
return & httperror . HandlerError { StatusCode : http . StatusInternalServerError , Message : err . Error ( ) , Err : err }
2018-06-11 13:13:19 +00:00
}
2021-01-11 23:38:49 +00:00
stack . CreatedBy = config . user . Username
2020-05-20 05:23:15 +00:00
err = handler . DataStore . Stack ( ) . CreateStack ( stack )
2018-06-11 13:13:19 +00:00
if err != nil {
2021-03-14 19:08:31 +00:00
return & httperror . HandlerError { StatusCode : http . StatusInternalServerError , Message : "Unable to persist the stack inside the database" , Err : err }
2018-06-11 13:13:19 +00:00
}
doCleanUp = false
2019-11-12 23:41:42 +00:00
return handler . decorateStackResponse ( w , stack , userID )
2018-06-11 13:13:19 +00:00
}
type composeStackFromGitRepositoryPayload struct {
2021-02-23 03:21:39 +00:00
// Name of the stack
Name string ` example:"myStack" validate:"required" `
// URL of a Git repository hosting the Stack file
RepositoryURL string ` example:"https://github.com/openfaas/faas" validate:"required" `
// Reference name of a Git repository hosting the Stack file
RepositoryReferenceName string ` example:"refs/heads/master" `
// Use basic authentication to clone the Git repository
RepositoryAuthentication bool ` example:"true" `
// Username used in basic authentication. Required when RepositoryAuthentication is true.
RepositoryUsername string ` example:"myGitUsername" `
// Password used in basic authentication. Required when RepositoryAuthentication is true.
RepositoryPassword string ` example:"myGitPassword" `
// Path to the Stack file inside the Git repository
2021-08-17 01:12:07 +00:00
ComposeFile string ` example:"docker-compose.yml" default:"docker-compose.yml" `
// Applicable when deploying with multiple stack files
AdditionalFiles [ ] string ` example:"[nz.compose.yml, uat.compose.yml]" `
// Optional auto update configuration
AutoUpdate * portainer . StackAutoUpdate
2021-09-20 00:14:22 +00:00
// A list of environment(endpoint) variables used during stack deployment
2021-02-23 03:21:39 +00:00
Env [ ] portainer . Pair
2018-06-11 13:13:19 +00:00
}
func ( payload * composeStackFromGitRepositoryPayload ) Validate ( r * http . Request ) error {
if govalidator . IsNull ( payload . Name ) {
2020-07-07 21:57:52 +00:00
return errors . New ( "Invalid stack name" )
2018-06-11 13:13:19 +00:00
}
if govalidator . IsNull ( payload . RepositoryURL ) || ! govalidator . IsURL ( payload . RepositoryURL ) {
2020-07-07 21:57:52 +00:00
return errors . New ( "Invalid repository URL. Must correspond to a valid URL format" )
2018-06-11 13:13:19 +00:00
}
2021-08-17 01:12:07 +00:00
if govalidator . IsNull ( payload . RepositoryReferenceName ) {
payload . RepositoryReferenceName = defaultGitReferenceName
}
if payload . RepositoryAuthentication && govalidator . IsNull ( payload . RepositoryPassword ) {
return errors . New ( "Invalid repository credentials. Password must be specified when authentication is enabled" )
}
if err := validateStackAutoUpdate ( payload . AutoUpdate ) ; err != nil {
return err
2018-06-11 13:13:19 +00:00
}
return nil
}
2019-11-12 23:41:42 +00:00
func ( handler * Handler ) createComposeStackFromGitRepository ( w http . ResponseWriter , r * http . Request , endpoint * portainer . Endpoint , userID portainer . UserID ) * httperror . HandlerError {
2018-06-11 13:13:19 +00:00
var payload composeStackFromGitRepositoryPayload
err := request . DecodeAndValidateJSONPayload ( r , & payload )
if err != nil {
2021-03-14 19:08:31 +00:00
return & httperror . HandlerError { StatusCode : http . StatusBadRequest , Message : "Invalid request payload" , Err : err }
}
payload . Name = handler . ComposeStackManager . NormalizeStackName ( payload . Name )
2021-08-17 01:12:07 +00:00
if payload . ComposeFile == "" {
payload . ComposeFile = filesystem . ComposeFileDefaultName
2018-06-11 13:13:19 +00:00
}
2021-09-29 23:58:10 +00:00
isUnique , err := handler . checkUniqueStackNameInDocker ( endpoint , payload . Name , 0 , false )
2018-06-11 13:13:19 +00:00
if err != nil {
2021-08-17 01:12:07 +00:00
return & httperror . HandlerError { StatusCode : http . StatusInternalServerError , Message : "Unable to check for name collision" , Err : err }
2018-06-11 13:13:19 +00:00
}
2021-02-23 20:18:05 +00:00
if ! isUnique {
2021-08-17 01:12:07 +00:00
return & httperror . HandlerError { StatusCode : http . StatusConflict , Message : fmt . Sprintf ( "A stack with the name '%s' already exists" , payload . Name ) , Err : errStackAlreadyExists }
}
//make sure the webhook ID is unique
if payload . AutoUpdate != nil && payload . AutoUpdate . Webhook != "" {
isUnique , err := handler . checkUniqueWebhookID ( payload . AutoUpdate . Webhook )
if err != nil {
return & httperror . HandlerError { StatusCode : http . StatusInternalServerError , Message : "Unable to check for webhook ID collision" , Err : err }
}
if ! isUnique {
return & httperror . HandlerError { StatusCode : http . StatusConflict , Message : fmt . Sprintf ( "Webhook ID: %s already exists" , payload . AutoUpdate . Webhook ) , Err : errWebhookIDAlreadyExists }
}
2018-06-11 13:13:19 +00:00
}
2020-05-20 05:23:15 +00:00
stackID := handler . DataStore . Stack ( ) . GetNextIdentifier ( )
2018-06-11 13:13:19 +00:00
stack := & portainer . Stack {
2021-08-17 01:12:07 +00:00
ID : portainer . StackID ( stackID ) ,
Name : payload . Name ,
Type : portainer . DockerComposeStack ,
EndpointID : endpoint . ID ,
EntryPoint : payload . ComposeFile ,
AdditionalFiles : payload . AdditionalFiles ,
AutoUpdate : payload . AutoUpdate ,
Env : payload . Env ,
GitConfig : & gittypes . RepoConfig {
URL : payload . RepositoryURL ,
ReferenceName : payload . RepositoryReferenceName ,
ConfigFilePath : payload . ComposeFile ,
} ,
2021-01-11 23:38:49 +00:00
Status : portainer . StackStatusActive ,
CreationDate : time . Now ( ) . Unix ( ) ,
2018-06-11 13:13:19 +00:00
}
2021-08-17 01:12:07 +00:00
if payload . RepositoryAuthentication {
stack . GitConfig . Authentication = & gittypes . GitAuthentication {
Username : payload . RepositoryUsername ,
Password : payload . RepositoryPassword ,
}
}
2018-06-25 11:48:28 +00:00
projectPath := handler . FileService . GetStackProjectPath ( strconv . Itoa ( int ( stack . ID ) ) )
2018-06-11 13:13:19 +00:00
stack . ProjectPath = projectPath
2018-07-24 14:11:35 +00:00
doCleanUp := true
defer handler . cleanUp ( stack , & doCleanUp )
2021-08-17 01:12:07 +00:00
err = handler . clone ( projectPath , payload . RepositoryURL , payload . RepositoryReferenceName , payload . RepositoryAuthentication , payload . RepositoryUsername , payload . RepositoryPassword )
2018-06-11 13:13:19 +00:00
if err != nil {
2021-03-14 19:08:31 +00:00
return & httperror . HandlerError { StatusCode : http . StatusInternalServerError , Message : "Unable to clone git repository" , Err : err }
2018-06-11 13:13:19 +00:00
}
2021-09-29 23:58:10 +00:00
commitID , err := handler . latestCommitID ( payload . RepositoryURL , payload . RepositoryReferenceName , payload . RepositoryAuthentication , payload . RepositoryUsername , payload . RepositoryPassword )
2021-08-17 01:12:07 +00:00
if err != nil {
return & httperror . HandlerError { StatusCode : http . StatusInternalServerError , Message : "Unable to fetch git repository id" , Err : err }
}
2021-09-29 23:58:10 +00:00
stack . GitConfig . ConfigHash = commitID
2021-08-17 01:12:07 +00:00
2018-06-11 13:13:19 +00:00
config , configErr := handler . createComposeDeployConfig ( r , stack , endpoint )
if configErr != nil {
return configErr
}
err = handler . deployComposeStack ( config )
if err != nil {
2021-03-14 19:08:31 +00:00
return & httperror . HandlerError { StatusCode : http . StatusInternalServerError , Message : err . Error ( ) , Err : err }
2018-06-11 13:13:19 +00:00
}
2021-08-17 01:12:07 +00:00
if payload . AutoUpdate != nil && payload . AutoUpdate . Interval != "" {
jobID , e := startAutoupdate ( stack . ID , stack . AutoUpdate . Interval , handler . Scheduler , handler . StackDeployer , handler . DataStore , handler . GitService )
if e != nil {
return e
}
stack . AutoUpdate . JobID = jobID
}
2021-01-11 23:38:49 +00:00
stack . CreatedBy = config . user . Username
2020-05-20 05:23:15 +00:00
err = handler . DataStore . Stack ( ) . CreateStack ( stack )
2018-06-11 13:13:19 +00:00
if err != nil {
2021-03-14 19:08:31 +00:00
return & httperror . HandlerError { StatusCode : http . StatusInternalServerError , Message : "Unable to persist the stack inside the database" , Err : err }
2018-06-11 13:13:19 +00:00
}
doCleanUp = false
2019-11-12 23:41:42 +00:00
return handler . decorateStackResponse ( w , stack , userID )
2018-06-11 13:13:19 +00:00
}
type composeStackFromFileUploadPayload struct {
Name string
StackFileContent [ ] byte
2018-07-12 07:17:07 +00:00
Env [ ] portainer . Pair
2018-06-11 13:13:19 +00:00
}
2021-03-14 19:08:31 +00:00
func decodeRequestForm ( r * http . Request ) ( * composeStackFromFileUploadPayload , error ) {
payload := & composeStackFromFileUploadPayload { }
2018-06-11 13:13:19 +00:00
name , err := request . RetrieveMultiPartFormValue ( r , "Name" , false )
if err != nil {
2021-03-14 19:08:31 +00:00
return nil , errors . New ( "Invalid stack name" )
2018-06-11 13:13:19 +00:00
}
2021-03-14 19:08:31 +00:00
payload . Name = name
2018-06-11 13:13:19 +00:00
2018-09-10 10:01:38 +00:00
composeFileContent , _ , err := request . RetrieveMultiPartFormFile ( r , "file" )
2018-06-11 13:13:19 +00:00
if err != nil {
2021-03-14 19:08:31 +00:00
return nil , errors . New ( "Invalid Compose file. Ensure that the Compose file is uploaded correctly" )
2018-06-11 13:13:19 +00:00
}
payload . StackFileContent = composeFileContent
2018-07-12 07:17:07 +00:00
var env [ ] portainer . Pair
err = request . RetrieveMultiPartFormJSONValue ( r , "Env" , & env , true )
if err != nil {
2021-03-14 19:08:31 +00:00
return nil , errors . New ( "Invalid Env parameter" )
2018-07-12 07:17:07 +00:00
}
payload . Env = env
2021-03-14 19:08:31 +00:00
return payload , nil
2018-06-11 13:13:19 +00:00
}
2019-11-12 23:41:42 +00:00
func ( handler * Handler ) createComposeStackFromFileUpload ( w http . ResponseWriter , r * http . Request , endpoint * portainer . Endpoint , userID portainer . UserID ) * httperror . HandlerError {
2021-03-14 19:08:31 +00:00
payload , err := decodeRequestForm ( r )
2018-06-11 13:13:19 +00:00
if err != nil {
2021-03-14 19:08:31 +00:00
return & httperror . HandlerError { StatusCode : http . StatusBadRequest , Message : "Invalid request payload" , Err : err }
2018-06-11 13:13:19 +00:00
}
2021-03-14 19:08:31 +00:00
payload . Name = handler . ComposeStackManager . NormalizeStackName ( payload . Name )
2021-09-29 23:58:10 +00:00
isUnique , err := handler . checkUniqueStackNameInDocker ( endpoint , payload . Name , 0 , false )
2018-06-11 13:13:19 +00:00
if err != nil {
2021-06-16 21:47:32 +00:00
return & httperror . HandlerError { StatusCode : http . StatusInternalServerError , Message : "Unable to check for name collision" , Err : err }
2018-06-11 13:13:19 +00:00
}
2021-02-23 20:18:05 +00:00
if ! isUnique {
2021-03-14 19:08:31 +00:00
errorMessage := fmt . Sprintf ( "A stack with the name '%s' already exists" , payload . Name )
2021-06-16 21:47:32 +00:00
return & httperror . HandlerError { StatusCode : http . StatusConflict , Message : errorMessage , Err : errors . New ( errorMessage ) }
2018-06-11 13:13:19 +00:00
}
2020-05-20 05:23:15 +00:00
stackID := handler . DataStore . Stack ( ) . GetNextIdentifier ( )
2018-06-11 13:13:19 +00:00
stack := & portainer . Stack {
2021-01-11 23:38:49 +00:00
ID : portainer . StackID ( stackID ) ,
Name : payload . Name ,
Type : portainer . DockerComposeStack ,
EndpointID : endpoint . ID ,
EntryPoint : filesystem . ComposeFileDefaultName ,
Env : payload . Env ,
Status : portainer . StackStatusActive ,
CreationDate : time . Now ( ) . Unix ( ) ,
2018-06-11 13:13:19 +00:00
}
2018-06-20 17:55:00 +00:00
stackFolder := strconv . Itoa ( int ( stack . ID ) )
2019-10-07 03:12:21 +00:00
projectPath , err := handler . FileService . StoreStackFileFromBytes ( stackFolder , stack . EntryPoint , payload . StackFileContent )
2018-06-11 13:13:19 +00:00
if err != nil {
2021-03-14 19:08:31 +00:00
return & httperror . HandlerError { StatusCode : http . StatusInternalServerError , Message : "Unable to persist Compose file on disk" , Err : err }
2018-06-11 13:13:19 +00:00
}
stack . ProjectPath = projectPath
doCleanUp := true
defer handler . cleanUp ( stack , & doCleanUp )
config , configErr := handler . createComposeDeployConfig ( r , stack , endpoint )
if configErr != nil {
return configErr
}
err = handler . deployComposeStack ( config )
if err != nil {
2021-03-14 19:08:31 +00:00
return & httperror . HandlerError { StatusCode : http . StatusInternalServerError , Message : err . Error ( ) , Err : err }
2018-06-11 13:13:19 +00:00
}
2021-01-11 23:38:49 +00:00
stack . CreatedBy = config . user . Username
2020-05-20 05:23:15 +00:00
err = handler . DataStore . Stack ( ) . CreateStack ( stack )
2018-06-11 13:13:19 +00:00
if err != nil {
2021-03-14 19:08:31 +00:00
return & httperror . HandlerError { StatusCode : http . StatusInternalServerError , Message : "Unable to persist the stack inside the database" , Err : err }
2018-06-11 13:13:19 +00:00
}
doCleanUp = false
2019-11-12 23:41:42 +00:00
return handler . decorateStackResponse ( w , stack , userID )
2018-06-11 13:13:19 +00:00
}
type composeStackDeploymentConfig struct {
stack * portainer . Stack
endpoint * portainer . Endpoint
registries [ ] portainer . Registry
2019-10-07 03:12:21 +00:00
isAdmin bool
2020-07-22 18:38:45 +00:00
user * portainer . User
2018-06-11 13:13:19 +00:00
}
func ( handler * Handler ) createComposeDeployConfig ( r * http . Request , stack * portainer . Stack , endpoint * portainer . Endpoint ) ( * composeStackDeploymentConfig , * httperror . HandlerError ) {
securityContext , err := security . RetrieveRestrictedRequestContext ( r )
if err != nil {
2021-03-14 19:08:31 +00:00
return nil , & httperror . HandlerError { StatusCode : http . StatusInternalServerError , Message : "Unable to retrieve info from request context" , Err : err }
2018-06-11 13:13:19 +00:00
}
2021-07-14 09:15:21 +00:00
user , err := handler . DataStore . User ( ) . User ( securityContext . UserID )
2018-06-11 13:13:19 +00:00
if err != nil {
2021-07-14 09:15:21 +00:00
return nil , & httperror . HandlerError { StatusCode : http . StatusInternalServerError , Message : "Unable to load user information from the database" , Err : err }
2018-06-11 13:13:19 +00:00
}
2020-05-20 05:23:15 +00:00
registries , err := handler . DataStore . Registry ( ) . Registries ( )
2018-06-11 13:13:19 +00:00
if err != nil {
2021-03-14 19:08:31 +00:00
return nil , & httperror . HandlerError { StatusCode : http . StatusInternalServerError , Message : "Unable to retrieve registries from the database" , Err : err }
2018-06-11 13:13:19 +00:00
}
2021-07-14 09:15:21 +00:00
filteredRegistries := security . FilterRegistries ( registries , user , securityContext . UserMemberships , endpoint . ID )
2020-07-22 18:38:45 +00:00
2018-06-11 13:13:19 +00:00
config := & composeStackDeploymentConfig {
stack : stack ,
endpoint : endpoint ,
registries : filteredRegistries ,
2019-10-07 03:12:21 +00:00
isAdmin : securityContext . IsAdmin ,
2020-07-22 18:38:45 +00:00
user : user ,
2018-06-11 13:13:19 +00:00
}
return config , nil
}
// TODO: libcompose uses credentials store into a config.json file to pull images from
// private registries. Right now the only solution is to re-use the embedded Docker binary
// to login/logout, which will generate the required data in the config.json file and then
// clean it. Hence the use of the mutex.
// We should contribute to libcompose to support authentication without using the config.json file.
func ( handler * Handler ) deployComposeStack ( config * composeStackDeploymentConfig ) error {
2020-07-22 18:38:45 +00:00
isAdminOrEndpointAdmin , err := handler . userIsAdminOrEndpointAdmin ( config . user , config . endpoint . ID )
if err != nil {
2021-08-17 01:12:07 +00:00
return errors . Wrap ( err , "failed to check user priviliges deploying a stack" )
2020-07-22 18:38:45 +00:00
}
2021-02-09 08:09:06 +00:00
securitySettings := & config . endpoint . SecuritySettings
if ( ! securitySettings . AllowBindMountsForRegularUsers ||
! securitySettings . AllowPrivilegedModeForRegularUsers ||
! securitySettings . AllowHostNamespaceForRegularUsers ||
! securitySettings . AllowDeviceMappingForRegularUsers ||
2021-04-12 07:40:45 +00:00
! securitySettings . AllowSysctlSettingForRegularUsers ||
2021-02-09 08:09:06 +00:00
! securitySettings . AllowContainerCapabilitiesForRegularUsers ) &&
2020-07-24 23:14:46 +00:00
! isAdminOrEndpointAdmin {
2021-08-17 01:12:07 +00:00
for _ , file := range append ( [ ] string { config . stack . EntryPoint } , config . stack . AdditionalFiles ... ) {
path := path . Join ( config . stack . ProjectPath , file )
stackContent , err := handler . FileService . GetFileContent ( path )
if err != nil {
return errors . Wrapf ( err , "failed to get stack file content `%q`" , path )
}
err = handler . isValidStackFile ( stackContent , securitySettings )
if err != nil {
return errors . Wrap ( err , "compose file is invalid" )
}
2019-10-07 03:12:21 +00:00
}
}
2021-09-07 00:37:26 +00:00
return handler . StackDeployer . DeployComposeStack ( config . stack , config . endpoint , config . registries )
2018-06-11 13:13:19 +00:00
}