Split off cli into separate binaries (#37)

* 🎨 Split off cli into separate binaries

This commit splits off the cli into 3 binaries:
- agent
- cli
- provider

The provider now is a separate component that can be tested by itself
and have its own lifecycle. This paves the way to a ligher c3os variant,
HA support and other features that can be provided on runtime.

This is working, but still there are low hanging fruit to care about.

Fixes #14

* 🤖 Add provider bin to releases

* ⚙️ Handle signals

* ⚙️ Reduce buildsize footprint

* 🎨 Scan for providers also in /system/providers

* 🤖 Run goreleaser

* 🎨 Refactoring
This commit is contained in:
Ettore Di Giacinto
2022-07-04 22:39:34 +02:00
committed by Itxaka
parent 74bfd373db
commit 63cd28d1cb
29 changed files with 1745 additions and 0 deletions

66
internal/bus/bus.go Normal file
View File

@@ -0,0 +1,66 @@
package bus
import (
"fmt"
"os"
"os/exec"
"github.com/c3os-io/c3os/pkg/bus"
"github.com/mudler/go-pluggable"
)
// Manager is the bus instance manager, which subscribes plugins to events emitted
var Manager *Bus = &Bus{
Manager: pluggable.NewManager(
[]pluggable.EventType{
bus.EventBootstrap,
bus.EventChallenge,
bus.EventInstall,
},
),
}
type Bus struct {
*pluggable.Manager
}
func (b *Bus) Initialize() {
b.Manager.Autoload("agent-provider", "/system/providers").Register()
for i := range b.Manager.Events {
e := b.Manager.Events[i]
b.Manager.Response(e, func(p *pluggable.Plugin, r *pluggable.EventResponse) {
if os.Getenv("BUS_DEBUG") == "true" {
fmt.Println(
fmt.Sprintf("[provider event: %s]", e),
"received from",
p.Name,
"at",
p.Executable,
r,
)
}
if r.Errored() {
err := fmt.Sprintf("Provider %s at %s had an error: %s", p.Name, p.Executable, r.Error)
fmt.Println(err)
os.Exit(1)
} else {
if r.State != "" {
fmt.Println(fmt.Sprintf("[provider event: %s]", e), r.State)
}
}
})
}
}
func RunHookScript(s string) error {
_, err := os.Stat(s)
if err != nil {
return nil
}
cmd := exec.Command(s)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
return cmd.Run()
}

View File

@@ -0,0 +1,7 @@
package c3os
import "path"
func BrandingFile(s string) string {
return path.Join("/etc", "c3os", "branding", s)
}

141
internal/cmd/commands.go Normal file
View File

@@ -0,0 +1,141 @@
package cmd
import (
//"fmt"
"encoding/base64"
"fmt"
"strconv"
"strings"
config "github.com/c3os-io/c3os/pkg/config"
edgeVPNClient "github.com/mudler/edgevpn/api/client"
"github.com/mudler/edgevpn/api/client/service"
"github.com/mudler/edgevpn/pkg/node"
"github.com/urfave/cli"
"gopkg.in/yaml.v2"
)
func CommonCommand(cmds ...cli.Command) []cli.Command {
return append(commonCommands, cmds...)
}
var commonCommands = []cli.Command{
{
Name: "get-kubeconfig",
Usage: "Return a deployment kubeconfig",
UsageText: "Retrieve a c3os network kubeconfig (only for automated deployments)",
Description: `
Retrieve a network kubeconfig and prints out to screen.
If a deployment was bootstrapped with a network token, you can use this command to retrieve the master node kubeconfig of a network id.
For example:
$ c3os get-kubeconfig --network-id c3os
`,
Flags: networkAPI,
Action: func(c *cli.Context) error {
cc := service.NewClient(
c.String("network-id"),
edgeVPNClient.NewClient(edgeVPNClient.WithHost(c.String("api"))))
str, _ := cc.Get("kubeconfig", "master")
b, _ := base64.RawURLEncoding.DecodeString(str)
masterIP, _ := cc.Get("master", "ip")
fmt.Println(strings.ReplaceAll(string(b), "127.0.0.1", masterIP))
return nil
},
},
{
Name: "role",
Usage: "Set or list node roles",
Subcommands: []cli.Command{
{
Flags: networkAPI,
Name: "set",
Usage: "Set a node role",
UsageText: "c3os role set <UUID> master",
Description: `
Sets a node role propagating the setting to the network.
A role must be set prior to the node joining a network. You can retrieve a node UUID by running "c3os uuid".
Example:
$ (node A) c3os uuid
$ (node B) c3os role set <UUID of node A> master
`,
Action: func(c *cli.Context) error {
cc := service.NewClient(
c.String("network-id"),
edgeVPNClient.NewClient(edgeVPNClient.WithHost(c.String("api"))))
return cc.Set("role", c.Args()[0], c.Args()[1])
},
},
{
Flags: networkAPI,
Name: "list",
Description: "List node roles",
Action: func(c *cli.Context) error {
cc := service.NewClient(
c.String("network-id"),
edgeVPNClient.NewClient(edgeVPNClient.WithHost(c.String("api"))))
advertizing, _ := cc.AdvertizingNodes()
fmt.Println("Node\tRole")
for _, a := range advertizing {
role, _ := cc.Get("role", a)
fmt.Printf("%s\t%s\n", a, role)
}
return nil
},
},
},
},
{
Name: "create-config",
Aliases: []string{"c"},
UsageText: "Create a config with a generated network token",
Usage: "Creates a pristine config file",
Description: `
Prints a vanilla YAML configuration on screen which can be used to bootstrap a c3os network.
`,
ArgsUsage: "Optionally takes a token rotation interval (seconds)",
Action: func(c *cli.Context) error {
l := int(^uint(0) >> 1)
args := c.Args()
if len(args) > 0 {
if i, err := strconv.Atoi(args[0]); err == nil {
l = i
}
}
cc := &config.Config{C3OS: &config.C3OS{NetworkToken: node.GenerateNewConnectionData(l).Base64()}}
y, _ := yaml.Marshal(cc)
fmt.Println(string(y))
return nil
},
},
{
Name: "generate-token",
Aliases: []string{"g"},
UsageText: "Generate a network token",
Usage: "Creates a new token",
Description: `
Generates a new token which can be used to bootstrap a c3os network.
`,
ArgsUsage: "Optionally takes a token rotation interval (seconds)",
Action: func(c *cli.Context) error {
l := int(^uint(0) >> 1)
args := c.Args()
if len(args) > 0 {
if i, err := strconv.Atoi(args[0]); err == nil {
l = i
}
}
fmt.Println(node.GenerateNewConnectionData(l).Base64())
return nil
},
},
}

18
internal/cmd/flags.go Normal file
View File

@@ -0,0 +1,18 @@
package cmd
import (
"github.com/urfave/cli"
)
var networkAPI = []cli.Flag{
&cli.StringFlag{
Name: "api",
Usage: "API Address",
Value: "http://localhost:8080",
},
&cli.StringFlag{
Name: "network-id",
Value: "c3os",
Usage: "Kubernetes Network Deployment ID",
},
}

33
internal/cmd/utils.go Normal file
View File

@@ -0,0 +1,33 @@
package cmd
import (
"fmt"
"io/ioutil"
"os"
"github.com/c3os-io/c3os/internal/c3os"
"github.com/c3os-io/c3os/internal/utils"
"github.com/pterm/pterm"
)
func PrintTextFromFile(f string, banner string) {
installText := ""
text, err := ioutil.ReadFile(f)
if err == nil {
installText = string(text)
}
pterm.DefaultBox.WithTitle(banner).WithTitleBottomRight().WithRightPadding(0).WithBottomPadding(0).Println(
installText)
}
func PrintBranding(b []byte) {
brandingFile := c3os.BrandingFile("banner")
if _, err := os.Stat(brandingFile); err == nil {
f, err := ioutil.ReadFile(brandingFile)
if err == nil {
fmt.Println(string(f))
}
}
utils.PrintBanner(b)
}

View File

@@ -0,0 +1,49 @@
package github
import (
"context"
"fmt"
"log"
"net/http"
"strings"
"github.com/google/go-github/v40/github"
"golang.org/x/oauth2"
)
func newHTTPClient(ctx context.Context, token string) *http.Client {
if token == "" {
return http.DefaultClient
}
src := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token})
return oauth2.NewClient(ctx, src)
}
func FindReleases(ctx context.Context, token, slug string) ([]string, error) {
hc := newHTTPClient(ctx, token)
cli := github.NewClient(hc)
repo := strings.Split(slug, "/")
if len(repo) != 2 || repo[0] == "" || repo[1] == "" {
return nil, fmt.Errorf("Invalid slug format. It should be 'owner/name': %s", slug)
}
rels, res, err := cli.Repositories.ListReleases(ctx, repo[0], repo[1], nil)
if err != nil {
log.Println("API returned an error response:", err)
if res != nil && res.StatusCode == 404 {
// 404 means repository not found or release not found. It's not an error here.
err = nil
log.Println("API returned 404. Repository or release not found")
}
return nil, err
}
versions := []string{}
for _, rel := range rels {
if strings.HasPrefix(*rel.Name, "v") {
versions = append(versions, *rel.Name)
}
}
return versions, nil
}

