mirror of
https://github.com/kairos-io/kairos-sdk.git
synced 2025-08-08 02:33:34 +00:00
art: Drop provider from c3os code
Part of: https://github.com/c3os-io/c3os/issues/68
This commit is contained in:
parent
7a148fe5bb
commit
52cb8cbc29
88
pkg/machine/bootcmdline.go
Normal file
88
pkg/machine/bootcmdline.go
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
package machine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/google/shlex"
|
||||||
|
"github.com/hashicorp/go-multierror"
|
||||||
|
"github.com/itchyny/gojq"
|
||||||
|
"gopkg.in/yaml.v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func DotToYAML(file string) ([]byte, error) {
|
||||||
|
if file == "" {
|
||||||
|
file = "/proc/cmdline"
|
||||||
|
}
|
||||||
|
dat, err := ioutil.ReadFile(file)
|
||||||
|
if err != nil {
|
||||||
|
return []byte{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
v := stringToMap(string(dat))
|
||||||
|
|
||||||
|
return dotToYAML(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringToMap(s string) map[string]interface{} {
|
||||||
|
v := map[string]interface{}{}
|
||||||
|
|
||||||
|
splitted, _ := shlex.Split(s)
|
||||||
|
for _, item := range splitted {
|
||||||
|
parts := strings.SplitN(item, "=", 2)
|
||||||
|
value := "true"
|
||||||
|
if len(parts) > 1 {
|
||||||
|
value = strings.Trim(parts[1], `"`)
|
||||||
|
}
|
||||||
|
key := strings.Trim(parts[0], `"`)
|
||||||
|
v[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
func jq(command string, data map[string]interface{}) (map[string]interface{}, error) {
|
||||||
|
query, err := gojq.Parse(command)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
code, err := gojq.Compile(query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
iter := code.Run(data)
|
||||||
|
|
||||||
|
v, ok := iter.Next()
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("failed getting rsult from gojq")
|
||||||
|
}
|
||||||
|
if err, ok := v.(error); ok {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if t, ok := v.(map[string]interface{}); ok {
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return make(map[string]interface{}), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func dotToYAML(v map[string]interface{}) ([]byte, error) {
|
||||||
|
data := map[string]interface{}{}
|
||||||
|
var errs error
|
||||||
|
|
||||||
|
for k, value := range v {
|
||||||
|
newData, err := jq(fmt.Sprintf(".%s=\"%s\"", k, value), data)
|
||||||
|
if err != nil {
|
||||||
|
errs = multierror.Append(errs, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data = newData
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := yaml.Marshal(&data)
|
||||||
|
if err != nil {
|
||||||
|
errs = multierror.Append(errs, err)
|
||||||
|
}
|
||||||
|
return out, errs
|
||||||
|
}
|
29
pkg/machine/bootcmdline_test.go
Normal file
29
pkg/machine/bootcmdline_test.go
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
package machine_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/ioutil"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
. "github.com/c3os-io/c3os/pkg/machine"
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ = Describe("BootCMDLine", func() {
|
||||||
|
Context("parses data", func() {
|
||||||
|
|
||||||
|
It("returns cmdline if provided", func() {
|
||||||
|
f, err := ioutil.TempFile("", "test")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
defer os.RemoveAll(f.Name())
|
||||||
|
|
||||||
|
err = ioutil.WriteFile(f.Name(), []byte(`config_url="foo bar" baz.bar=""`), os.ModePerm)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
b, err := DotToYAML(f.Name())
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
Expect(string(b)).To(Equal("baz:\n bar: \"\"\nconfig_url: foo bar\n"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
157
pkg/machine/machine.go
Normal file
157
pkg/machine/machine.go
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
package machine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/c3os-io/c3os/pkg/machine/openrc"
|
||||||
|
"github.com/c3os-io/c3os/pkg/machine/systemd"
|
||||||
|
"github.com/denisbrodbeck/machineid"
|
||||||
|
|
||||||
|
"github.com/c3os-io/c3os/pkg/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service interface {
|
||||||
|
WriteUnit() error
|
||||||
|
Start() error
|
||||||
|
OverrideCmd(string) error
|
||||||
|
Enable() error
|
||||||
|
Restart() error
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
PassiveBoot = "passive"
|
||||||
|
ActiveBoot = "active"
|
||||||
|
RecoveryBoot = "recovery"
|
||||||
|
LiveCDBoot = "liveCD"
|
||||||
|
NetBoot = "netboot"
|
||||||
|
UnknownBoot = "unknown"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BootFrom returns the booting partition of the SUT.
|
||||||
|
func BootFrom() string {
|
||||||
|
out, err := utils.SH("cat /proc/cmdline")
|
||||||
|
if err != nil {
|
||||||
|
return UnknownBoot
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case strings.Contains(out, "COS_ACTIVE"):
|
||||||
|
return ActiveBoot
|
||||||
|
case strings.Contains(out, "COS_PASSIVE"):
|
||||||
|
return PassiveBoot
|
||||||
|
case strings.Contains(out, "COS_RECOVERY"), strings.Contains(out, "COS_SYSTEM"):
|
||||||
|
return RecoveryBoot
|
||||||
|
case strings.Contains(out, "live:CDLABEL"):
|
||||||
|
return LiveCDBoot
|
||||||
|
case strings.Contains(out, "netboot"):
|
||||||
|
return NetBoot
|
||||||
|
default:
|
||||||
|
return UnknownBoot
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func EdgeVPN(instance, rootDir string) (Service, error) {
|
||||||
|
if utils.IsOpenRCBased() {
|
||||||
|
return openrc.NewService(
|
||||||
|
openrc.WithName("edgevpn"),
|
||||||
|
openrc.WithUnitContent(openrc.EdgevpnUnit),
|
||||||
|
openrc.WithRoot(rootDir),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return systemd.NewService(
|
||||||
|
systemd.WithName("edgevpn"),
|
||||||
|
systemd.WithInstance(instance),
|
||||||
|
systemd.WithUnitContent(systemd.EdgevpnUnit),
|
||||||
|
systemd.WithRoot(rootDir),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const EdgeVPNDefaultInstance string = "c3os"
|
||||||
|
|
||||||
|
type fakegetty struct{}
|
||||||
|
|
||||||
|
func (fakegetty) Restart() error { return nil }
|
||||||
|
func (fakegetty) Enable() error { return nil }
|
||||||
|
func (fakegetty) OverrideCmd(string) error { return nil }
|
||||||
|
func (fakegetty) SetEnvFile(string) error { return nil }
|
||||||
|
func (fakegetty) WriteUnit() error { return nil }
|
||||||
|
func (fakegetty) Start() error {
|
||||||
|
utils.SH("chvt 2") //nolint:errcheck
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Getty(i int) (Service, error) {
|
||||||
|
if utils.IsOpenRCBased() {
|
||||||
|
return &fakegetty{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return systemd.NewService(
|
||||||
|
systemd.WithName("getty"),
|
||||||
|
systemd.WithInstance(fmt.Sprintf("tty%d", i)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func K3s() (Service, error) {
|
||||||
|
if utils.IsOpenRCBased() {
|
||||||
|
return openrc.NewService(
|
||||||
|
openrc.WithName("k3s"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return systemd.NewService(
|
||||||
|
systemd.WithName("k3s"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func K3sAgent() (Service, error) {
|
||||||
|
if utils.IsOpenRCBased() {
|
||||||
|
return openrc.NewService(
|
||||||
|
openrc.WithName("k3s-agent"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return systemd.NewService(
|
||||||
|
systemd.WithName("k3s-agent"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func K3sEnvUnit(unit string) string {
|
||||||
|
if utils.IsOpenRCBased() {
|
||||||
|
return fmt.Sprintf("/etc/rancher/k3s/%s.env", unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("/etc/sysconfig/%s", unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func UUID() string {
|
||||||
|
if os.Getenv("UUID") != "" {
|
||||||
|
return os.Getenv("UUID")
|
||||||
|
}
|
||||||
|
id, _ := machineid.ID()
|
||||||
|
hostname, _ := os.Hostname()
|
||||||
|
return fmt.Sprintf("%s-%s", id, hostname)
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateSentinel(f string) error {
|
||||||
|
return ioutil.WriteFile(fmt.Sprintf("/usr/local/.c3os/sentinel_%s", f), []byte{}, os.ModePerm)
|
||||||
|
}
|
||||||
|
|
||||||
|
func SentinelExist(f string) bool {
|
||||||
|
if _, err := os.Stat(fmt.Sprintf("/usr/local/.c3os/sentinel_%s", f)); err == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExecuteInlineCloudConfig(cloudConfig, stage string) error {
|
||||||
|
_, err := utils.ShellSTDIN(cloudConfig, fmt.Sprintf("elemental run-stage -s %s -", stage))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExecuteCloudConfig(file, stage string) error {
|
||||||
|
_, err := utils.SH(fmt.Sprintf("elemental run-stage -s %s %s", stage, file))
|
||||||
|
return err
|
||||||
|
}
|
13
pkg/machine/machine_suite_test.go
Normal file
13
pkg/machine/machine_suite_test.go
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
package machine_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestInstaller(t *testing.T) {
|
||||||
|
RegisterFailHandler(Fail)
|
||||||
|
RunSpecs(t, "Machine Suite")
|
||||||
|
}
|
19
pkg/machine/openrc/edgevpn.go
Normal file
19
pkg/machine/openrc/edgevpn.go
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
package openrc
|
||||||
|
|
||||||
|
const EdgevpnUnit string = `#!/sbin/openrc-run
|
||||||
|
|
||||||
|
depend() {
|
||||||
|
after net
|
||||||
|
provide edgevpn
|
||||||
|
}
|
||||||
|
|
||||||
|
supervisor=supervise-daemon
|
||||||
|
name="edgevpn"
|
||||||
|
command="edgevpn"
|
||||||
|
supervise_daemon_args="--stdout /var/log/edgevpn.log --stderr /var/log/edgevpn.log"
|
||||||
|
pidfile="/run/edgevpn.pid"
|
||||||
|
respawn_delay=5
|
||||||
|
set -o allexport
|
||||||
|
if [ -f /etc/environment ]; then source /etc/environment; fi
|
||||||
|
if [ -f /etc/systemd/system.conf.d/edgevpn-c3os.env ]; then source /etc/systemd/system.conf.d/edgevpn-c3os.env; fi
|
||||||
|
set +o allexport`
|
98
pkg/machine/openrc/unit.go
Normal file
98
pkg/machine/openrc/unit.go
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
package openrc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/c3os-io/c3os/pkg/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ServiceUnit struct {
|
||||||
|
content string
|
||||||
|
name string
|
||||||
|
rootdir string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ServiceOpts func(*ServiceUnit) error
|
||||||
|
|
||||||
|
func WithRoot(n string) ServiceOpts {
|
||||||
|
return func(su *ServiceUnit) error {
|
||||||
|
su.rootdir = n
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithName(n string) ServiceOpts {
|
||||||
|
return func(su *ServiceUnit) error {
|
||||||
|
su.name = n
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithUnitContent(n string) ServiceOpts {
|
||||||
|
return func(su *ServiceUnit) error {
|
||||||
|
su.content = n
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(opts ...ServiceOpts) (ServiceUnit, error) {
|
||||||
|
s := &ServiceUnit{}
|
||||||
|
for _, o := range opts {
|
||||||
|
if err := o(s); err != nil {
|
||||||
|
return *s, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return *s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s ServiceUnit) WriteUnit() error {
|
||||||
|
uname := s.name
|
||||||
|
|
||||||
|
if err := ioutil.WriteFile(filepath.Join(s.rootdir, fmt.Sprintf("/etc/init.d/%s", uname)), []byte(s.content), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: This is too much k3s specific.
|
||||||
|
func (s ServiceUnit) OverrideCmd(cmd string) error {
|
||||||
|
k3sbin := utils.K3sBin()
|
||||||
|
if k3sbin == "" {
|
||||||
|
return fmt.Errorf("no k3s binary found (?)")
|
||||||
|
}
|
||||||
|
cmd = strings.ReplaceAll(cmd, k3sbin+" ", "")
|
||||||
|
envFile := filepath.Join(s.rootdir, fmt.Sprintf("/etc/rancher/k3s/%s.env", s.name))
|
||||||
|
env := make(map[string]string)
|
||||||
|
env["command_args"] = fmt.Sprintf("%s >>/var/log/%s.log 2>&1", cmd, s.name)
|
||||||
|
|
||||||
|
return utils.WriteEnv(envFile, env)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s ServiceUnit) Start() error {
|
||||||
|
out, err := utils.SH(fmt.Sprintf("/etc/init.d/%s start", s.name))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed starting service: %s. %s (%w)", s.name, out, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s ServiceUnit) Restart() error {
|
||||||
|
out, err := utils.SH(fmt.Sprintf("/etc/init.d/%s restart", s.name))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed restarting service: %s. %s (%w)", s.name, out, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s ServiceUnit) Enable() error {
|
||||||
|
_, err := utils.SH(fmt.Sprintf("ln -sf /etc/init.d/%s /etc/runlevels/default/%s", s.name, s.name))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s ServiceUnit) StartBlocking() error {
|
||||||
|
return s.Start()
|
||||||
|
}
|
12
pkg/machine/systemd/edgevpn.go
Normal file
12
pkg/machine/systemd/edgevpn.go
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
package systemd
|
||||||
|
|
||||||
|
const EdgevpnUnit string = `[Unit]
|
||||||
|
Description=EdgeVPN Daemon
|
||||||
|
After=network.target
|
||||||
|
[Service]
|
||||||
|
EnvironmentFile=/etc/systemd/system.conf.d/edgevpn-%i.env
|
||||||
|
LimitNOFILE=49152
|
||||||
|
ExecStart=edgevpn
|
||||||
|
Restart=always
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target`
|
115
pkg/machine/systemd/unit.go
Normal file
115
pkg/machine/systemd/unit.go
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
package systemd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/c3os-io/c3os/pkg/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ServiceUnit struct {
|
||||||
|
content string
|
||||||
|
name, instance string
|
||||||
|
rootdir string
|
||||||
|
}
|
||||||
|
|
||||||
|
const overrideCmdTemplate string = `
|
||||||
|
[Service]
|
||||||
|
ExecStart=
|
||||||
|
ExecStart=%s
|
||||||
|
`
|
||||||
|
|
||||||
|
type ServiceOpts func(*ServiceUnit) error
|
||||||
|
|
||||||
|
func WithRoot(n string) ServiceOpts {
|
||||||
|
return func(su *ServiceUnit) error {
|
||||||
|
su.rootdir = n
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithName(n string) ServiceOpts {
|
||||||
|
return func(su *ServiceUnit) error {
|
||||||
|
su.name = n
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithInstance(n string) ServiceOpts {
|
||||||
|
return func(su *ServiceUnit) error {
|
||||||
|
su.instance = n
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithUnitContent(n string) ServiceOpts {
|
||||||
|
return func(su *ServiceUnit) error {
|
||||||
|
su.content = n
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(opts ...ServiceOpts) (ServiceUnit, error) {
|
||||||
|
s := &ServiceUnit{}
|
||||||
|
for _, o := range opts {
|
||||||
|
if err := o(s); err != nil {
|
||||||
|
return *s, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return *s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s ServiceUnit) WriteUnit() error {
|
||||||
|
uname := s.name
|
||||||
|
if s.instance != "" {
|
||||||
|
uname = fmt.Sprintf("%s@", s.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ioutil.WriteFile(filepath.Join(s.rootdir, fmt.Sprintf("/etc/systemd/system/%s.service", uname)), []byte(s.content), 0600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := utils.SH("systemctl daemon-reload")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s ServiceUnit) OverrideCmd(cmd string) error {
|
||||||
|
svcDir := filepath.Join(s.rootdir, fmt.Sprintf("/etc/systemd/system/%s.service.d/", s.name))
|
||||||
|
os.MkdirAll(svcDir, 0600) //nolint:errcheck
|
||||||
|
|
||||||
|
return ioutil.WriteFile(filepath.Join(svcDir, "override.conf"), []byte(fmt.Sprintf(overrideCmdTemplate, cmd)), 0600)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s ServiceUnit) Start() error {
|
||||||
|
return s.systemctl("start", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s ServiceUnit) Restart() error {
|
||||||
|
return s.systemctl("restart", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s ServiceUnit) Enable() error {
|
||||||
|
return s.systemctl("enable", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s ServiceUnit) StartBlocking() error {
|
||||||
|
return s.systemctl("start", true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s ServiceUnit) systemctl(action string, blocking bool) error {
|
||||||
|
uname := s.name
|
||||||
|
if s.instance != "" {
|
||||||
|
uname = fmt.Sprintf("%s@%s", s.name, s.instance)
|
||||||
|
}
|
||||||
|
args := []string{action}
|
||||||
|
if !blocking {
|
||||||
|
args = append(args, "--no-block")
|
||||||
|
}
|
||||||
|
args = append(args, uname)
|
||||||
|
|
||||||
|
_, err := utils.SH(fmt.Sprintf("systemctl %s", strings.Join(args, " ")))
|
||||||
|
return err
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user