seedling: Webui installer (#587)

* 🌱 Add webui

Signed-off-by: mudler <mudler@c3os.io>

* 🌱 Re-read config files after loading bundles

Signed-off-by: mudler <mudler@c3os.io>

* [check-spelling] Update metadata

Update for https://github.com/kairos-io/kairos/actions/runs/3806058276/attempts/1
Accepted in https://github.com/kairos-io/kairos/pull/587#issuecomment-1367859480

Signed-off-by: check-spelling-bot <check-spelling-bot@users.noreply.github.com>
Signed-off-by: mudler <mudler@c3os.io>

* 🎨 Beautify index page

Signed-off-by: mudler <mudler@c3os.io>

* Do not rerun if we were successful or we are already running

Signed-off-by: mudler <mudler@c3os.io>

* Add syntax highlight

Signed-off-by: mudler <mudler@c3os.io>

* Add error message

Signed-off-by: mudler <mudler@c3os.io>

* Add YAML validation and highlight

Signed-off-by: mudler <mudler@c3os.io>

* Fixup terminal output

Signed-off-by: mudler <mudler@c3os.io>

* Fix newlines

Signed-off-by: mudler <mudler@c3os.io>

* fixups

Signed-off-by: mudler <mudler@c3os.io>

* 🎨 Fixup lint issues

Signed-off-by: mudler <mudler@c3os.io>

* Mark dependencies

Signed-off-by: mudler <mudler@c3os.io>

* Let configure the listening address

Signed-off-by: mudler <mudler@c3os.io>

Signed-off-by: mudler <mudler@c3os.io>
Signed-off-by: check-spelling-bot <check-spelling-bot@users.noreply.github.com>
This commit is contained in:
Ettore Di Giacinto
2023-01-05 14:15:05 +01:00
committed by Itxaka
parent 34c8ad827f
commit d59892c5c5
10 changed files with 582 additions and 3 deletions

View File

@@ -75,6 +75,12 @@ func Run(opts ...Option) error {
if c.FailOnBundleErrors && err != nil {
return err
}
// Re-read config files
c, err = config.Scan(config.Directories(o.Dir...))
if err != nil {
return err
}
}
_, err = bus.Manager.Publish(events.EventBootstrap, events.BootstrapPayload{APIAddress: o.APIAddress, Config: c.String(), Logfile: fileName})

View File

@@ -14,10 +14,13 @@ type BrandingText struct {
Reset string `yaml:"reset"`
Recovery string `yaml:"recovery"`
}
type WebUI struct {
Disable bool `yaml:"disable"`
ListenAddress string `yaml:"listen_address"`
}
type Config struct {
Fast bool `yaml:"fast,omitempty"`
Fast bool `yaml:"fast,omitempty"`
WebUI WebUI `yaml:"webui"`
Branding BrandingText `yaml:"branding"`
}

View File

@@ -228,6 +228,10 @@ func RunInstall(options map[string]string) error {
c.Install.Reboot = true
}
if c.Install.Image != "" {
options["system.uri"] = c.Install.Image
}
env := append(c.Install.Env, c.Env...)
utils.SetEnv(env)

View File

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

2
internal/webui/public/js/yaml.min.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

241
internal/webui/webui.go Normal file
View File

@@ -0,0 +1,241 @@
package webui
import (
"context"
"embed"
"io"
"io/fs"
"io/ioutil"
"log"
"net/http"
"os"
"sync"
"text/template"
"time"
"github.com/kairos-io/kairos/internal/agent"
"github.com/labstack/echo/v4"
process "github.com/mudler/go-processmanager"
"github.com/nxadm/tail"
"golang.org/x/net/websocket"
)
type FormData struct {
CloudConfig string `form:"cloud-config" json:"cloud-config" query:"cloud-config"`
Reboot string `form:"reboot" json:"reboot" query:"reboot"`
PowerOff string `form:"power-off" json:"power-off" query:"power-off"`
InstallationDevice string `form:"installation-device" json:"installation-device" query:"installation-device"`
}
//go:embed public
var embededFiles embed.FS
func getFileSystem() http.FileSystem {
fsys, err := fs.Sub(embededFiles, "public")
if err != nil {
panic(err)
}
return http.FS(fsys)
}
func getFS() fs.FS {
fsys, err := fs.Sub(embededFiles, "public")
if err != nil {
panic(err)
}
return fsys
}
func streamProcess(s *state) func(c echo.Context) error {
return func(c echo.Context) error {
consumeError := func(err error) {
if err != nil {
c.Logger().Error(err)
}
}
websocket.Handler(func(ws *websocket.Conn) {
defer ws.Close()
for {
s.Lock()
if s.p == nil {
// Write
err := websocket.Message.Send(ws, "No process!")
consumeError(err)
s.Unlock()
return
}
s.Unlock()
if !s.p.IsAlive() {
errOut, err := os.ReadFile(s.p.StderrPath())
if err == nil {
err := websocket.Message.Send(ws, string(errOut))
consumeError(err)
}
out, err := os.ReadFile(s.p.StdoutPath())
if err == nil {
err = websocket.Message.Send(ws, string(out))
consumeError(err)
}
err = websocket.Message.Send(ws, "Process stopped!")
consumeError(err)
return
}
t, err := tail.TailFile(s.p.StdoutPath(), tail.Config{Follow: true})
if err != nil {
return
}
t2, err := tail.TailFile(s.p.StderrPath(), tail.Config{Follow: true})
if err != nil {
return
}
for {
select {
case line := <-t.Lines:
err = websocket.Message.Send(ws, line.Text+"\r\n")
consumeError(err)
case line := <-t2.Lines:
err = websocket.Message.Send(ws, line.Text+"\r\n")
consumeError(err)
}
}
}
}).ServeHTTP(c.Response(), c.Request())
return nil
}
}
type state struct {
p *process.Process
sync.Mutex
}
// TemplateRenderer is a custom html/template renderer for Echo framework.
type TemplateRenderer struct {
templates *template.Template
}
// Render renders a template document.
func (t *TemplateRenderer) Render(w io.Writer, name string, data interface{}, c echo.Context) error {
// Add global methods if data is a map
if viewContext, isMap := data.(map[string]interface{}); isMap {
viewContext["reverse"] = c.Echo().Reverse
}
return t.templates.ExecuteTemplate(w, name, data)
}
func Start(ctx context.Context) error {
s := state{}
listen := ":8080"
ec := echo.New()
assetHandler := http.FileServer(getFileSystem())
renderer := &TemplateRenderer{
templates: template.Must(template.ParseFS(getFS(), "*.html")),
}
ec.Renderer = renderer
agentConfig, err := agent.LoadConfig()
if err != nil {
return err
}
if agentConfig.WebUI.ListenAddress != "" {
listen = agentConfig.WebUI.ListenAddress
}
if agentConfig.WebUI.Disable {
log.Println("WebUI installer disabled by branding")
return nil
}
ec.GET("/*", echo.WrapHandler(http.StripPrefix("/", assetHandler)))
ec.POST("/install", func(c echo.Context) error {
s.Lock()
if s.p != nil {
status, _ := s.p.ExitCode()
if s.p.IsAlive() || status == "0" {
s.Unlock()
return c.Redirect(http.StatusSeeOther, "progress.html")
}
}
s.Unlock()
formData := new(FormData)
if err := c.Bind(formData); err != nil {
return err
}
// Process the form data as necessary
cloudConfig := formData.CloudConfig
reboot := formData.Reboot
powerOff := formData.PowerOff
installationDevice := formData.InstallationDevice
args := []string{"manual-install"}
if powerOff == "on" {
args = append(args, "--poweroff")
}
if reboot == "on" {
args = append(args, "--reboot")
}
args = append(args, "--device", installationDevice)
// create tempfile to store cloud-config, bail out if we fail as we couldn't go much further
file, err := ioutil.TempFile("", "install-webui")
if err != nil {
log.Fatalf("could not create tmpfile for cloud-config: %s", err.Error())
}
err = os.WriteFile(file.Name(), []byte(cloudConfig), 0600)
if err != nil {
log.Fatalf("could not write tmpfile for cloud-config: %s", err.Error())
}
args = append(args, file.Name())
s.Lock()
s.p = process.New(process.WithName("/usr/bin/kairos-agent"), process.WithArgs(args...), process.WithTemporaryStateDir())
s.Unlock()
err = s.p.Run()
if err != nil {
return c.Render(http.StatusOK, "message.html", map[string]interface{}{
"message": err.Error(),
"type": "danger",
})
}
// Start install process, lock with sentinel
return c.Redirect(http.StatusSeeOther, "progress.html")
})
ec.GET("/ws", streamProcess(&s))
if err := ec.Start(listen); err != nil && err != http.ErrServerClosed {
return err
}
go func() {
<-ctx.Done()
ct, cancel := context.WithTimeout(context.Background(), 10*time.Second)
err := ec.Shutdown(ct)
if err != nil {
log.Printf("shutdown failed: %s", err.Error())
}
cancel()
}()
return nil
}