2015-09-27 13:59:51 +00:00
// This is the main file that sets up integration tests using go-check.
2017-07-06 14:28:13 +00:00
package integration
2015-09-27 13:59:51 +00:00
import (
2017-05-17 13:22:44 +00:00
"bytes"
2021-11-25 10:10:06 +00:00
"context"
2022-07-13 16:32:08 +00:00
"errors"
2017-10-13 09:08:03 +00:00
"flag"
2015-09-27 13:59:51 +00:00
"fmt"
2024-01-05 14:10:05 +00:00
"io"
2022-07-13 16:32:08 +00:00
"io/fs"
2022-11-21 17:36:05 +00:00
stdlog "log"
2024-01-09 16:00:07 +00:00
"net/http"
2015-09-28 20:37:19 +00:00
"os"
"os/exec"
"path/filepath"
2024-01-09 16:00:07 +00:00
"regexp"
2024-01-17 10:12:05 +00:00
"runtime"
2024-01-09 16:00:07 +00:00
"slices"
2019-06-17 09:48:05 +00:00
"strings"
2015-09-27 13:59:51 +00:00
"testing"
2015-09-28 20:37:19 +00:00
"text/template"
2020-10-09 07:32:03 +00:00
"time"
2015-09-27 13:59:51 +00:00
2024-01-09 16:00:07 +00:00
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/mount"
dockernetwork "github.com/docker/docker/api/types/network"
2019-07-15 08:22:03 +00:00
"github.com/fatih/structs"
2022-11-21 17:36:05 +00:00
"github.com/rs/zerolog/log"
2024-01-09 16:00:07 +00:00
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/network"
2024-01-10 09:47:44 +00:00
"github.com/traefik/traefik/v3/integration/try"
2024-01-09 16:00:07 +00:00
"gopkg.in/yaml.v3"
2015-09-27 13:59:51 +00:00
)
2024-01-22 14:30:05 +00:00
var (
2024-08-30 15:14:03 +00:00
showLog = flag . Bool ( "tlog" , false , "always show Traefik logs" )
k8sConformance = flag . Bool ( "k8sConformance" , false , "run K8s Gateway API conformance test" )
k8sConformanceRunTest = flag . String ( "k8sConformanceRunTest" , "" , "run a specific K8s Gateway API conformance test" )
k8sConformanceTraefikVersion = flag . String ( "k8sConformanceTraefikVersion" , "dev" , "specify the Traefik version for the K8s Gateway API conformance report" )
2024-01-22 14:30:05 +00:00
)
2017-10-13 09:08:03 +00:00
2024-01-19 14:44:05 +00:00
const tailscaleSecretFilePath = "tailscale.secret"
2024-01-09 16:00:07 +00:00
type composeConfig struct {
Services map [ string ] composeService ` yaml:"services" `
}
2017-10-13 09:08:03 +00:00
2024-01-09 16:00:07 +00:00
type composeService struct {
Image string ` yaml:"image" `
Labels map [ string ] string ` yaml:"labels" `
Hostname string ` yaml:"hostname" `
Volumes [ ] string ` yaml:"volumes" `
CapAdd [ ] string ` yaml:"cap_add" `
Command [ ] string ` yaml:"command" `
Environment map [ string ] string ` yaml:"environment" `
Privileged bool ` yaml:"privileged" `
Deploy composeDeploy ` yaml:"deploy" `
}
type composeDeploy struct {
Replicas int ` yaml:"replicas" `
}
type BaseSuite struct {
suite . Suite
containers map [ string ] testcontainers . Container
network * testcontainers . DockerNetwork
hostIP string
}
func ( s * BaseSuite ) waitForTraefik ( containerName string ) {
time . Sleep ( 1 * time . Second )
2017-10-13 09:08:03 +00:00
2024-01-09 16:00:07 +00:00
// Wait for Traefik to turn ready.
req , err := http . NewRequest ( http . MethodGet , "http://127.0.0.1:8080/api/rawdata" , nil )
require . NoError ( s . T ( ) , err )
2022-11-21 17:36:05 +00:00
2024-01-09 16:00:07 +00:00
err = try . Request ( req , 2 * time . Second , try . StatusCodeIs ( http . StatusOK ) , try . BodyContains ( containerName ) )
require . NoError ( s . T ( ) , err )
}
2022-11-21 17:36:05 +00:00
2024-01-09 16:00:07 +00:00
func ( s * BaseSuite ) displayTraefikLogFile ( path string ) {
if s . T ( ) . Failed ( ) {
if _ , err := os . Stat ( path ) ; ! os . IsNotExist ( err ) {
content , errRead := os . ReadFile ( path )
// TODO TestName
// fmt.Printf("%s: Traefik logs: \n", c.TestName())
fmt . Print ( "Traefik logs: \n" )
if errRead == nil {
fmt . Println ( content )
} else {
fmt . Println ( errRead )
}
} else {
// fmt.Printf("%s: No Traefik logs.\n", c.TestName())
fmt . Print ( "No Traefik logs.\n" )
}
errRemove := os . Remove ( path )
if errRemove != nil {
fmt . Println ( errRemove )
2022-07-13 16:32:08 +00:00
}
}
2024-01-09 16:00:07 +00:00
}
2022-07-13 16:32:08 +00:00
2024-01-09 16:00:07 +00:00
func ( s * BaseSuite ) SetupSuite ( ) {
2024-01-19 14:44:05 +00:00
if isDockerDesktop ( context . Background ( ) , s . T ( ) ) {
_ , err := os . Stat ( tailscaleSecretFilePath )
require . NoError ( s . T ( ) , err , "Tailscale need to be configured when running integration tests with Docker Desktop: (https://doc.traefik.io/traefik/v2.11/contributing/building-testing/#testing)" )
}
2022-11-21 17:36:05 +00:00
// configure default standard log.
stdlog . SetFlags ( stdlog . Lshortfile | stdlog . LstdFlags )
2024-01-09 16:00:07 +00:00
// TODO
// stdlog.SetOutput(log.Logger)
ctx := context . Background ( )
// Create docker network
// docker network create traefik-test-network --driver bridge --subnet 172.31.42.0/24
var opts [ ] network . NetworkCustomizer
opts = append ( opts , network . WithDriver ( "bridge" ) )
opts = append ( opts , network . WithIPAM ( & dockernetwork . IPAM {
Driver : "default" ,
Config : [ ] dockernetwork . IPAMConfig {
{
Subnet : "172.31.42.0/24" ,
} ,
} ,
} ) )
dockerNetwork , err := network . New ( ctx , opts ... )
require . NoError ( s . T ( ) , err )
s . network = dockerNetwork
s . hostIP = "172.31.42.1"
if isDockerDesktop ( ctx , s . T ( ) ) {
s . hostIP = getDockerDesktopHostIP ( ctx , s . T ( ) )
2024-01-19 14:44:05 +00:00
s . setupVPN ( tailscaleSecretFilePath )
2022-07-13 16:32:08 +00:00
}
2015-09-27 13:59:51 +00:00
}
2024-01-09 16:00:07 +00:00
func getDockerDesktopHostIP ( ctx context . Context , t * testing . T ) string {
t . Helper ( )
2022-07-13 16:32:08 +00:00
2024-01-09 16:00:07 +00:00
req := testcontainers . ContainerRequest {
Image : "alpine" ,
HostConfigModifier : func ( config * container . HostConfig ) {
config . AutoRemove = true
} ,
Cmd : [ ] string { "getent" , "hosts" , "host.docker.internal" } ,
2022-07-13 16:32:08 +00:00
}
2024-01-09 16:00:07 +00:00
con , err := testcontainers . GenericContainer ( ctx , testcontainers . GenericContainerRequest {
ContainerRequest : req ,
Started : true ,
} )
require . NoError ( t , err )
closer , err := con . Logs ( ctx )
require . NoError ( t , err )
all , err := io . ReadAll ( closer )
require . NoError ( t , err )
ipRegex := regexp . MustCompile ( ` \b(?:\d { 1,3}\.) { 3}\d { 1,3}\b ` )
matches := ipRegex . FindAllString ( string ( all ) , - 1 )
require . Len ( t , matches , 1 )
return matches [ 0 ]
2015-09-27 13:59:51 +00:00
}
2024-01-09 16:00:07 +00:00
func isDockerDesktop ( ctx context . Context , t * testing . T ) bool {
t . Helper ( )
cli , err := testcontainers . NewDockerClientWithOpts ( ctx )
if err != nil {
t . Fatalf ( "failed to create docker client: %s" , err )
2022-07-13 16:32:08 +00:00
}
2024-01-09 16:00:07 +00:00
info , err := cli . Info ( ctx )
if err != nil {
t . Fatalf ( "failed to get docker info: %s" , err )
2022-07-13 16:32:08 +00:00
}
2024-01-09 16:00:07 +00:00
return info . OperatingSystem == "Docker Desktop"
2015-09-27 13:59:51 +00:00
}
2024-01-09 16:00:07 +00:00
func ( s * BaseSuite ) TearDownSuite ( ) {
s . composeDown ( )
2015-09-27 13:59:51 +00:00
2024-01-09 16:00:07 +00:00
err := try . Do ( 5 * time . Second , func ( ) error {
if s . network != nil {
err := s . network . Remove ( context . Background ( ) )
if err != nil {
return err
}
}
2015-09-27 13:59:51 +00:00
2024-01-09 16:00:07 +00:00
return nil
} )
require . NoError ( s . T ( ) , err )
2015-09-27 13:59:51 +00:00
}
2021-11-25 10:10:06 +00:00
// createComposeProject creates the docker compose project stored as a field in the BaseSuite.
// This method should be called before starting and/or stopping compose services.
2024-01-09 16:00:07 +00:00
func ( s * BaseSuite ) createComposeProject ( name string ) {
2016-03-27 17:58:08 +00:00
composeFile := fmt . Sprintf ( "resources/compose/%s.yml" , name )
2016-12-12 17:30:31 +00:00
2024-01-09 16:00:07 +00:00
file , err := os . ReadFile ( composeFile )
require . NoError ( s . T ( ) , err )
2016-12-12 17:30:31 +00:00
2024-01-09 16:00:07 +00:00
var composeConfigData composeConfig
err = yaml . Unmarshal ( file , & composeConfigData )
require . NoError ( s . T ( ) , err )
2021-11-25 10:10:06 +00:00
2024-01-09 16:00:07 +00:00
if s . containers == nil {
s . containers = map [ string ] testcontainers . Container { }
2024-01-05 14:10:05 +00:00
}
2015-09-28 20:37:19 +00:00
2024-01-09 16:00:07 +00:00
ctx := context . Background ( )
for id , containerConfig := range composeConfigData . Services {
var mounts [ ] mount . Mount
for _ , volume := range containerConfig . Volumes {
split := strings . Split ( volume , ":" )
if len ( split ) != 2 {
continue
}
if strings . HasPrefix ( split [ 0 ] , "./" ) {
path , err := os . Getwd ( )
if err != nil {
2024-01-10 09:47:44 +00:00
log . Err ( err ) . Msg ( "can't determine current directory" )
2024-01-09 16:00:07 +00:00
continue
}
split [ 0 ] = strings . Replace ( split [ 0 ] , "./" , path + "/" , 1 )
}
abs , err := filepath . Abs ( split [ 0 ] )
require . NoError ( s . T ( ) , err )
mounts = append ( mounts , mount . Mount { Source : abs , Target : split [ 1 ] , Type : mount . TypeBind } )
}
2021-11-25 10:10:06 +00:00
2024-01-09 16:00:07 +00:00
if containerConfig . Deploy . Replicas > 0 {
2024-02-19 14:44:03 +00:00
for i := range containerConfig . Deploy . Replicas {
2024-01-09 16:00:07 +00:00
id = fmt . Sprintf ( "%s-%d" , id , i + 1 )
con , err := s . createContainer ( ctx , containerConfig , id , mounts )
require . NoError ( s . T ( ) , err )
s . containers [ id ] = con
}
continue
}
2021-11-25 10:10:06 +00:00
2024-01-09 16:00:07 +00:00
con , err := s . createContainer ( ctx , containerConfig , id , mounts )
require . NoError ( s . T ( ) , err )
s . containers [ id ] = con
}
2021-11-25 10:10:06 +00:00
}
2024-01-09 16:00:07 +00:00
func ( s * BaseSuite ) createContainer ( ctx context . Context , containerConfig composeService , id string , mounts [ ] mount . Mount ) ( testcontainers . Container , error ) {
req := testcontainers . ContainerRequest {
Image : containerConfig . Image ,
Env : containerConfig . Environment ,
Cmd : containerConfig . Command ,
Labels : containerConfig . Labels ,
Name : id ,
Hostname : containerConfig . Hostname ,
Privileged : containerConfig . Privileged ,
Networks : [ ] string { s . network . Name } ,
HostConfigModifier : func ( config * container . HostConfig ) {
if containerConfig . CapAdd != nil {
config . CapAdd = containerConfig . CapAdd
}
if ! isDockerDesktop ( ctx , s . T ( ) ) {
config . ExtraHosts = append ( config . ExtraHosts , "host.docker.internal:" + s . hostIP )
}
config . Mounts = mounts
} ,
}
con , err := testcontainers . GenericContainer ( ctx , testcontainers . GenericContainerRequest {
ContainerRequest : req ,
Started : false ,
2022-07-13 16:32:08 +00:00
} )
2024-01-09 16:00:07 +00:00
return con , err
2021-11-25 10:10:06 +00:00
}
2024-01-09 16:00:07 +00:00
// composeUp starts the given services of the current docker compose project, if they are not already started.
2022-07-13 16:32:08 +00:00
// Already running services are not affected (i.e. not stopped).
2024-01-09 16:00:07 +00:00
func ( s * BaseSuite ) composeUp ( services ... string ) {
for name , con := range s . containers {
if len ( services ) == 0 || slices . Contains ( services , name ) {
err := con . Start ( context . Background ( ) )
require . NoError ( s . T ( ) , err )
}
}
2022-07-13 16:32:08 +00:00
}
2021-11-25 10:10:06 +00:00
// composeStop stops the given services of the current docker compose project and removes the corresponding containers.
2024-01-09 16:00:07 +00:00
func ( s * BaseSuite ) composeStop ( services ... string ) {
for name , con := range s . containers {
if len ( services ) == 0 || slices . Contains ( services , name ) {
timeout := 10 * time . Second
err := con . Stop ( context . Background ( ) , & timeout )
require . NoError ( s . T ( ) , err )
}
}
2021-11-25 10:10:06 +00:00
}
// composeDown stops all compose project services and removes the corresponding containers.
2024-01-09 16:00:07 +00:00
func ( s * BaseSuite ) composeDown ( ) {
for _ , c := range s . containers {
err := c . Terminate ( context . Background ( ) )
require . NoError ( s . T ( ) , err )
}
s . containers = map [ string ] testcontainers . Container { }
2017-05-17 13:22:44 +00:00
}
func ( s * BaseSuite ) cmdTraefik ( args ... string ) ( * exec . Cmd , * bytes . Buffer ) {
2024-01-17 10:12:05 +00:00
binName := "traefik"
if runtime . GOOS == "windows" {
binName += ".exe"
}
traefikBinPath := filepath . Join ( ".." , "dist" , runtime . GOOS , runtime . GOARCH , binName )
cmd := exec . Command ( traefikBinPath , args ... )
2024-01-09 16:00:07 +00:00
s . T ( ) . Cleanup ( func ( ) {
s . killCmd ( cmd )
} )
2017-05-17 13:22:44 +00:00
var out bytes . Buffer
cmd . Stdout = & out
cmd . Stderr = & out
2024-01-09 16:00:07 +00:00
err := cmd . Start ( )
require . NoError ( s . T ( ) , err )
2017-05-17 13:22:44 +00:00
return cmd , & out
}
2020-10-09 07:32:03 +00:00
func ( s * BaseSuite ) killCmd ( cmd * exec . Cmd ) {
2024-01-09 16:00:07 +00:00
if cmd . Process == nil {
2024-01-10 09:47:44 +00:00
log . Error ( ) . Msg ( "No process to kill" )
2024-01-09 16:00:07 +00:00
return
}
2020-10-09 07:32:03 +00:00
err := cmd . Process . Kill ( )
if err != nil {
2022-11-21 17:36:05 +00:00
log . Error ( ) . Err ( err ) . Msg ( "Kill" )
2020-10-09 07:32:03 +00:00
}
time . Sleep ( 100 * time . Millisecond )
}
2024-01-09 16:00:07 +00:00
func ( s * BaseSuite ) traefikCmd ( args ... string ) * exec . Cmd {
2017-09-13 08:34:04 +00:00
cmd , out := s . cmdTraefik ( args ... )
2024-01-09 16:00:07 +00:00
s . T ( ) . Cleanup ( func ( ) {
if s . T ( ) . Failed ( ) || * showLog {
2022-08-31 06:24:08 +00:00
s . displayLogK3S ( )
2024-01-09 16:00:07 +00:00
s . displayLogCompose ( )
s . displayTraefikLog ( out )
2017-09-13 08:34:04 +00:00
}
2024-01-09 16:00:07 +00:00
} )
return cmd
2017-09-13 08:34:04 +00:00
}
2022-08-31 06:24:08 +00:00
func ( s * BaseSuite ) displayLogK3S ( ) {
2019-08-11 10:22:14 +00:00
filePath := "./fixtures/k8s/config.skip/k3s.log"
if _ , err := os . Stat ( filePath ) ; err == nil {
2021-03-04 19:08:03 +00:00
content , errR := os . ReadFile ( filePath )
2019-08-11 10:22:14 +00:00
if errR != nil {
2022-11-21 17:36:05 +00:00
log . Error ( ) . Err ( errR ) . Send ( )
2019-08-11 10:22:14 +00:00
}
2022-11-21 17:36:05 +00:00
log . Print ( string ( content ) )
2019-08-11 10:22:14 +00:00
}
2022-11-21 17:36:05 +00:00
log . Print ( )
log . Print ( "################################" )
log . Print ( )
2019-08-11 10:22:14 +00:00
}
2024-01-09 16:00:07 +00:00
func ( s * BaseSuite ) displayLogCompose ( ) {
for name , ctn := range s . containers {
readCloser , err := ctn . Logs ( context . Background ( ) )
require . NoError ( s . T ( ) , err )
for {
b := make ( [ ] byte , 1024 )
_ , err := readCloser . Read ( b )
if errors . Is ( err , io . EOF ) {
break
}
require . NoError ( s . T ( ) , err )
trimLogs := bytes . Trim ( bytes . TrimSpace ( b ) , string ( [ ] byte { 0 } ) )
if len ( trimLogs ) > 0 {
2024-01-10 09:47:44 +00:00
log . Info ( ) . Str ( "container" , name ) . Msg ( string ( trimLogs ) )
2024-01-09 16:00:07 +00:00
}
}
2021-11-25 10:10:06 +00:00
}
}
2024-01-09 16:00:07 +00:00
func ( s * BaseSuite ) displayTraefikLog ( output * bytes . Buffer ) {
2017-05-17 13:22:44 +00:00
if output == nil || output . Len ( ) == 0 {
2024-01-10 09:47:44 +00:00
log . Info ( ) . Msg ( "No Traefik logs." )
2017-05-17 13:22:44 +00:00
} else {
2024-01-09 16:00:07 +00:00
for _ , line := range strings . Split ( output . String ( ) , "\n" ) {
2024-01-10 09:47:44 +00:00
log . Info ( ) . Msg ( line )
2024-01-09 16:00:07 +00:00
}
2017-05-17 13:22:44 +00:00
}
}
2019-01-21 18:06:02 +00:00
func ( s * BaseSuite ) getDockerHost ( ) string {
2015-09-28 20:37:19 +00:00
dockerHost := os . Getenv ( "DOCKER_HOST" )
if dockerHost == "" {
// Default docker socket
dockerHost = "unix:///var/run/docker.sock"
}
2021-11-25 10:10:06 +00:00
2019-01-21 18:06:02 +00:00
return dockerHost
2016-04-27 23:43:43 +00:00
}
2015-09-28 20:37:19 +00:00
2024-01-09 16:00:07 +00:00
func ( s * BaseSuite ) adaptFile ( path string , tempObjects interface { } ) string {
2015-09-28 20:37:19 +00:00
// Load file
tmpl , err := template . ParseFiles ( path )
2024-01-09 16:00:07 +00:00
require . NoError ( s . T ( ) , err )
2015-09-28 20:37:19 +00:00
folder , prefix := filepath . Split ( path )
2021-03-04 19:08:03 +00:00
tmpFile , err := os . CreateTemp ( folder , strings . TrimSuffix ( prefix , filepath . Ext ( prefix ) ) + "_*" + filepath . Ext ( prefix ) )
2024-01-09 16:00:07 +00:00
require . NoError ( s . T ( ) , err )
2015-09-28 20:37:19 +00:00
defer tmpFile . Close ( )
2019-07-15 08:22:03 +00:00
model := structs . Map ( tempObjects )
model [ "SelfFilename" ] = tmpFile . Name ( )
err = tmpl . ExecuteTemplate ( tmpFile , prefix , model )
2024-01-09 16:00:07 +00:00
require . NoError ( s . T ( ) , err )
2015-09-28 20:37:19 +00:00
err = tmpFile . Sync ( )
2024-01-09 16:00:07 +00:00
require . NoError ( s . T ( ) , err )
2015-09-28 20:37:19 +00:00
2024-01-09 16:00:07 +00:00
s . T ( ) . Cleanup ( func ( ) {
os . Remove ( tmpFile . Name ( ) )
} )
2015-09-28 20:37:19 +00:00
return tmpFile . Name ( )
}
2021-11-25 10:10:06 +00:00
2024-01-09 16:00:07 +00:00
func ( s * BaseSuite ) getComposeServiceIP ( name string ) string {
container , ok := s . containers [ name ]
if ! ok {
return ""
2021-11-25 10:10:06 +00:00
}
2024-01-09 16:00:07 +00:00
ip , err := container . ContainerIP ( context . Background ( ) )
if err != nil {
return ""
}
return ip
2021-11-25 10:10:06 +00:00
}
func withConfigFile ( file string ) string {
return "--configFile=" + file
}
2022-07-13 16:32:08 +00:00
// setupVPN starts Tailscale on the corresponding container, and makes it a subnet
// router, for all the other containers (whoamis, etc) subsequently started for the
// integration tests.
// It only does so if the file provided as argument exists, and contains a
// Tailscale auth key (an ephemeral, but reusable, one is recommended).
//
// Add this section to your tailscale ACLs to auto-approve the routes for the
// containers in the docker subnet:
//
2022-07-19 16:38:09 +00:00
// "autoApprovers": {
// // Allow myself to automatically advertize routes for docker networks
// "routes": {
// "172.0.0.0/8": ["your_tailscale_identity"],
// },
// },
2024-01-09 16:00:07 +00:00
func ( s * BaseSuite ) setupVPN ( keyFile string ) {
2022-08-09 15:36:08 +00:00
data , err := os . ReadFile ( keyFile )
2022-07-13 16:32:08 +00:00
if err != nil {
if ! errors . Is ( err , fs . ErrNotExist ) {
2024-01-10 09:47:44 +00:00
log . Error ( ) . Err ( err ) . Send ( )
2022-07-13 16:32:08 +00:00
}
2024-01-09 16:00:07 +00:00
return
2022-07-13 16:32:08 +00:00
}
authKey := strings . TrimSpace ( string ( data ) )
2024-01-09 16:00:07 +00:00
// // TODO: copy and create versions that don't need a check.C?
s . createComposeProject ( "tailscale" )
s . composeUp ( )
2022-07-13 16:32:08 +00:00
time . Sleep ( 5 * time . Second )
// If we ever change the docker subnet in the Makefile,
// we need to change this one below correspondingly.
2024-01-09 16:00:07 +00:00
s . composeExec ( "tailscaled" , "tailscale" , "up" , "--authkey=" + authKey , "--advertise-routes=172.31.42.0/24" )
2024-01-05 14:10:05 +00:00
}
2024-01-09 16:00:07 +00:00
// composeExec runs the command in the given args in the given compose service container.
// Already running services are not affected (i.e. not stopped).
func ( s * BaseSuite ) composeExec ( service string , args ... string ) string {
require . Contains ( s . T ( ) , s . containers , service )
2024-01-05 14:10:05 +00:00
2024-01-09 16:00:07 +00:00
_ , reader , err := s . containers [ service ] . Exec ( context . Background ( ) , args )
require . NoError ( s . T ( ) , err )
2024-01-05 14:10:05 +00:00
2024-01-09 16:00:07 +00:00
content , err := io . ReadAll ( reader )
require . NoError ( s . T ( ) , err )
2024-01-05 14:10:05 +00:00
2024-01-09 16:00:07 +00:00
return string ( content )
2022-07-13 16:32:08 +00:00
}