103
internal/machine/machine.go Normal file
View File

@@ -0,0 +1,103 @@
package machine
import (
"fmt"
"os"
"github.com/c3os-io/c3os/internal/machine/openrc"
"github.com/c3os-io/c3os/internal/machine/systemd"
"github.com/denisbrodbeck/machineid"
"github.com/c3os-io/c3os/internal/utils"
)
type Service interface {
WriteUnit() error
Start() error
OverrideCmd(string) error
Enable() error
Restart() error
}
func EdgeVPN(instance, rootDir string) (Service, error) {
if utils.IsOpenRCBased() {
return openrc.NewService(
openrc.WithName("edgevpn"),
openrc.WithUnitContent(openrc.EdgevpnUnit),
openrc.WithRoot(rootDir),
)
} else {
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")
return nil
}
func Getty(i int) (Service, error) {
if utils.IsOpenRCBased() {
return &fakegetty{}, nil
} else {
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"),
)
} else {
return systemd.NewService(
systemd.WithName("k3s"),
)
}
}
func K3sAgent() (Service, error) {
if utils.IsOpenRCBased() {
return openrc.NewService(
openrc.WithName("k3s-agent"),
)
} else {
return systemd.NewService(
systemd.WithName("k3s-agent"),
)
}
}
func K3sEnvUnit(unit string) string {
if utils.IsOpenRCBased() {
return fmt.Sprintf("/etc/rancher/k3s/%s.env", unit)
} else {
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)
}

View 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`

View File

@@ -0,0 +1,86 @@
package openrc
import (
"fmt"
"io/ioutil"
"path/filepath"
"strings"
"github.com/c3os-io/c3os/internal/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 {
cmd = strings.ReplaceAll(cmd, "/usr/bin/k3s ", "")
svcDir := filepath.Join(s.rootdir, fmt.Sprintf("/etc/rancher/k3s/%s.env", s.name))
return ioutil.WriteFile(svcDir, []byte(fmt.Sprintf(`command_args="%s >>/var/log/%s.log 2>&1"`, cmd, s.name)), 0600)
}
func (s ServiceUnit) Start() error {
_, err := utils.SH(fmt.Sprintf("/etc/init.d/%s start", s.name))
return err
}
func (s ServiceUnit) Restart() error {
_, err := utils.SH(fmt.Sprintf("/etc/init.d/%s restart", s.name))
return err
}
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()
}

View File

@@ -0,0 +1,13 @@
package systemd
const EdgevpnUnit string = `[Unit]
Description=EdgeVPN Daemon
After=network.target
[Service]
EnvironmentFile=/etc/systemd/system.conf.d/edgevpn-%i.env
LimitNOFILE=49152
ExecStartPre=-/bin/sh -c "sysctl -w net.core.rmem_max=2500000"
ExecStart=edgevpn
Restart=always
[Install]
WantedBy=multi-user.target`

View File

@@ -0,0 +1,115 @@
package systemd
import (
"fmt"
"io/ioutil"
"os"
"path/filepath"
"strings"
"github.com/c3os-io/c3os/internal/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
}
utils.SH("systemctl daemon-reload")
return nil
}
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)
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
}

View File

@@ -0,0 +1,210 @@
package provider
import (
"context"
"encoding/json"
"fmt"
"strings"
logging "github.com/ipfs/go-log"
edgeVPNClient "github.com/mudler/edgevpn/api/client"
"go.uber.org/zap"
eventBus "github.com/c3os-io/c3os/internal/bus"
"github.com/c3os-io/c3os/internal/machine"
"github.com/c3os-io/c3os/internal/machine/openrc"
"github.com/c3os-io/c3os/internal/machine/systemd"
"github.com/c3os-io/c3os/internal/role"
"github.com/c3os-io/c3os/internal/utils"
"github.com/c3os-io/c3os/internal/vpn"
"github.com/c3os-io/c3os/pkg/bus"
"github.com/c3os-io/c3os/pkg/config"
"github.com/mudler/edgevpn/api/client/service"
"github.com/mudler/go-pluggable"
)
func Bootstrap(e *pluggable.Event) pluggable.EventResponse {
cfg := &bus.BootstrapPayload{}
err := json.Unmarshal([]byte(e.Data), cfg)
if err != nil {
return pluggable.EventResponse{Error: fmt.Sprintf("Failed reading JSON input: %s input '%s'", err.Error(), e.Data)}
}
c := &config.Config{}
err = config.FromString(cfg.Config, c)
if err != nil {
return pluggable.EventResponse{Error: fmt.Sprintf("Failed reading JSON input: %s input '%s'", err.Error(), cfg.Config)}
}
utils.SH("sysctl -w net.core.rmem_max=2500000")
tokenNotDefined := (c.C3OS == nil || c.C3OS.NetworkToken == "")
if c.C3OS == nil && !c.K3s.Enabled && !c.K3sAgent.Enabled {
return pluggable.EventResponse{State: "No config file supplied"}
}
utils.SH("elemental run-stage c3os-agent.bootstrap")
eventBus.RunHookScript("/usr/bin/c3os-agent.bootstrap.hook")
logLevel := "debug"
if c.C3OS != nil && c.C3OS.LogLevel != "" {
logLevel = c.C3OS.LogLevel
}
lvl, err := logging.LevelFromString(logLevel)
if err != nil {
return pluggable.EventResponse{Error: fmt.Sprintf("Failed setup VPN: %s", err.Error())}
}
// TODO: Fixup Logging to file
loggerCfg := zap.NewProductionConfig()
loggerCfg.OutputPaths = []string{
cfg.Logfile,
}
logger, err := loggerCfg.Build()
if err != nil {
return pluggable.EventResponse{Error: fmt.Sprintf("Failed setup VPN: %s", err.Error())}
}
logging.SetAllLoggers(lvl)
log := &logging.ZapEventLogger{SugaredLogger: *logger.Sugar()}
// Do onetimebootstrap if K3s or K3s-agent are enabled.
// Those blocks are not required to be enabled in case of a c3os
// full automated setup. Otherwise, they must be explicitly enabled.
if c.K3s.Enabled || c.K3sAgent.Enabled {
err := oneTimeBootstrap(log, c, func() error { return vpn.Setup(machine.EdgeVPNDefaultInstance, cfg.APIAddress, "/", true, c) })
if err != nil {
return pluggable.EventResponse{Error: fmt.Sprintf("Failed setup: %s", err.Error())}
}
return pluggable.EventResponse{}
} else if tokenNotDefined {
return pluggable.EventResponse{Error: "No network token provided, exiting"}
}
logger.Info("Configuring VPN")
if err := vpn.Setup(machine.EdgeVPNDefaultInstance, cfg.APIAddress, "/", true, c); err != nil {
return pluggable.EventResponse{Error: fmt.Sprintf("Failed setup VPN: %s", err.Error())}
}
networkID := "c3os"
if c.C3OS != nil && c.C3OS.NetworkID != "" {
networkID = c.C3OS.NetworkID
}
cc := service.NewClient(
networkID,
edgeVPNClient.NewClient(edgeVPNClient.WithHost(cfg.APIAddress)))
nodeOpts := []service.Option{
service.WithLogger(log),
service.WithClient(cc),
service.WithUUID(machine.UUID()),
service.WithStateDir("/usr/local/.c3os/state"),
service.WithNetworkToken(c.C3OS.NetworkToken),
service.WithPersistentRoles("auto"),
service.WithRoles(
service.RoleKey{
Role: "master",
RoleHandler: role.Master(c),
},
service.RoleKey{
Role: "worker",
RoleHandler: role.Worker(c),
},
service.RoleKey{
Role: "auto",
RoleHandler: role.Auto(c),
},
),
}
// Optionally set up a specific node role if the user has defined so
if c.C3OS.Role != "" {
nodeOpts = append(nodeOpts, service.WithDefaultRoles(c.C3OS.Role))
}
k, err := service.NewNode(nodeOpts...)
if err != nil {
return pluggable.EventResponse{Error: fmt.Sprintf("Failed creating node: %s", err.Error())}
}
err = k.Start(context.Background())
if err != nil {
return pluggable.EventResponse{Error: fmt.Sprintf("Failed start: %s", err.Error())}
}
return pluggable.EventResponse{
State: "",
Data: "",
Error: "shouldn't return here",
}
}
func oneTimeBootstrap(l logging.StandardLogger, c *config.Config, vpnSetupFN func() error) error {
if role.SentinelExist() {
l.Info("Sentinel exists, nothing to do. exiting.")
return nil
}
l.Info("One time bootstrap starting")
var svc machine.Service
k3sConfig := config.K3s{}
svcName := "k3s"
svcRole := "server"
if c.K3s.Enabled {
k3sConfig = c.K3s
} else if c.K3sAgent.Enabled {
k3sConfig = c.K3sAgent
svcName = "k3s-agent"
svcRole = "agent"
}
if utils.IsOpenRCBased() {
svc, _ = openrc.NewService(
openrc.WithName(svcName),
)
} else {
svc, _ = systemd.NewService(
systemd.WithName(svcName),
)
}
envFile := machine.K3sEnvUnit(svcName)
if svc == nil {
return fmt.Errorf("could not detect OS")
}
// Setup systemd unit and starts it
if err := utils.WriteEnv(envFile,
k3sConfig.Env,
); err != nil {
return err
}
if err := svc.OverrideCmd(fmt.Sprintf("/usr/bin/k3s %s %s", svcRole, strings.Join(k3sConfig.Args, " "))); err != nil {
return err
}
if err := svc.Start(); err != nil {
return err
}
if err := svc.Enable(); err != nil {
return err
}
if len(c.VPN) > 0 {
if err := vpnSetupFN(); err != nil {
return err
}
}
return role.CreateSentinel()
}

View File

@@ -0,0 +1,53 @@
package provider_test
import (
"encoding/json"
"io/ioutil"
"os"
. "github.com/c3os-io/c3os/internal/provider"
"github.com/c3os-io/c3os/pkg/bus"
"github.com/c3os-io/c3os/pkg/config"
"github.com/mudler/go-pluggable"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"gopkg.in/yaml.v2"
)
var _ = Describe("Bootstrap provider", func() {
Context("logging", func() {
e := &pluggable.Event{}
BeforeEach(func() {
e = &pluggable.Event{}
})
It("logs to file", func() {
f, err := ioutil.TempFile(os.TempDir(), "tests")
Expect(err).ToNot(HaveOccurred())
defer os.RemoveAll(f.Name())
cfg := &config.Config{
C3OS: &config.C3OS{
NetworkToken: "foo",
},
}
dat, err := yaml.Marshal(cfg)
Expect(err).ToNot(HaveOccurred())
payload := &bus.BootstrapPayload{Logfile: f.Name(), Config: string(dat)}
dat, err = json.Marshal(payload)
Expect(err).ToNot(HaveOccurred())
e.Data = string(dat)
resp := Bootstrap(e)
dat, _ = json.Marshal(resp)
Expect(resp.Errored()).To(BeTrue(), string(dat))
data, err := ioutil.ReadFile(f.Name())
Expect(err).ToNot(HaveOccurred())
Expect(string(data)).Should(ContainSubstring("Configuring VPN"))
})
})
})

View File

@@ -0,0 +1,36 @@
package provider
import (
"encoding/json"
"fmt"
"github.com/c3os-io/c3os/pkg/bus"
"github.com/c3os-io/c3os/pkg/config"
"github.com/mudler/go-nodepair"
"github.com/mudler/go-pluggable"
)
func Challenge(e *pluggable.Event) pluggable.EventResponse {
p := &bus.EventPayload{}
err := json.Unmarshal([]byte(e.Data), p)
if err != nil {
return pluggable.EventResponse{Error: fmt.Sprintf("Failed reading JSON input: %s input '%s'", err.Error(), e.Data)}
}
cfg := &config.Config{}
err = config.FromString(p.Config, cfg)
if err != nil {
return pluggable.EventResponse{Error: fmt.Sprintf("Failed reading JSON input: %s input '%s'", err.Error(), p.Config)}
}
tk := ""
if cfg.C3OS != nil && cfg.C3OS.NetworkToken != "" {
tk = cfg.C3OS.NetworkToken
}
if tk == "" {
tk = nodepair.GenerateToken()
}
return pluggable.EventResponse{
Data: tk,
}
}

View File

@@ -0,0 +1,64 @@
package provider_test
import (
"encoding/json"
"io/ioutil"
"os"
. "github.com/c3os-io/c3os/internal/provider"
"github.com/c3os-io/c3os/pkg/bus"
"github.com/c3os-io/c3os/pkg/config"
"github.com/mudler/go-pluggable"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Challenge provider", func() {
Context("network token", func() {
e := &pluggable.Event{}
BeforeEach(func() {
e = &pluggable.Event{}
})
It("returns it if provided", func() {
f, err := ioutil.TempFile(os.TempDir(), "tests")
Expect(err).ToNot(HaveOccurred())
defer os.RemoveAll(f.Name())
cfg := &config.Config{
C3OS: &config.C3OS{
NetworkToken: "foo",
},
}
c := &bus.EventPayload{Config: cfg.String()}
dat, err := json.Marshal(c)
Expect(err).ToNot(HaveOccurred())
e.Data = string(dat)
resp := Challenge(e)
Expect(string(resp.Data)).Should(ContainSubstring("foo"))
})
It("generates it if not provided", func() {
f, err := ioutil.TempFile(os.TempDir(), "tests")
Expect(err).ToNot(HaveOccurred())
defer os.RemoveAll(f.Name())
cfg := &config.Config{
C3OS: &config.C3OS{
NetworkToken: "",
},
}
c := &bus.EventPayload{Config: cfg.String()}
dat, err := json.Marshal(c)
Expect(err).ToNot(HaveOccurred())
e.Data = string(dat)
resp := Challenge(e)
Expect(len(string(resp.Data))).Should(BeNumerically(">", 12))
})
})
})

View File

@@ -0,0 +1,36 @@
package provider
import (
"context"
"encoding/json"
"fmt"
"github.com/c3os-io/c3os/pkg/bus"
"github.com/mudler/go-nodepair"
"github.com/mudler/go-pluggable"
)
func Install(e *pluggable.Event) pluggable.EventResponse {
cfg := &bus.InstallPayload{}
err := json.Unmarshal([]byte(e.Data), cfg)
if err != nil {
return pluggable.EventResponse{Error: fmt.Sprintf("Failed reading JSON input: %s", err.Error())}
}
r := map[string]string{}
ctx := context.Background()
if err := nodepair.Receive(ctx, &r, nodepair.WithToken(cfg.Token)); err != nil {
return pluggable.EventResponse{Error: fmt.Sprintf("Failed reading JSON input: %s", err.Error())}
}
payload, err := json.Marshal(r)
if err != nil {
return pluggable.EventResponse{Error: fmt.Sprintf("Failed marshalling JSON input: %s", err.Error())}
}
return pluggable.EventResponse{
State: "",
Data: string(payload),
Error: "",
}
}

View File

@@ -0,0 +1,13 @@
package provider_test
import (
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestInstaller(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Provider Suite")
}

57
internal/role/auto.go Normal file
View File

@@ -0,0 +1,57 @@
package role
import (
"github.com/c3os-io/c3os/pkg/config"
utils "github.com/mudler/edgevpn/pkg/utils"
service "github.com/mudler/edgevpn/api/client/service"
)
func contains(slice []string, elem string) bool {
for _, s := range slice {
if elem == s {
return true
}
}
return false
}
func Auto(cc *config.Config) Role {
return func(c *service.RoleConfig) error {
advertizing, _ := c.Client.AdvertizingNodes()
actives, _ := c.Client.ActiveNodes()
c.Logger.Info("Active nodes:", actives)
c.Logger.Info("Advertizing nodes:", advertizing)
if len(advertizing) < 2 {
c.Logger.Info("Not enough nodes")
return nil
}
// first get available nodes
nodes := advertizing
shouldBeLeader := utils.Leader(advertizing)
lead, _ := c.Client.Get("auto", "leader")
// From now on, only the leader keeps processing
// TODO: Make this more reliable with consensus
if shouldBeLeader != c.UUID && lead != c.UUID {
c.Logger.Infof("<%s> not a leader, leader is '%s', sleeping", c.UUID, shouldBeLeader)
return nil
}
if shouldBeLeader == c.UUID && (lead == "" || !contains(nodes, lead)) {
c.Client.Set("auto", "leader", c.UUID)
c.Logger.Info("Announcing ourselves as leader, backing off")
return nil
}
if lead != c.UUID {
c.Logger.Info("Backing off, as we are not currently flagged as leader")
return nil
}
return scheduleRoles(nodes, c, cc)
}
}

21
internal/role/common.go Normal file
View File

@@ -0,0 +1,21 @@
package role
import (
"io/ioutil"
"os"
service "github.com/mudler/edgevpn/api/client/service"
)
type Role func(*service.RoleConfig) error
func SentinelExist() bool {
if _, err := os.Stat("/usr/local/.c3os/deployed"); err == nil {
return true
}
return false
}
func CreateSentinel() error {
return ioutil.WriteFile("/usr/local/.c3os/deployed", []byte{}, os.ModePerm)
}

134
internal/role/master.go Normal file
View File

@@ -0,0 +1,134 @@
package role
import (
"encoding/base64"
"errors"
"fmt"
"io/ioutil"
"strings"
"time"
"github.com/c3os-io/c3os/internal/machine"
"github.com/c3os-io/c3os/internal/utils"
"github.com/c3os-io/c3os/pkg/config"
service "github.com/mudler/edgevpn/api/client/service"
)
func propagateMasterData(ip string, c *service.RoleConfig) error {
defer func() {
// Avoid polluting the API.
// The ledger already retries in the background to update the blockchain, but it has
// a default timeout where it would stop trying afterwards.
// Each request here would have it's own background announce, so that can become expensive
// when network is having lot of changes on its way.
time.Sleep(30 * time.Second)
}()
// If we are configured as master, always signal our role
c.Client.Set("role", c.UUID, "master")
tokenB, err := ioutil.ReadFile("/var/lib/rancher/k3s/server/node-token")
if err != nil {
c.Logger.Error(err)
return err
}
nodeToken := string(tokenB)
nodeToken = strings.TrimRight(nodeToken, "\n")
if nodeToken != "" {
c.Client.Set("nodetoken", "token", nodeToken)
}
kubeB, err := ioutil.ReadFile("/etc/rancher/k3s/k3s.yaml")
if err != nil {
c.Logger.Error(err)
return err
}
kubeconfig := string(kubeB)
if kubeconfig != "" {
c.Client.Set("kubeconfig", "master", base64.RawURLEncoding.EncodeToString(kubeB))
}
c.Client.Set("master", "ip", ip)
return nil
}
func Master(cc *config.Config) Role {
return func(c *service.RoleConfig) error {
ip := utils.GetInterfaceIP("edgevpn0")
if ip == "" {
return errors.New("node doesn't have an ip yet")
}
if cc.C3OS.Role != "" {
// propagate role if we were forced by configuration
// This unblocks eventual auto instances to try to assign roles
c.Client.Set("role", c.UUID, cc.C3OS.Role)
}
if SentinelExist() {
c.Logger.Info("Node already configured, backing off")
return propagateMasterData(ip, c)
}
// Configure k3s service to start on edgevpn0
c.Logger.Info("Configuring k3s")
svc, err := machine.K3s()
if err != nil {
return err
}
k3sConfig := config.K3s{}
if cc.K3s.Enabled {
k3sConfig = cc.K3s
}
env := map[string]string{}
if !k3sConfig.ReplaceEnv {
// Override opts with user-supplied
for k, v := range k3sConfig.Env {
env[k] = v
}
} else {
env = k3sConfig.Env
}
if err := utils.WriteEnv(machine.K3sEnvUnit("k3s"),
env,
); err != nil {
return err
}
args := []string{"--flannel-iface=edgevpn0"}
if k3sConfig.ReplaceArgs {
args = k3sConfig.Args
} else {
args = append(args, k3sConfig.Args...)
}
if err := svc.OverrideCmd(fmt.Sprintf("/usr/bin/k3s server %s", strings.Join(args, " "))); err != nil {
return err
}
if err := svc.Start(); err != nil {
return err
}
if err := svc.Enable(); err != nil {
return err
}
propagateMasterData(ip, c)
CreateSentinel()
return nil
}
}
// TODO: https://rancher.com/docs/k3s/latest/en/installation/ha-embedded/
func HAMaster(c *service.RoleConfig) {
c.Logger.Info("HA Role not implemented yet")
}

73
internal/role/schedule.go Normal file
View File

@@ -0,0 +1,73 @@
package role
import (
"math/rand"
"time"
"github.com/c3os-io/c3os/pkg/config"
service "github.com/mudler/edgevpn/api/client/service"
)
// scheduleRoles assigns roles to nodes. Meant to be called only by leaders
// TODO: HA-Auto
func scheduleRoles(nodes []string, c *service.RoleConfig, cc *config.Config) error {
rand.Seed(time.Now().Unix())
// Assign roles to nodes
currentRoles := map[string]string{}
existsMaster := false
unassignedNodes := []string{}
for _, a := range nodes {
role, _ := c.Client.Get("role", a)
currentRoles[a] = role
if role == "master" {
existsMaster = true
} else if role == "" {
unassignedNodes = append(unassignedNodes, a)
}
}
c.Logger.Infof("I'm the leader. My UUID is: %s.\n Current assigned roles: %+v", c.UUID, currentRoles)
c.Logger.Infof("Master already present: %t", existsMaster)
c.Logger.Infof("Unassigned nodes: %+v", unassignedNodes)
if !existsMaster && len(unassignedNodes) > 0 {
var selected string
toSelect := unassignedNodes
// Avoid to schedule to ourselves if we have a static role
if cc.C3OS.Role != "" {
toSelect = []string{}
for _, u := range unassignedNodes {
if u != c.UUID {
toSelect = append(toSelect, u)
}
}
}
// select one node without roles to become master
if len(toSelect) == 1 {
selected = toSelect[0]
} else {
selected = toSelect[rand.Intn(len(toSelect)-1)]
}
c.Client.Set("role", selected, "master")
c.Logger.Info("-> Set master to", selected)
currentRoles[selected] = "master"
// Return here, so next time we get called
// makes sure master is set.
return nil
}
// cycle all empty roles and assign worker roles
for _, uuid := range unassignedNodes {
c.Client.Set("role", uuid, "worker")
c.Logger.Info("-> Set worker to", uuid)
}
c.Logger.Info("Done scheduling")
return nil
}

108
internal/role/worker.go Normal file
View File

@@ -0,0 +1,108 @@
package role
import (
"errors"
"fmt"
"strings"
"github.com/c3os-io/c3os/internal/machine"
"github.com/c3os-io/c3os/internal/utils"
"github.com/c3os-io/c3os/pkg/config"
service "github.com/mudler/edgevpn/api/client/service"
)
func Worker(cc *config.Config) Role {
return func(c *service.RoleConfig) error {
if cc.C3OS.Role != "" {
// propagate role if we were forced by configuration
// This unblocks eventual auto instances to try to assign roles
c.Client.Set("role", c.UUID, cc.C3OS.Role)
}
if SentinelExist() {
c.Logger.Info("Node already configured, backing off")
return nil
}
masterIP, _ := c.Client.Get("master", "ip")
if masterIP == "" {
c.Logger.Info("MasterIP not there still..")
return nil
}
nodeToken, _ := c.Client.Get("nodetoken", "token")
if masterIP == "" {
c.Logger.Info("nodetoken not there still..")
return nil
}
nodeToken = strings.TrimRight(nodeToken, "\n")
ip := utils.GetInterfaceIP("edgevpn0")
if ip == "" {
return errors.New("node doesn't have an ip yet")
}
c.Logger.Info("Configuring k3s-agent", ip, masterIP, nodeToken)
svc, err := machine.K3sAgent()
if err != nil {
return err
}
k3sConfig := config.K3s{}
if cc.K3sAgent.Enabled {
k3sConfig = cc.K3sAgent
}
env := map[string]string{
"K3S_URL": fmt.Sprintf("https://%s:6443", masterIP),
"K3S_TOKEN": nodeToken,
}
if !k3sConfig.ReplaceEnv {
// Override opts with user-supplied
for k, v := range k3sConfig.Env {
env[k] = v
}
} else {
env = k3sConfig.Env
}
// Setup systemd unit and starts it
if err := utils.WriteEnv(machine.K3sEnvUnit("k3s-agent"),
env,
); err != nil {
return err
}
args := []string{
"--with-node-id",
fmt.Sprintf("--node-ip %s", ip),
"--flannel-iface=edgevpn0",
}
if k3sConfig.ReplaceArgs {
args = k3sConfig.Args
} else {
args = append(args, k3sConfig.Args...)
}
if err := svc.OverrideCmd(fmt.Sprintf("/usr/bin/k3s agent %s", strings.Join(args, " "))); err != nil {
return err
}
if err := svc.Start(); err != nil {
return err
}
if err := svc.Enable(); err != nil {
return err
}
CreateSentinel()
return nil
}
}

37
internal/utils/console.go Normal file
View File

@@ -0,0 +1,37 @@
package utils
import (
"bufio"
"bytes"
"fmt"
"image"
"os"
"strings"
"github.com/pterm/pterm"
"github.com/qeesung/image2ascii/convert"
)
func Prompt(t string) (string, error) {
if t != "" {
pterm.Info.Println(t)
}
answer, err := bufio.NewReader(os.Stdin).ReadString('\n')
if err != nil {
return "", err
}
return strings.TrimSpace(answer), nil
}
func PrintBanner(d []byte) {
img, _, _ := image.Decode(bytes.NewReader(d))
convertOptions := convert.DefaultOptions
convertOptions.FixedWidth = 100
convertOptions.FixedHeight = 40
// Create the image converter
converter := convert.NewImageConverter()
fmt.Print(converter.Image2ASCIIString(img, &convertOptions))
}

33
internal/utils/sh.go Normal file
View File

@@ -0,0 +1,33 @@
package utils
import (
"io/ioutil"
"os"
"os/exec"
"github.com/joho/godotenv"
)
func SH(c string) (string, error) {
o, err := exec.Command("/bin/sh", "-c", c).CombinedOutput()
return string(o), err
}
func WriteEnv(envFile string, config map[string]string) error {
content, _ := ioutil.ReadFile(envFile)
env, _ := godotenv.Unmarshal(string(content))
for key, val := range config {
env[key] = val
}
return godotenv.Write(env, envFile)
}
func Shell() *exec.Cmd {
cmd := exec.Command("/bin/sh")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
return cmd
}

15
internal/utils/signal.go Normal file
View File

@@ -0,0 +1,15 @@
package utils
import (
"os"
"os/signal"
)
func OnSignal(fn func(), sig ...os.Signal) {
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, sig...)
go func() {
<-sigs
fn()
}()
}

20
internal/utils/strings.go Normal file
View File

@@ -0,0 +1,20 @@
package utils
import (
"math/rand"
"time"
)
func init() {
rand.Seed(time.Now().UnixNano())
}
var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
func RandStringRunes(n int) string {
b := make([]rune, n)
for i := range b {
b[i] = letterRunes[rand.Intn(len(letterRunes))]
}
return string(b)
}

82
internal/utils/system.go Normal file
View File

@@ -0,0 +1,82 @@
package utils
import (
"fmt"
"net"
"strings"
"github.com/joho/godotenv"
"github.com/pterm/pterm"
)
func Reboot() {
pterm.Info.Println("Rebooting node")
SH("reboot")
}
func PowerOFF() {
pterm.Info.Println("Shutdown node")
if IsOpenRCBased() {
SH("poweroff")
} else {
SH("shutdown")
}
}
func Version() string {
release, _ := godotenv.Read("/etc/os-release")
v := release["VERSION"]
v = strings.ReplaceAll(v, "+k3s1-c3OS", "-")
return strings.ReplaceAll(v, "+k3s-c3OS", "-")
}
func OSRelease(key string) (string, error) {
release, err := godotenv.Read("/etc/os-release")
if err != nil {
return "", err
}
v, exists := release[key]
if !exists {
return "", fmt.Errorf("key not found")
}
return v, nil
}
func Flavor() string {
release, _ := godotenv.Read("/etc/os-release")
v := release["NAME"]
return strings.ReplaceAll(v, "c3os-", "")
}
func IsOpenRCBased() bool {
f := Flavor()
return f == "alpine" || f == "alpine-arm-rpi"
}
func GetInterfaceIP(in string) string {
ifaces, err := net.Interfaces()
if err != nil {
fmt.Println("failed getting system interfaces")
return ""
}
for _, i := range ifaces {
if i.Name == in {
addrs, _ := i.Addrs()
// handle err
for _, addr := range addrs {
var ip net.IP
switch v := addr.(type) {
case *net.IPNet:
ip = v.IP
case *net.IPAddr:
ip = v.IP
}
if ip != nil {
return ip.String()
}
}
}
}
return ""
}

13
internal/utils/token.go Normal file
View File

@@ -0,0 +1,13 @@
package utils
import "strings"
const sep = "_CREDENTIALS_"
func EncodeRecoveryToken(data ...string) string {
return strings.Join(data, sep)
}
func DecodeRecoveryToken(recoverytoken string) []string {
return strings.Split(recoverytoken, sep)
}

90
internal/vpn/setup.go Normal file
View File

@@ -0,0 +1,90 @@
package vpn
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/c3os-io/c3os/internal/machine"
"github.com/c3os-io/c3os/internal/machine/systemd"
"github.com/c3os-io/c3os/internal/utils"
"github.com/c3os-io/c3os/pkg/config"
yip "github.com/mudler/yip/pkg/schema"
)
func Setup(instance, apiAddress, rootDir string, start bool, c *config.Config) error {
if c.C3OS == nil || c.C3OS.NetworkToken == "" {
return fmt.Errorf("no network token defined")
}
svc, err := machine.EdgeVPN(instance, rootDir)
if err != nil {
return err
}
apiAddress = strings.ReplaceAll(apiAddress, "https://", "")
apiAddress = strings.ReplaceAll(apiAddress, "http://", "")
vpnOpts := map[string]string{
"EDGEVPNTOKEN": c.C3OS.NetworkToken,
"API": "true",
"APILISTEN": apiAddress,
"EDGEVPNLOWPROFILEVPN": "true",
"DHCP": "true",
"DHCPLEASEDIR": "/usr/local/.c3os/lease",
}
// Override opts with user-supplied
for k, v := range c.VPN {
vpnOpts[k] = v
}
if c.C3OS.DNS {
vpnOpts["DNSADDRESS"] = "127.0.0.1:53"
vpnOpts["DNSFORWARD"] = "true"
if !utils.IsOpenRCBased() {
if _, err := os.Stat("/etc/sysconfig/network/config"); err == nil {
utils.WriteEnv("/etc/sysconfig/network/config", map[string]string{
"NETCONFIG_DNS_STATIC_SERVERS": "127.0.0.1",
})
if utils.Flavor() == "opensuse" {
// TODO: This is dependant on wickedd, move this out in its own network detection block
svc, err := systemd.NewService(systemd.WithName("wickedd"))
if err == nil {
svc.Restart()
}
}
}
}
if err := config.SaveCloudConfig("dns", yip.YipConfig{
Name: "DNS Configuration",
Stages: map[string][]yip.Stage{
config.NetworkStage.String(): {{Dns: yip.DNS{Nameservers: []string{"127.0.0.1"}}}}},
}); err != nil {
fmt.Println("Failed installing DNS")
}
}
os.MkdirAll("/etc/systemd/system.conf.d/", 0600)
// Setup edgevpn instance
err = utils.WriteEnv(filepath.Join(rootDir, "/etc/systemd/system.conf.d/edgevpn-c3os.env"), vpnOpts)
if err != nil {
return err
}
err = svc.WriteUnit()
if err != nil {
return err
}
if start {
err = svc.Start()
if err != nil {
return err
}
return svc.Enable()
}
return nil
}