15 Commits

Author SHA1 Message Date
Miroslav Šedivý 74fe2d7eab add stop timeout to config. 2023-12-26 16:17:55 +01:00
Miroslav Šedivý a844a9f9d2 add sse events to client. 2023-12-26 16:17:55 +01:00
Miroslav Šedivý afa67ac537 move events out of the rooms. 2023-12-26 16:17:55 +01:00
Miroslav Šedivý d7f7acc7f3 room events optional sse. 2023-12-26 16:17:55 +01:00
Miroslav Šedivý 2f7aa7ed13 events fix logging level. 2023-12-26 16:17:55 +01:00
Miroslav Šedivý bf83709880 fix proxy. 2023-12-26 16:17:55 +01:00
Miroslav Šedivý 77695d42a3 fix double logging. 2023-12-26 16:17:55 +01:00
Miroslav Šedivý 2ac5e83448 wait only for ready. 2023-12-26 16:17:55 +01:00
Miroslav Šedivý 38e4cab3fe proxy use room manager. 2023-12-26 16:17:55 +01:00
Miroslav Šedivý b62ac1097c container labels and room event type. 2023-12-26 16:17:55 +01:00
Miroslav Šedivý 7d70f7ac9b fix events. 2023-12-26 16:17:55 +01:00
Miroslav Šedivý 4d2393c457 add is ready to API. 2023-12-26 16:17:55 +01:00
Miroslav Šedivý ab8c6a53ec WIP. 2023-12-26 16:17:55 +01:00
Miroslav Šedivý a6169da135 interface{} to any. 2023-11-25 22:54:35 +01:00
Miroslav Šedivý ffeef5e887 fix pprof. 2023-11-25 15:37:08 +01:00
23 changed files with 592 additions and 228 deletions
+3
View File
@@ -411,6 +411,9 @@ components:
running:
type: boolean
example: true
is_ready:
type: boolean
example: true
status:
type: string
example: Up 2 seconds
+6
View File
@@ -153,6 +153,12 @@ export default new Vuex.Store({
const res = await defaultApi.pullStop()
return res.data
},
async EVENTS_SSE(): Promise<EventSource> {
return new EventSource(configuration.basePath + '/api/events?sse', {
withCredentials: true,
})
},
},
modules: {
}
+63 -4
View File
@@ -83,9 +83,10 @@ export default class Home extends Vue {
public dialog = false
public interval!: number
public autoRefresh = 10
public autoRefresh = 0
public autoRefreshItems = [
{ text: 'Off', value: 0 },
{ text: 'Off', value: -1 },
{ text: 'Live', value: 0 },
{ text: '5s', value: 5 },
{ text: '10s', value: 10 },
{ text: '30s', value: 30 },
@@ -127,8 +128,11 @@ export default class Home extends Vue {
window.clearInterval(this.interval)
}
if (this.autoRefresh) {
if (this.autoRefresh > 0) {
this.sseClose()
this.interval = window.setInterval(this.LoadRooms, this.autoRefresh * 1000)
} else if (this.autoRefresh == 0) {
this.sseStart()
}
}
@@ -148,7 +152,62 @@ export default class Home extends Vue {
}
beforeDestroy() {
this.autoRefresh = 0
this.autoRefresh = -1
}
sse: EventSource | null = null
sseIsOpen = false
sseReconnBackoff = 1000
sseReconnRetries = 0
async sseStart(reconnect = false) {
if (!reconnect) {
this.sseIsOpen = true
}
if (this.sse) {
this.sse.close()
}
this.sse = await this.$store.dispatch('EVENTS_SSE') as EventSource
this.sseReconnRetries++
this.sse.addEventListener('rooms', (e) => {
console.log('SSE rooms', e)
this.LoadRooms()
})
this.sse.addEventListener('error', (e: Event) => {
console.log('SSE error', e)
// if is closed, don't try to reconnect
if (!this.sseIsOpen) return
const wait = this.sseReconnBackoff + 100 * this.sseReconnRetries
// try to reconnect
setTimeout(() => {
this.sseStart(true)
}, wait)
console.log('SSE error, trying to reconnect in ' + wait + 'ms')
})
this.sse.addEventListener('message', (e: Event) => {
console.log('SSE message', e)
})
this.sse.addEventListener('open', (e: Event) => {
console.log('SSE opened', e)
})
}
sseClose() {
if (this.sse) {
this.sse.close()
}
this.sse = null
this.sseIsOpen = false
}
}
</script>
+2
View File
@@ -11,7 +11,9 @@ module.exports = defineConfig({
proxy: process.env.API_PROXY ? {
'^/api': {
target: process.env.API_PROXY,
timeout: 0, // because of SSE
},
} : undefined,
compress: false, // because of SSE
}
})
+6
View File
@@ -62,4 +62,10 @@ func (manager *ApiManagerCtx) Mount(r chi.Router) {
})
r.Get("/docker-compose.yaml", manager.dockerCompose)
//
// events
//
r.Get("/events", manager.events)
}
+67
View File
@@ -0,0 +1,67 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"time"
)
func (manager *ApiManagerCtx) events(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
sse := r.URL.Query().Has("sse")
if sse {
w.Header().Set("Content-Type", "text/event-stream")
} else {
w.Header().Set("Content-Type", "application/json")
}
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "Connection does not support streaming", http.StatusBadRequest)
return
}
var ping <-chan time.Time
if !sse {
// dummy channel, never ping
ping = make(<-chan time.Time)
} else {
// ping every 1 minute
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
ping = ticker.C
}
// listen for room events
events, errs := manager.rooms.Events(r.Context())
for {
select {
case <-ping:
fmt.Fprintf(w, ": ping\n\n")
flusher.Flush()
case _, ok := <-errs:
if !ok {
manager.logger.Debug().Msg("sse channel closed")
}
return
case e := <-events:
jsonData, err := json.Marshal(e)
if err != nil {
manager.logger.Err(err).Msg("failed to marshal event")
continue
}
if sse {
fmt.Fprintf(w, "event: rooms\n")
fmt.Fprintf(w, "data: %s\n\n", jsonData)
} else {
fmt.Fprintf(w, "rooms\t%s\n", jsonData)
}
flusher.Flush()
}
}
}
+7
View File
@@ -32,6 +32,7 @@ type Room struct {
PathPrefix string
Labels []string
WaitEnabled bool
StopTimeoutSec int
StorageEnabled bool
StorageInternal string
@@ -100,6 +101,11 @@ func (Room) Init(cmd *cobra.Command) error {
return err
}
cmd.PersistentFlags().Int("stop_timeout", 10, "timeout in seconds for stopping the room with SIGTERM, after that SIGKILL is used (0 to disable, -1 to wait forever)")
if err := viper.BindPFlag("stop_timeout", cmd.PersistentFlags().Lookup("stop_timeout")); err != nil {
return err
}
// Data
cmd.PersistentFlags().Bool("storage.enabled", true, "whether storage is enabled, where peristent containers data will be stored")
@@ -207,6 +213,7 @@ func (s *Room) Set() {
s.PathPrefix = path.Join("/", path.Clean(viper.GetString("path_prefix")))
s.Labels = viper.GetStringSlice("labels")
s.WaitEnabled = viper.GetBool("wait_enabled")
s.StopTimeoutSec = viper.GetInt("stop_timeout")
s.StorageEnabled = viper.GetBool("storage.enabled")
s.StorageInternal = viper.GetString("storage.internal")
+4 -4
View File
@@ -14,7 +14,7 @@ import (
var policiesJson string
func Generate(policies types.BrowserPolicyContent) (string, error) {
policiesTmpl := map[string]interface{}{}
policiesTmpl := map[string]any{}
if err := json.Unmarshal([]byte(policiesJson), &policiesTmpl); err != nil {
return "", err
}
@@ -23,7 +23,7 @@ func Generate(policies types.BrowserPolicyContent) (string, error) {
// Extensions
//
ExtensionInstallForcelist := []interface{}{}
ExtensionInstallForcelist := []any{}
for _, e := range policies.Extensions {
URL := e.URL
if URL == "" {
@@ -36,7 +36,7 @@ func Generate(policies types.BrowserPolicyContent) (string, error) {
)
}
ExtensionInstallAllowlist := []interface{}{}
ExtensionInstallAllowlist := []any{}
for _, e := range policies.Extensions {
ExtensionInstallAllowlist = append(
ExtensionInstallAllowlist,
@@ -46,7 +46,7 @@ func Generate(policies types.BrowserPolicyContent) (string, error) {
policiesTmpl["ExtensionInstallForcelist"] = ExtensionInstallForcelist
policiesTmpl["ExtensionInstallAllowlist"] = ExtensionInstallAllowlist
policiesTmpl["ExtensionInstallBlocklist"] = []interface{}{"*"}
policiesTmpl["ExtensionInstallBlocklist"] = []any{"*"}
//
// Developer Tools
+2 -2
View File
@@ -12,7 +12,7 @@ import (
func Parse(policiesJson string) (*types.BrowserPolicyContent, error) {
policies := types.BrowserPolicyContent{}
policiesTmpl := map[string]interface{}{}
policiesTmpl := map[string]any{}
if err := json.Unmarshal([]byte(policiesJson), &policiesTmpl); err != nil {
return nil, err
}
@@ -23,7 +23,7 @@ func Parse(policiesJson string) (*types.BrowserPolicyContent, error) {
if extensions, ok := policiesTmpl["ExtensionInstallForcelist"]; ok {
policies.Extensions = []types.BrowserPolicyExtension{}
for _, val := range extensions.([]interface{}) {
for _, val := range extensions.([]any) {
s := strings.Split(val.(string), ";")
url := ""
if len(s) > 1 {
+7 -7
View File
@@ -14,7 +14,7 @@ var policiesJson string
func Generate(policies types.BrowserPolicyContent) (string, error) {
policiesTmpl := struct {
Policies map[string]interface{} `json:"policies"`
Policies map[string]any `json:"policies"`
}{}
if err := json.Unmarshal([]byte(policiesJson), &policiesTmpl); err != nil {
return "", err
@@ -24,13 +24,13 @@ func Generate(policies types.BrowserPolicyContent) (string, error) {
// Extensions
//
ExtensionSettings := map[string]interface{}{}
ExtensionSettings["*"] = map[string]interface{}{
ExtensionSettings := map[string]any{}
ExtensionSettings["*"] = map[string]any{
"installation_mode": "blocked",
}
for _, e := range policies.Extensions {
ExtensionSettings[e.ID] = map[string]interface{}{
ExtensionSettings[e.ID] = map[string]any{
"install_url": e.URL,
"installation_mode": "force_installed",
}
@@ -48,18 +48,18 @@ func Generate(policies types.BrowserPolicyContent) (string, error) {
// Persistent Data
//
Preferences := policiesTmpl.Policies["Preferences"].(map[string]interface{})
Preferences := policiesTmpl.Policies["Preferences"].(map[string]any)
Preferences["browser.urlbar.suggest.history"] = policies.PersistentData
Preferences["places.history.enabled"] = policies.PersistentData
policiesTmpl.Policies["Preferences"] = Preferences
policiesTmpl.Policies["SanitizeOnShutdown"] = !policies.PersistentData
if policies.PersistentData {
policiesTmpl.Policies["Homepage"] = map[string]interface{}{
policiesTmpl.Policies["Homepage"] = map[string]any{
"StartPage": "previous-session",
}
} else {
policiesTmpl.Policies["Homepage"] = map[string]interface{}{
policiesTmpl.Policies["Homepage"] = map[string]any{
"StartPage": "homepage",
}
}
+3 -3
View File
@@ -13,7 +13,7 @@ func Parse(policiesJson string) (*types.BrowserPolicyContent, error) {
policies := types.BrowserPolicyContent{}
policiesTmpl := struct {
Policies map[string]interface{} `json:"policies"`
Policies map[string]any `json:"policies"`
}{}
if err := json.Unmarshal([]byte(policiesJson), &policiesTmpl); err != nil {
return nil, err
@@ -30,12 +30,12 @@ func Parse(policiesJson string) (*types.BrowserPolicyContent, error) {
if extensions, ok := policiesTmpl.Policies["ExtensionSettings"]; ok {
policies.Extensions = []types.BrowserPolicyExtension{}
for id, val := range extensions.(map[string]interface{}) {
for id, val := range extensions.(map[string]any) {
if id == "*" {
continue
}
data := val.(map[string]interface{})
data := val.(map[string]any)
url, _ := data["install_url"].(string)
policies.Extensions = append(
+11 -1
View File
@@ -59,6 +59,8 @@ func RoomNotFound(w http.ResponseWriter, r *http.Request, waitEnabled bool) {
if waitEnabled {
roomWait(w, r)
} else {
w.Write([]byte(`<meta http-equiv="refresh" content="60">`))
}
}
@@ -81,10 +83,12 @@ func RoomNotRunning(w http.ResponseWriter, r *http.Request, waitEnabled bool) {
if waitEnabled {
roomWait(w, r)
} else {
w.Write([]byte(`<meta http-equiv="refresh" content="10">`))
}
}
func RoomNotReady(w http.ResponseWriter, r *http.Request) {
func RoomNotReady(w http.ResponseWriter, r *http.Request, waitEnabled bool) {
utils.Swal2Response(w, `
<meta http-equiv="refresh" content="2">
@@ -102,6 +106,12 @@ func RoomNotReady(w http.ResponseWriter, r *http.Request) {
<button type="button" onclick="location = location" class="swal2-confirm swal2-styled" style="margin-top: 1.25em">Reload</button>
</div>
`)
if waitEnabled {
roomWait(w, r)
} else {
w.Write([]byte(`<meta http-equiv="refresh" content="2">`))
}
}
func RoomReady(w http.ResponseWriter, r *http.Request) {
+68 -152
View File
@@ -2,7 +2,6 @@ package proxy
import (
"context"
"fmt"
"net/http"
"net/http/httputil"
"net/url"
@@ -11,19 +10,19 @@ import (
"strings"
"sync"
dockerTypes "github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/filters"
dockerClient "github.com/docker/docker/client"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/m1k1o/neko-rooms/internal/room"
"github.com/m1k1o/neko-rooms/internal/types"
"github.com/m1k1o/neko-rooms/pkg/prefix"
)
type entry struct {
id string
running bool
handler *httputil.ReverseProxy
ready bool
handler http.Handler
}
type wait struct {
@@ -41,20 +40,18 @@ type ProxyManagerCtx struct {
waitChans map[string]*wait
waitEnabled bool
client *dockerClient.Client
instanceName string
handlers prefix.Tree[*entry]
rooms *room.RoomManagerCtx
handlers prefix.Tree[*entry]
}
func New(client *dockerClient.Client, instanceName string, waitEnabled bool) *ProxyManagerCtx {
func New(rooms *room.RoomManagerCtx, waitEnabled bool) *ProxyManagerCtx {
return &ProxyManagerCtx{
logger: log.With().Str("module", "proxy").Logger(),
waitChans: map[string]*wait{},
client: client,
instanceName: instanceName,
waitEnabled: waitEnabled,
handlers: prefix.NewTree[*entry](),
rooms: rooms,
waitEnabled: waitEnabled,
handlers: prefix.NewTree[*entry](),
}
}
@@ -67,48 +64,32 @@ func (p *ProxyManagerCtx) Start() {
p.logger.Err(err).Msg("unable to refresh containers")
}
msgs, errs := p.client.Events(p.ctx, dockerTypes.EventsOptions{
Filters: filters.NewArgs(
filters.Arg("type", "container"),
filters.Arg("label", fmt.Sprintf("m1k1o.neko_rooms.instance=%s", p.instanceName)),
filters.Arg("event", "create"),
filters.Arg("event", "start"),
//filters.Arg("event", "health_status"),
filters.Arg("event", "stop"),
filters.Arg("event", "destroy"),
),
})
msgs, errs := p.rooms.Events(p.ctx)
for {
select {
case err, ok := <-errs:
if !ok {
p.logger.Fatal().Msg("docker event error channel closed")
return
}
p.logger.Err(err).Msg("got docker event error")
p.logger.Err(err).Msg("room event error")
case msg, ok := <-msgs:
if !ok {
p.logger.Fatal().Msg("docker event channel closed")
return
}
enabled, path, port, ok := p.parseLabels(msg.Actor.Attributes)
enabled, path, port, ok := p.parseLabels(msg.ContainerLabels)
if !ok {
break
}
host := msg.ID[:12] + ":" + port
host := msg.ID + ":" + port
p.logger.Info().
Str("action", msg.Action).
Str("action", string(msg.Action)).
Str("path", path).
Str("host", host).
Msg("got docker event")
Msg("got room event")
// terminate waiting for any events
if p.waitEnabled {
// terminate waiting for room ready event
if p.waitEnabled && msg.Action == types.RoomEventReady {
p.waitMu.Lock()
ch, ok := p.waitChans[path]
if ok {
@@ -120,32 +101,36 @@ func (p *ProxyManagerCtx) Start() {
p.mu.Lock()
switch msg.Action {
case "create":
case types.RoomEventCreated:
p.handlers.Insert(path, &entry{
id: msg.ID,
running: false,
})
case "start":
case types.RoomEventStarted:
p.handlers.Insert(path, &entry{
id: msg.ID,
running: true,
ready: false,
})
case types.RoomEventReady:
e := &entry{
id: msg.ID,
running: true,
ready: true,
}
// if proxying is disabled
if enabled {
e.handler = httputil.NewSingleHostReverseProxy(&url.URL{
Scheme: "http",
Host: host,
})
e.handler = p.newProxyHandler(path, host)
}
p.handlers.Insert(path, e)
case "stop":
case types.RoomEventStopped:
p.handlers.Insert(path, &entry{
id: msg.ID,
running: false,
})
case "destroy":
case types.RoomEventDestroyed:
p.handlers.Remove(path)
}
p.mu.Unlock()
@@ -163,41 +148,30 @@ func (p *ProxyManagerCtx) Refresh() error {
p.mu.Lock()
defer p.mu.Unlock()
containers, err := p.client.ContainerList(p.ctx, dockerTypes.ContainerListOptions{
All: true,
Filters: filters.NewArgs(
filters.Arg("label", fmt.Sprintf("m1k1o.neko_rooms.instance=%s", p.instanceName)),
),
})
rooms, err := p.rooms.List(p.ctx, nil)
if err != nil {
return err
}
p.handlers = prefix.NewTree[*entry]()
for _, cont := range containers {
enabled, path, port, ok := p.parseLabels(cont.Labels)
for _, room := range rooms {
enabled, path, port, ok := p.parseLabels(room.ContainerLabels)
if !ok {
continue
}
host := cont.ID[:12] + ":" + port
host := room.ID + ":" + port
entry := &entry{
id: cont.ID,
id: room.ID,
running: room.Running,
ready: room.IsReady,
}
if cont.State == "running" {
entry.running = true
// if proxying is disabled
if enabled {
entry.handler = httputil.NewSingleHostReverseProxy(&url.URL{
Scheme: "http",
Host: host,
})
}
// if proxying is enabled and room is ready
if enabled && room.IsReady {
entry.handler = p.newProxyHandler(path, host)
}
p.handlers.Insert(path, entry)
@@ -207,41 +181,9 @@ func (p *ProxyManagerCtx) Refresh() error {
}
func (p *ProxyManagerCtx) parseLabels(labels map[string]string) (enabled bool, path string, port string, ok bool) {
enabledStr, found := labels["m1k1o.neko_rooms.proxy.enabled"]
if !found {
//
// workaround for legacy traefik labels
//
// get room name
var roomName string
roomName, ok = labels["m1k1o.neko_rooms.name"]
if !ok {
return
}
// get container name
containerName := p.instanceName + "-" + roomName
// get path
path, ok = labels["traefik.http.middlewares."+containerName+"-prf.stripprefix.prefixes"]
if !ok {
return
}
// remove last /
path = strings.TrimSuffix(path, "/")
// get port
port, ok = labels["traefik.http.services."+containerName+"-frontend.loadbalancer.server.port"]
if !ok {
return
}
//
// workaround for legacy traefik labels
//
var enabledStr string
enabledStr, ok = labels["m1k1o.neko_rooms.proxy.enabled"]
if !ok {
return
}
@@ -257,6 +199,18 @@ func (p *ProxyManagerCtx) parseLabels(labels map[string]string) (enabled bool, p
return
}
func (p *ProxyManagerCtx) newProxyHandler(prefix, host string) http.Handler {
handler := httputil.NewSingleHostReverseProxy(&url.URL{
Scheme: "http",
Host: host,
})
handler.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
p.logger.Err(err).Str("prefix", prefix).Msg("proxy error")
http.Error(w, "unable to connect to room", http.StatusBadGateway)
}
return http.StripPrefix(prefix, handler)
}
func (p *ProxyManagerCtx) waitForPath(w http.ResponseWriter, r *http.Request, path string) {
p.logger.Debug().Str("path", path).Msg("adding new wait handler")
@@ -299,15 +253,21 @@ func (p *ProxyManagerCtx) ServeHTTP(w http.ResponseWriter, r *http.Request) {
proxy, prefix, ok := p.handlers.Match(cleanPath)
p.mu.RUnlock()
// if room not found
if !ok {
// blocking until room is created
// if room is not ready
if !ok || !proxy.running || !proxy.ready {
// blocking until room is ready
if r.URL.Query().Has("wait") && p.waitEnabled {
p.waitForPath(w, r, cleanPath)
return
}
RoomNotFound(w, r, p.waitEnabled)
if !ok {
RoomNotFound(w, r, p.waitEnabled)
} else if !proxy.running {
RoomNotRunning(w, r, p.waitEnabled)
} else {
RoomNotReady(w, r, p.waitEnabled)
}
return
}
@@ -318,56 +278,12 @@ func (p *ProxyManagerCtx) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
// if room not running
if !proxy.running {
// blocking until room is running
if r.URL.Query().Has("wait") && p.waitEnabled {
p.waitForPath(w, r, cleanPath)
return
}
RoomNotRunning(w, r, p.waitEnabled)
return
}
// if not proxying, check for container readiness status
// if not proxying, just return room ready
if proxy.handler == nil {
containers, err := p.client.ContainerList(p.ctx, dockerTypes.ContainerListOptions{
All: true,
Filters: filters.NewArgs(
filters.Arg("label", fmt.Sprintf("m1k1o.neko_rooms.instance=%s", p.instanceName)),
filters.Arg("id", proxy.id),
),
})
if err != nil || len(containers) != 1 {
p.logger.Err(err).Msg("error while getting container ready status")
RoomNotReady(w, r)
return
}
container := containers[0]
if strings.Contains(container.Status, "starting") {
RoomNotReady(w, r)
} else {
RoomReady(w, r)
}
RoomReady(w, r)
return
}
// handle not ready room
proxy.handler.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
if r.URL.Path == "/" {
RoomNotReady(w, r)
}
p.logger.Err(err).Str("prefix", prefix).Msg("proxying error")
}
// strip prefix from proxy
handler := http.StripPrefix(prefix, proxy.handler)
// handle by proxy
handler.ServeHTTP(w, r)
proxy.handler.ServeHTTP(w, r)
}
+7 -1
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"io"
"strings"
"time"
dockerTypes "github.com/docker/docker/api/types"
@@ -19,17 +20,22 @@ func (manager *RoomManagerCtx) containerToEntry(container dockerTypes.Container)
return nil, err
}
roomId := container.ID[:12]
entry := &types.RoomEntry{
ID: container.ID[:12],
ID: roomId,
URL: labels.URL,
Name: labels.Name,
NekoImage: labels.NekoImage,
IsOutdated: labels.NekoImage != container.Image,
MaxConnections: labels.Epr.Max - labels.Epr.Min + 1,
Running: container.State == "running",
IsReady: manager.events.IsRoomReady(roomId) || strings.Contains(container.Status, "healthy"),
Status: container.Status,
Created: time.Unix(container.Created, 0),
Labels: labels.UserDefined,
ContainerLabels: container.Labels,
}
if labels.Mux {
+263
View File
@@ -0,0 +1,263 @@
package room
import (
"context"
"fmt"
"io"
"strings"
"sync"
"github.com/m1k1o/neko-rooms/internal/config"
"github.com/m1k1o/neko-rooms/internal/types"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
dockerTypes "github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/filters"
dockerClient "github.com/docker/docker/client"
)
type roomReady struct {
id string
labels map[string]string
}
type events struct {
wg sync.WaitGroup
logger zerolog.Logger
config *config.Room
client *dockerClient.Client
roomsReadyCh chan roomReady
roomsReadyMu sync.Mutex
roomsReady map[string]struct{}
ctx context.Context
cancel context.CancelFunc
listeners []chan types.RoomEvent
listenersMu sync.Mutex
}
func newEvents(config *config.Room, client *dockerClient.Client) *events {
return &events{
logger: log.With().Str("module", "events").Logger(),
config: config,
client: client,
roomsReadyCh: make(chan roomReady),
roomsReady: make(map[string]struct{}),
}
}
func (e *events) Start() {
e.ctx, e.cancel = context.WithCancel(context.Background())
msgs, errs := e.client.Events(e.ctx, dockerTypes.EventsOptions{
Filters: filters.NewArgs(
filters.Arg("type", "container"),
filters.Arg("label", fmt.Sprintf("m1k1o.neko_rooms.instance=%s", e.config.InstanceName)),
filters.Arg("event", "create"),
filters.Arg("event", "start"),
filters.Arg("event", "health_status"),
filters.Arg("event", "stop"),
filters.Arg("event", "destroy"),
),
})
e.wg.Add(1)
go func() {
defer e.wg.Done()
for {
select {
case <-e.ctx.Done():
e.logger.Info().Msg("docker event context closed")
return
case err, ok := <-errs:
if !ok {
e.logger.Error().Msg("docker event error channel closed")
return
}
e.logger.Err(err).Msg("got docker event error")
return
case room := <-e.roomsReadyCh:
// ignore if room was already ready
if !e.setRoomReady(room.id) {
continue
}
e.broadcast(types.RoomEvent{
ID: room.id,
Action: types.RoomEventReady,
ContainerLabels: room.labels,
})
case msg := <-msgs:
roomId := msg.Actor.ID[:12]
labels := msg.Actor.Attributes
e.logger.Debug().
Str("id", roomId).
Str("action", msg.Action).
Msg("got docker event")
var action types.RoomEventAction
switch msg.Action {
case "create":
action = types.RoomEventCreated
case "start":
action = types.RoomEventStarted
e.waitForRoomReady(roomId, labels)
case "health_status: healthy":
action = types.RoomEventReady
// ignore if room was already ready
if !e.setRoomReady(roomId) {
continue
}
case "stop":
action = types.RoomEventStopped
e.setRoomNotReady(roomId)
case "destroy":
action = types.RoomEventDestroyed
}
e.broadcast(types.RoomEvent{
ID: roomId,
Action: action,
ContainerLabels: labels,
})
}
}
}()
}
func (e *events) Shutdown() error {
e.cancel()
close(e.roomsReadyCh)
e.wg.Wait()
return nil
}
//
// room ready
//
func (e *events) waitForRoomReady(roomId string, labels map[string]string) {
e.wg.Add(1)
go func() {
defer e.wg.Done()
// check if room is ready
exec, err := e.client.ContainerExecCreate(e.ctx, roomId, dockerTypes.ExecConfig{
AttachStdout: true,
Cmd: []string{
"/bin/bash", "-c",
fmt.Sprintf(`for ((a=1; a<=5; a++)); do (echo > /dev/tcp/localhost/%d) >/dev/null && echo -n OK && exit; sleep 1; done; exit`, frontendPort),
},
})
if err != nil {
e.logger.Err(err).Msg("failed to create exec")
return
}
conn, err := e.client.ContainerExecAttach(e.ctx, exec.ID, dockerTypes.ExecStartCheck{})
if err != nil {
e.logger.Err(err).Msg("failed to attach exec")
return
}
defer conn.Close()
data, err := io.ReadAll(conn.Reader)
if err != nil {
e.logger.Err(err).Msg("failed to read exec")
return
}
if strings.HasSuffix(string(data), "OK") {
e.logger.Debug().Str("id", roomId).Msg("room ready")
e.roomsReadyCh <- roomReady{
id: roomId,
labels: labels,
}
return
}
e.logger.Warn().Str("id", roomId).Str("data", string(data)).Msg("room not ready")
}()
}
func (e *events) setRoomReady(roomId string) bool {
e.roomsReadyMu.Lock()
defer e.roomsReadyMu.Unlock()
_, ok := e.roomsReady[roomId]
e.roomsReady[roomId] = struct{}{}
return !ok
}
func (e *events) setRoomNotReady(roomId string) {
e.roomsReadyMu.Lock()
defer e.roomsReadyMu.Unlock()
delete(e.roomsReady, roomId)
}
func (e *events) IsRoomReady(roomId string) bool {
e.roomsReadyMu.Lock()
defer e.roomsReadyMu.Unlock()
_, ok := e.roomsReady[roomId]
return ok
}
//
// events
//
func (e *events) broadcast(event types.RoomEvent) {
e.listenersMu.Lock()
for _, listener := range e.listeners {
listener <- event
}
e.listenersMu.Unlock()
}
func (e *events) Events(ctx context.Context) (<-chan types.RoomEvent, <-chan error) {
messages := make(chan types.RoomEvent)
errs := make(chan error, 1)
// add listener
e.listenersMu.Lock()
e.listeners = append(e.listeners, messages)
e.listenersMu.Unlock()
e.wg.Add(1)
go func() {
defer e.wg.Done()
defer close(errs)
select {
case <-ctx.Done():
errs <- ctx.Err()
case <-e.ctx.Done():
errs <- fmt.Errorf("room events shutdown")
return
}
// remove listener
e.listenersMu.Lock()
for i, listener := range e.listeners {
if listener == messages {
e.listeners = append(e.listeners[:i], e.listeners[i+1:]...)
break
}
}
e.listenersMu.Unlock()
}()
return messages, errs
}
+28 -3
View File
@@ -48,6 +48,7 @@ func New(client *dockerClient.Client, config *config.Room) *RoomManagerCtx {
logger: logger,
config: config,
client: client,
events: newEvents(config, client),
}
}
@@ -55,6 +56,7 @@ type RoomManagerCtx struct {
logger zerolog.Logger
config *config.Room
client *dockerClient.Client
events *events
}
func (manager *RoomManagerCtx) Config() types.RoomsConfig {
@@ -722,7 +724,10 @@ func (manager *RoomManagerCtx) Remove(ctx context.Context, id string) error {
}
// Stop the actual container
err = manager.client.ContainerStop(ctx, id, container.StopOptions{})
err = manager.client.ContainerStop(ctx, id, container.StopOptions{
Signal: "SIGTERM",
Timeout: &manager.config.StopTimeoutSec,
})
if err != nil {
return err
@@ -996,7 +1001,10 @@ func (manager *RoomManagerCtx) Stop(ctx context.Context, id string) error {
}
// Stop the actual container
return manager.client.ContainerStop(ctx, id, container.StopOptions{})
return manager.client.ContainerStop(ctx, id, container.StopOptions{
Signal: "SIGTERM",
Timeout: &manager.config.StopTimeoutSec,
})
}
func (manager *RoomManagerCtx) Restart(ctx context.Context, id string) error {
@@ -1006,5 +1014,22 @@ func (manager *RoomManagerCtx) Restart(ctx context.Context, id string) error {
}
// Restart the actual container
return manager.client.ContainerRestart(ctx, id, container.StopOptions{})
return manager.client.ContainerRestart(ctx, id, container.StopOptions{
Signal: "SIGTERM",
Timeout: &manager.config.StopTimeoutSec,
})
}
// events
func (manager *RoomManagerCtx) EventsLoopStart() {
manager.events.Start()
}
func (manager *RoomManagerCtx) EventsLoopStop() error {
return manager.events.Shutdown()
}
func (manager *RoomManagerCtx) Events(ctx context.Context) (<-chan types.RoomEvent, <-chan error) {
return manager.events.Events(ctx)
}
-33
View File
@@ -1,33 +0,0 @@
package server
import (
"net/http"
"net/http/pprof"
"github.com/go-chi/chi/v5"
)
const pprofPath = "/debug/pprof/"
func withPProf(router *chi.Mux) {
router.Route(pprofPath, func(r chi.Router) {
r.Get("/", pprof.Index)
r.Get("/{action}", func(w http.ResponseWriter, r *http.Request) {
action := chi.URLParam(r, "action")
switch action {
case "cmdline":
pprof.Cmdline(w, r)
case "profile":
pprof.Profile(w, r)
case "symbol":
pprof.Symbol(w, r)
case "trace":
pprof.Trace(w, r)
default:
pprof.Handler(action).ServeHTTP(w, r)
}
})
})
}
+8 -8
View File
@@ -14,7 +14,7 @@ type logformatter struct {
}
func (l *logformatter) NewLogEntry(r *http.Request) middleware.LogEntry {
req := map[string]interface{}{}
req := map[string]any{}
if reqID := middleware.GetReqID(r.Context()); reqID != "" {
req["id"] = reqID
@@ -32,7 +32,7 @@ func (l *logformatter) NewLogEntry(r *http.Request) middleware.LogEntry {
req["agent"] = r.UserAgent()
req["uri"] = fmt.Sprintf("%s://%s%s", scheme, r.Host, r.RequestURI)
fields := map[string]interface{}{}
fields := map[string]any{}
fields["req"] = req
return &logentry{
@@ -43,12 +43,12 @@ func (l *logformatter) NewLogEntry(r *http.Request) middleware.LogEntry {
type logentry struct {
logger zerolog.Logger
fields map[string]interface{}
errors []map[string]interface{}
fields map[string]any
errors []map[string]any
}
func (e *logentry) Write(status, bytes int, header http.Header, elapsed time.Duration, extra interface{}) {
res := map[string]interface{}{}
func (e *logentry) Write(status, bytes int, header http.Header, elapsed time.Duration, extra any) {
res := map[string]any{}
res["time"] = time.Now().UTC().Format(time.RFC1123)
res["status"] = status
res["bytes"] = bytes
@@ -65,8 +65,8 @@ func (e *logentry) Write(status, bytes int, header http.Header, elapsed time.Dur
}
}
func (e *logentry) Panic(v interface{}, stack []byte) {
err := map[string]interface{}{}
func (e *logentry) Panic(v any, stack []byte) {
err := map[string]any{}
err["message"] = fmt.Sprintf("%+v", v)
err["stack"] = string(stack)
+6 -6
View File
@@ -58,12 +58,6 @@ func New(ApiManager types.ApiManager, roomConfig *config.Room, config *config.Se
}))
}
// mount pprof endpoint
if config.PProf {
withPProf(router)
logger.Info().Msgf("with pprof endpoint at %s", pprofPath)
}
//
// admin page
//
@@ -240,6 +234,12 @@ func New(ApiManager types.ApiManager, roomConfig *config.Room, config *config.Se
})
}
// mount pprof endpoint
if config.PProf {
router.Mount("/debug", middleware.Profiler())
logger.Info().Msgf("with pprof endpoint")
}
// handle all remaining paths with proxy
router.Handle("/*", proxyHandler)
+24
View File
@@ -23,9 +23,12 @@ type RoomEntry struct {
IsOutdated bool `json:"is_outdated"`
MaxConnections uint16 `json:"max_connections"` // 0 when using mux
Running bool `json:"running"`
IsReady bool `json:"is_ready"`
Status string `json:"status"`
Created time.Time `json:"created"`
Labels map[string]string `json:"labels,omitempty"`
ContainerLabels map[string]string `json:"-"` // for internal use
}
type MountType string
@@ -138,6 +141,23 @@ type RoomMember struct {
Muted bool `json:"muted"`
}
type RoomEventAction string
const (
RoomEventCreated RoomEventAction = "created"
RoomEventStarted RoomEventAction = "started"
RoomEventReady RoomEventAction = "ready"
RoomEventStopped RoomEventAction = "stopped"
RoomEventDestroyed RoomEventAction = "destroyed"
)
type RoomEvent struct {
ID string `json:"id"`
Action RoomEventAction `json:"action"`
ContainerLabels map[string]string `json:"-"` // for internal use
}
var ErrRoomNotFound = fmt.Errorf("room not found")
type RoomManager interface {
@@ -155,4 +175,8 @@ type RoomManager interface {
Start(ctx context.Context, id string) error
Stop(ctx context.Context, id string) error
Restart(ctx context.Context, id string) error
EventsLoopStart()
EventsLoopStop() error
Events(ctx context.Context) (<-chan RoomEvent, <-chan error)
}
+1 -1
View File
@@ -29,6 +29,6 @@ func Color(str string) string {
return result + str[lastIndex:]
}
func Colorf(format string, a ...interface{}) string {
func Colorf(format string, a ...any) string {
return fmt.Sprintf(Color(format), a...)
}
+1 -1
View File
@@ -17,7 +17,7 @@ func Swal2Response(w http.ResponseWriter, body string) {
http.Error(w, err.Error(), 500)
}
err = tmpl.Execute(w, map[string]interface{}{
err = tmpl.Execute(w, map[string]any{
"Body": template.HTML(body),
})
+5 -2
View File
@@ -131,6 +131,7 @@ func (main *MainCtx) Start() {
client,
main.Configs.Room,
)
main.roomManager.EventsLoopStart()
main.pullManager = pull.New(
client,
@@ -143,8 +144,7 @@ func (main *MainCtx) Start() {
)
main.proxyManager = proxy.New(
client,
main.Configs.Room.InstanceName,
main.roomManager,
main.Configs.Room.WaitEnabled,
)
main.proxyManager.Start()
@@ -169,6 +169,9 @@ func (main *MainCtx) Shutdown() {
err = main.pullManager.Shutdown()
main.logger.Err(err).Msg("pull manager shutdown")
err = main.roomManager.EventsLoopStop()
main.logger.Err(err).Msg("room events loop shutdown")
}
func (main *MainCtx) ServeCommand(cmd *cobra.Command, args []string) {