Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
K
k3s
Project
Project
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Registry
Registry
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Jacklull
k3s
Commits
ae4a1a14
Unverified
Commit
ae4a1a14
authored
Nov 29, 2021
by
Chris Kim
Committed by
GitHub
Nov 29, 2021
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
etcd snapshot functionality enhancements (#4453)
Signed-off-by:
Chris Kim
<
oats87g@gmail.com
>
parent
0c1f816f
Expand all
Show whitespace changes
Inline
Side-by-side
Showing
7 changed files
with
94 additions
and
26 deletions
+94
-26
etcd_snapshot.go
pkg/cli/cmds/etcd_snapshot.go
+8
-11
etcd_snapshot.go
pkg/cli/etcdsnapshot/etcd_snapshot.go
+16
-1
cluster.go
pkg/cluster/cluster.go
+1
-1
drivers.go
pkg/cluster/managed/drivers.go
+1
-1
etcd.go
pkg/etcd/etcd.go
+0
-0
etcd_int_test.go
pkg/etcd/etcd_int_test.go
+5
-5
s3.go
pkg/etcd/s3.go
+63
-7
No files found.
pkg/cli/cmds/etcd_snapshot.go
View file @
ae4a1a14
...
@@ -26,6 +26,11 @@ var EtcdSnapshotFlags = []cli.Flag{
...
@@ -26,6 +26,11 @@ var EtcdSnapshotFlags = []cli.Flag{
Destination
:
&
ServerConfig
.
DataDir
,
Destination
:
&
ServerConfig
.
DataDir
,
},
},
&
cli
.
StringFlag
{
&
cli
.
StringFlag
{
Name
:
"dir,etcd-snapshot-dir"
,
Usage
:
"(db) Directory to save etcd on-demand snapshot. (default: ${data-dir}/db/snapshots)"
,
Destination
:
&
ServerConfig
.
EtcdSnapshotDir
,
},
&
cli
.
StringFlag
{
Name
:
"name"
,
Name
:
"name"
,
Usage
:
"(db) Set the base name of the etcd on-demand snapshot (appended with UNIX timestamp)."
,
Usage
:
"(db) Set the base name of the etcd on-demand snapshot (appended with UNIX timestamp)."
,
Destination
:
&
ServerConfig
.
EtcdSnapshotName
,
Destination
:
&
ServerConfig
.
EtcdSnapshotName
,
...
@@ -101,11 +106,7 @@ func NewEtcdSnapshotCommand(action func(*cli.Context) error, subcommands []cli.C
...
@@ -101,11 +106,7 @@ func NewEtcdSnapshotCommand(action func(*cli.Context) error, subcommands []cli.C
SkipArgReorder
:
true
,
SkipArgReorder
:
true
,
Action
:
action
,
Action
:
action
,
Subcommands
:
subcommands
,
Subcommands
:
subcommands
,
Flags
:
append
(
EtcdSnapshotFlags
,
&
cli
.
StringFlag
{
Flags
:
EtcdSnapshotFlags
,
Name
:
"dir,etcd-snapshot-dir"
,
Usage
:
"(db) Directory to save etcd on-demand snapshot. (default: ${data-dir}/db/snapshots)"
,
Destination
:
&
ServerConfig
.
EtcdSnapshotDir
,
}),
}
}
}
}
...
@@ -130,7 +131,7 @@ func NewEtcdSnapshotSubcommands(delete, list, prune, save func(ctx *cli.Context)
...
@@ -130,7 +131,7 @@ func NewEtcdSnapshotSubcommands(delete, list, prune, save func(ctx *cli.Context)
},
},
{
{
Name
:
"prune"
,
Name
:
"prune"
,
Usage
:
"Remove snapshots that exceed the configured retention count"
,
Usage
:
"Remove snapshots that
match the name prefix that
exceed the configured retention count"
,
SkipFlagParsing
:
false
,
SkipFlagParsing
:
false
,
SkipArgReorder
:
true
,
SkipArgReorder
:
true
,
Action
:
prune
,
Action
:
prune
,
...
@@ -147,11 +148,7 @@ func NewEtcdSnapshotSubcommands(delete, list, prune, save func(ctx *cli.Context)
...
@@ -147,11 +148,7 @@ func NewEtcdSnapshotSubcommands(delete, list, prune, save func(ctx *cli.Context)
SkipFlagParsing
:
false
,
SkipFlagParsing
:
false
,
SkipArgReorder
:
true
,
SkipArgReorder
:
true
,
Action
:
save
,
Action
:
save
,
Flags
:
append
(
EtcdSnapshotFlags
,
&
cli
.
StringFlag
{
Flags
:
EtcdSnapshotFlags
,
Name
:
"dir"
,
Usage
:
"(db) Directory to save etcd on-demand snapshot. (default: ${data-dir}/db/snapshots)"
,
Destination
:
&
ServerConfig
.
EtcdSnapshotDir
,
}),
},
},
}
}
}
}
pkg/cli/etcdsnapshot/etcd_snapshot.go
View file @
ae4a1a14
...
@@ -173,13 +173,21 @@ func list(app *cli.Context, cfg *cmds.Server) error {
...
@@ -173,13 +173,21 @@ func list(app *cli.Context, cfg *cmds.Server) error {
w
:=
tabwriter
.
NewWriter
(
os
.
Stdout
,
0
,
0
,
1
,
' '
,
0
)
w
:=
tabwriter
.
NewWriter
(
os
.
Stdout
,
0
,
0
,
1
,
' '
,
0
)
defer
w
.
Flush
()
defer
w
.
Flush
()
for
_
,
s
:=
range
sf
{
if
cfg
.
EtcdS3
{
if
cfg
.
EtcdS3
{
fmt
.
Fprint
(
w
,
"Name
\t
Size
\t
Created
\n
"
)
for
_
,
s
:=
range
sf
{
if
s
.
NodeName
==
"s3"
{
fmt
.
Fprintf
(
w
,
"%s
\t
%d
\t
%s
\n
"
,
s
.
Name
,
s
.
Size
,
s
.
CreatedAt
.
Format
(
time
.
RFC3339
))
fmt
.
Fprintf
(
w
,
"%s
\t
%d
\t
%s
\n
"
,
s
.
Name
,
s
.
Size
,
s
.
CreatedAt
.
Format
(
time
.
RFC3339
))
}
}
}
else
{
}
else
{
fmt
.
Fprint
(
w
,
"Name
\t
Location
\t
Size
\t
Created
\n
"
)
for
_
,
s
:=
range
sf
{
if
s
.
NodeName
!=
"s3"
{
fmt
.
Fprintf
(
w
,
"%s
\t
%s
\t
%d
\t
%s
\n
"
,
s
.
Name
,
s
.
Location
,
s
.
Size
,
s
.
CreatedAt
.
Format
(
time
.
RFC3339
))
fmt
.
Fprintf
(
w
,
"%s
\t
%s
\t
%d
\t
%s
\n
"
,
s
.
Name
,
s
.
Location
,
s
.
Size
,
s
.
CreatedAt
.
Format
(
time
.
RFC3339
))
}
}
}
}
}
return
nil
return
nil
}
}
...
@@ -201,10 +209,17 @@ func prune(app *cli.Context, cfg *cmds.Server) error {
...
@@ -201,10 +209,17 @@ func prune(app *cli.Context, cfg *cmds.Server) error {
serverConfig
.
ControlConfig
.
DataDir
=
dataDir
serverConfig
.
ControlConfig
.
DataDir
=
dataDir
serverConfig
.
ControlConfig
.
EtcdSnapshotRetention
=
cfg
.
EtcdSnapshotRetention
serverConfig
.
ControlConfig
.
EtcdSnapshotRetention
=
cfg
.
EtcdSnapshotRetention
serverConfig
.
ControlConfig
.
Runtime
.
KubeConfigAdmin
=
filepath
.
Join
(
dataDir
,
"cred"
,
"admin.kubeconfig"
)
ctx
:=
signals
.
SetupSignalContext
()
ctx
:=
signals
.
SetupSignalContext
()
e
:=
etcd
.
NewETCD
()
e
:=
etcd
.
NewETCD
()
e
.
SetControlConfig
(
&
serverConfig
.
ControlConfig
)
e
.
SetControlConfig
(
&
serverConfig
.
ControlConfig
)
sc
,
err
:=
server
.
NewContext
(
ctx
,
serverConfig
.
ControlConfig
.
Runtime
.
KubeConfigAdmin
)
if
err
!=
nil
{
return
err
}
serverConfig
.
ControlConfig
.
Runtime
.
Core
=
sc
.
Core
return
e
.
PruneSnapshots
(
ctx
)
return
e
.
PruneSnapshots
(
ctx
)
}
}
pkg/cluster/cluster.go
View file @
ae4a1a14
...
@@ -104,7 +104,7 @@ func (c *Cluster) Start(ctx context.Context) (<-chan struct{}, error) {
...
@@ -104,7 +104,7 @@ func (c *Cluster) Start(ctx context.Context) (<-chan struct{}, error) {
}
}
if
!
c
.
config
.
EtcdDisableSnapshots
{
if
!
c
.
config
.
EtcdDisableSnapshots
{
if
err
:=
c
.
managedDB
.
Stor
eSnapshotData
(
ctx
);
err
!=
nil
{
if
err
:=
c
.
managedDB
.
Reconcil
eSnapshotData
(
ctx
);
err
!=
nil
{
logrus
.
Errorf
(
"Failed to record snapshots for cluster: %v"
,
err
)
logrus
.
Errorf
(
"Failed to record snapshots for cluster: %v"
,
err
)
}
}
}
}
...
...
pkg/cluster/managed/drivers.go
View file @
ae4a1a14
...
@@ -22,7 +22,7 @@ type Driver interface {
...
@@ -22,7 +22,7 @@ type Driver interface {
Restore
(
ctx
context
.
Context
)
error
Restore
(
ctx
context
.
Context
)
error
EndpointName
()
string
EndpointName
()
string
Snapshot
(
ctx
context
.
Context
,
config
*
config
.
Control
)
error
Snapshot
(
ctx
context
.
Context
,
config
*
config
.
Control
)
error
Stor
eSnapshotData
(
ctx
context
.
Context
)
error
Reconcil
eSnapshotData
(
ctx
context
.
Context
)
error
GetMembersClientURLs
(
ctx
context
.
Context
)
([]
string
,
error
)
GetMembersClientURLs
(
ctx
context
.
Context
)
([]
string
,
error
)
RemoveSelf
(
ctx
context
.
Context
)
error
RemoveSelf
(
ctx
context
.
Context
)
error
}
}
...
...
pkg/etcd/etcd.go
View file @
ae4a1a14
This diff is collapsed.
Click to expand it.
pkg/etcd/etcd_int_test.go
View file @
ae4a1a14
...
@@ -36,7 +36,7 @@ var _ = Describe("etcd snapshots", func() {
...
@@ -36,7 +36,7 @@ var _ = Describe("etcd snapshots", func() {
})
})
It
(
"saves an etcd snapshot"
,
func
()
{
It
(
"saves an etcd snapshot"
,
func
()
{
Expect
(
testutil
.
K3sCmd
(
"etcd-snapshot"
,
"save"
))
.
Expect
(
testutil
.
K3sCmd
(
"etcd-snapshot"
,
"save"
))
.
To
(
ContainSubstring
(
"
Saving current etcd snapshot set to k3s-etcd-snapshots
"
))
To
(
ContainSubstring
(
"
saved
"
))
})
})
It
(
"list snapshots"
,
func
()
{
It
(
"list snapshots"
,
func
()
{
Expect
(
testutil
.
K3sCmd
(
"etcd-snapshot"
,
"ls"
))
.
Expect
(
testutil
.
K3sCmd
(
"etcd-snapshot"
,
"ls"
))
.
...
@@ -70,13 +70,13 @@ var _ = Describe("etcd snapshots", func() {
...
@@ -70,13 +70,13 @@ var _ = Describe("etcd snapshots", func() {
When
(
"using etcd snapshot prune"
,
func
()
{
When
(
"using etcd snapshot prune"
,
func
()
{
It
(
"saves 3 different snapshots"
,
func
()
{
It
(
"saves 3 different snapshots"
,
func
()
{
Expect
(
testutil
.
K3sCmd
(
"etcd-snapshot"
,
"save"
,
"-name"
,
"PRUNE_TEST"
))
.
Expect
(
testutil
.
K3sCmd
(
"etcd-snapshot"
,
"save"
,
"-name"
,
"PRUNE_TEST"
))
.
To
(
ContainSubstring
(
"
Saving current etcd snapshot set to k3s-etcd-snapshots
"
))
To
(
ContainSubstring
(
"
saved
"
))
time
.
Sleep
(
1
*
time
.
Second
)
time
.
Sleep
(
1
*
time
.
Second
)
Expect
(
testutil
.
K3sCmd
(
"etcd-snapshot"
,
"save"
,
"-name"
,
"PRUNE_TEST"
))
.
Expect
(
testutil
.
K3sCmd
(
"etcd-snapshot"
,
"save"
,
"-name"
,
"PRUNE_TEST"
))
.
To
(
ContainSubstring
(
"
Saving current etcd snapshot set to k3s-etcd-snapshots
"
))
To
(
ContainSubstring
(
"
saved
"
))
time
.
Sleep
(
1
*
time
.
Second
)
time
.
Sleep
(
1
*
time
.
Second
)
Expect
(
testutil
.
K3sCmd
(
"etcd-snapshot"
,
"save"
,
"-name"
,
"PRUNE_TEST"
))
.
Expect
(
testutil
.
K3sCmd
(
"etcd-snapshot"
,
"save"
,
"-name"
,
"PRUNE_TEST"
))
.
To
(
ContainSubstring
(
"
Saving current etcd snapshot set to k3s-etcd-snapshots
"
))
To
(
ContainSubstring
(
"
saved
"
))
time
.
Sleep
(
1
*
time
.
Second
)
time
.
Sleep
(
1
*
time
.
Second
)
})
})
It
(
"lists all 3 snapshots"
,
func
()
{
It
(
"lists all 3 snapshots"
,
func
()
{
...
@@ -89,7 +89,7 @@ var _ = Describe("etcd snapshots", func() {
...
@@ -89,7 +89,7 @@ var _ = Describe("etcd snapshots", func() {
})
})
It
(
"prunes snapshots down to 2"
,
func
()
{
It
(
"prunes snapshots down to 2"
,
func
()
{
Expect
(
testutil
.
K3sCmd
(
"etcd-snapshot"
,
"prune"
,
"--snapshot-retention"
,
"2"
,
"--name"
,
"PRUNE_TEST"
))
.
Expect
(
testutil
.
K3sCmd
(
"etcd-snapshot"
,
"prune"
,
"--snapshot-retention"
,
"2"
,
"--name"
,
"PRUNE_TEST"
))
.
To
(
BeEmpty
(
))
To
(
ContainSubstring
(
"Removing local snapshot"
))
lsResult
,
err
:=
testutil
.
K3sCmd
(
"etcd-snapshot"
,
"ls"
)
lsResult
,
err
:=
testutil
.
K3sCmd
(
"etcd-snapshot"
,
"ls"
)
Expect
(
err
)
.
ToNot
(
HaveOccurred
())
Expect
(
err
)
.
ToNot
(
HaveOccurred
())
reg
,
err
:=
regexp
.
Compile
(
`:///var/lib/rancher/k3s/server/db/snapshots/PRUNE_TEST`
)
reg
,
err
:=
regexp
.
Compile
(
`:///var/lib/rancher/k3s/server/db/snapshots/PRUNE_TEST`
)
...
...
pkg/etcd/s3.go
View file @
ae4a1a14
...
@@ -14,12 +14,14 @@ import (
...
@@ -14,12 +14,14 @@ import (
"path/filepath"
"path/filepath"
"sort"
"sort"
"strings"
"strings"
"time"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
"github.com/minio/minio-go/v7/pkg/credentials"
"github.com/pkg/errors"
"github.com/pkg/errors"
"github.com/rancher/k3s/pkg/daemons/config"
"github.com/rancher/k3s/pkg/daemons/config"
"github.com/sirupsen/logrus"
"github.com/sirupsen/logrus"
metav1
"k8s.io/apimachinery/pkg/apis/meta/v1"
)
)
// S3 maintains state for S3 functionality.
// S3 maintains state for S3 functionality.
...
@@ -32,6 +34,9 @@ type S3 struct {
...
@@ -32,6 +34,9 @@ type S3 struct {
// copy of the config.Control pointer and initializes
// copy of the config.Control pointer and initializes
// a new Minio client.
// a new Minio client.
func
NewS3
(
ctx
context
.
Context
,
config
*
config
.
Control
)
(
*
S3
,
error
)
{
func
NewS3
(
ctx
context
.
Context
,
config
*
config
.
Control
)
(
*
S3
,
error
)
{
if
config
.
EtcdS3BucketName
==
""
{
return
nil
,
errors
.
New
(
"s3 bucket name was not set"
)
}
tr
:=
http
.
DefaultTransport
tr
:=
http
.
DefaultTransport
switch
{
switch
{
...
@@ -88,9 +93,11 @@ func NewS3(ctx context.Context, config *config.Control) (*S3, error) {
...
@@ -88,9 +93,11 @@ func NewS3(ctx context.Context, config *config.Control) (*S3, error) {
// upload uploads the given snapshot to the configured S3
// upload uploads the given snapshot to the configured S3
// compatible backend.
// compatible backend.
func
(
s
*
S3
)
upload
(
ctx
context
.
Context
,
snapshot
string
)
error
{
func
(
s
*
S3
)
upload
(
ctx
context
.
Context
,
snapshot
,
extraMetadata
string
,
now
time
.
Time
)
(
*
SnapshotFile
,
error
)
{
logrus
.
Infof
(
"Uploading snapshot %s to S3"
,
snapshot
)
basename
:=
filepath
.
Base
(
snapshot
)
basename
:=
filepath
.
Base
(
snapshot
)
var
snapshotFileName
string
var
snapshotFileName
string
var
snapshotFile
SnapshotFile
if
s
.
config
.
EtcdS3Folder
!=
""
{
if
s
.
config
.
EtcdS3Folder
!=
""
{
snapshotFileName
=
filepath
.
Join
(
s
.
config
.
EtcdS3Folder
,
basename
)
snapshotFileName
=
filepath
.
Join
(
s
.
config
.
EtcdS3Folder
,
basename
)
}
else
{
}
else
{
...
@@ -103,11 +110,56 @@ func (s *S3) upload(ctx context.Context, snapshot string) error {
...
@@ -103,11 +110,56 @@ func (s *S3) upload(ctx context.Context, snapshot string) error {
ContentType
:
"application/zip"
,
ContentType
:
"application/zip"
,
NumThreads
:
2
,
NumThreads
:
2
,
}
}
if
_
,
err
:=
s
.
client
.
FPutObject
(
toCtx
,
s
.
config
.
EtcdS3BucketName
,
snapshotFileName
,
snapshot
,
opts
);
err
!=
nil
{
uploadInfo
,
err
:=
s
.
client
.
FPutObject
(
toCtx
,
s
.
config
.
EtcdS3BucketName
,
snapshotFileName
,
snapshot
,
opts
)
logrus
.
Errorf
(
"Error received in attempt to upload snapshot to S3: %s"
,
err
)
if
err
!=
nil
{
snapshotFile
=
SnapshotFile
{
Name
:
filepath
.
Base
(
uploadInfo
.
Key
),
Metadata
:
extraMetadata
,
NodeName
:
"s3"
,
CreatedAt
:
&
metav1
.
Time
{
Time
:
now
,
},
Message
:
base64
.
StdEncoding
.
EncodeToString
([]
byte
(
err
.
Error
())),
Size
:
0
,
Status
:
FailedSnapshotStatus
,
S3
:
&
s3Config
{
Endpoint
:
s
.
config
.
EtcdS3Endpoint
,
EndpointCA
:
s
.
config
.
EtcdS3EndpointCA
,
SkipSSLVerify
:
s
.
config
.
EtcdS3SkipSSLVerify
,
Bucket
:
s
.
config
.
EtcdS3BucketName
,
Region
:
s
.
config
.
EtcdS3Region
,
Folder
:
s
.
config
.
EtcdS3Folder
,
Insecure
:
s
.
config
.
EtcdS3Insecure
,
},
}
logrus
.
Errorf
(
"Error received during snapshot upload to S3: %s"
,
err
)
}
else
{
ca
,
err
:=
time
.
Parse
(
time
.
RFC3339
,
uploadInfo
.
LastModified
.
Format
(
time
.
RFC3339
))
if
err
!=
nil
{
return
nil
,
err
}
}
return
nil
snapshotFile
=
SnapshotFile
{
Name
:
filepath
.
Base
(
uploadInfo
.
Key
),
Metadata
:
extraMetadata
,
NodeName
:
"s3"
,
CreatedAt
:
&
metav1
.
Time
{
Time
:
ca
,
},
Size
:
uploadInfo
.
Size
,
Status
:
SuccessfulSnapshotStatus
,
S3
:
&
s3Config
{
Endpoint
:
s
.
config
.
EtcdS3Endpoint
,
EndpointCA
:
s
.
config
.
EtcdS3EndpointCA
,
SkipSSLVerify
:
s
.
config
.
EtcdS3SkipSSLVerify
,
Bucket
:
s
.
config
.
EtcdS3BucketName
,
Region
:
s
.
config
.
EtcdS3Region
,
Folder
:
s
.
config
.
EtcdS3Folder
,
Insecure
:
s
.
config
.
EtcdS3Insecure
,
},
}
}
return
&
snapshotFile
,
nil
}
}
// download downloads the given snapshot from the configured S3
// download downloads the given snapshot from the configured S3
...
@@ -170,9 +222,13 @@ func (s *S3) snapshotPrefix() string {
...
@@ -170,9 +222,13 @@ func (s *S3) snapshotPrefix() string {
return
prefix
return
prefix
}
}
// snapshotRetention deletes the given snapshot from the configured S3
// snapshotRetention prunes snapshots in the configured S3 compatible backend for this specific node.
// compatible backend.
func
(
s
*
S3
)
snapshotRetention
(
ctx
context
.
Context
)
error
{
func
(
s
*
S3
)
snapshotRetention
(
ctx
context
.
Context
)
error
{
if
s
.
config
.
EtcdSnapshotRetention
<
1
{
return
nil
}
logrus
.
Infof
(
"Applying snapshot retention policy to snapshots stored in S3: retention: %d, snapshotPrefix: %s"
,
s
.
config
.
EtcdSnapshotRetention
,
s
.
snapshotPrefix
())
var
snapshotFiles
[]
minio
.
ObjectInfo
var
snapshotFiles
[]
minio
.
ObjectInfo
toCtx
,
cancel
:=
context
.
WithTimeout
(
ctx
,
s
.
config
.
EtcdS3Timeout
)
toCtx
,
cancel
:=
context
.
WithTimeout
(
ctx
,
s
.
config
.
EtcdS3Timeout
)
...
@@ -199,7 +255,7 @@ func (s *S3) snapshotRetention(ctx context.Context) error {
...
@@ -199,7 +255,7 @@ func (s *S3) snapshotRetention(ctx context.Context) error {
delCount
:=
len
(
snapshotFiles
)
-
s
.
config
.
EtcdSnapshotRetention
delCount
:=
len
(
snapshotFiles
)
-
s
.
config
.
EtcdSnapshotRetention
for
_
,
df
:=
range
snapshotFiles
[
:
delCount
]
{
for
_
,
df
:=
range
snapshotFiles
[
:
delCount
]
{
logrus
.
Debugf
(
"Removing
snapshot: %s"
,
df
.
Key
)
logrus
.
Infof
(
"Removing S3
snapshot: %s"
,
df
.
Key
)
if
err
:=
s
.
client
.
RemoveObject
(
ctx
,
s
.
config
.
EtcdS3BucketName
,
df
.
Key
,
minio
.
RemoveObjectOptions
{});
err
!=
nil
{
if
err
:=
s
.
client
.
RemoveObject
(
ctx
,
s
.
config
.
EtcdS3BucketName
,
df
.
Key
,
minio
.
RemoveObjectOptions
{});
err
!=
nil
{
return
err
return
err
}
}
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment