feat: Update website, API, and infra (#164)

>Adds `maitred` in charge of handling automated game installs, updates,
and even execution.

>Not only that, we have the hosted stuff here
>- [x] AWS Task on ECS GPUs
>- [ ] Add a service to listen for game starts and stops
(docker-compose.yml)
>- [x] Add a queue for requesting a game to start
>- [x] Fix up the play/watch UI 

>TODO:
>- Add a README
>- Add an SST docs

Edit:

- This adds a new landing page, updates the homepage etc etc
>I forgot what the rest of the updated stuff are 😅
This commit is contained in:
Wanjohi
2025-02-11 12:26:35 +03:00
committed by GitHub
parent 93327bdf1a
commit 060718d8b0
139 changed files with 5814 additions and 5049 deletions

23
packages/maitred/go.mod Normal file
View File

@@ -0,0 +1,23 @@
module nestri/maitred
go 1.23.3
require github.com/eclipse/paho.golang v0.22.0
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/lipgloss v0.10.0 // indirect
github.com/charmbracelet/log v0.4.0 // indirect
github.com/go-logfmt/logfmt v0.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.18 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.15.2 // indirect
github.com/oklog/ulid/v2 v2.1.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
golang.org/x/net v0.27.0 // indirect
golang.org/x/sys v0.22.0 // indirect
)

49
packages/maitred/go.sum Normal file
View File

@@ -0,0 +1,49 @@
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/lipgloss v0.10.0 h1:KWeXFSexGcfahHX+54URiZGkBFazf70JNMtwg/AFW3s=
github.com/charmbracelet/lipgloss v0.10.0/go.mod h1:Wig9DSfvANsxqkRsqj6x87irdy123SR4dOXlKa91ciE=
github.com/charmbracelet/log v0.4.0 h1:G9bQAcx8rWA2T3pWvx7YtPTPwgqpk7D68BX21IRW8ZM=
github.com/charmbracelet/log v0.4.0/go.mod h1:63bXt/djrizTec0l11H20t8FDSvA4CRZJ1KH22MdptM=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/eclipse/paho.golang v0.22.0 h1:JhhUngr8TBlyUZDZw/L6WVayPi9qmSmdWeki48i5AVE=
github.com/eclipse/paho.golang v0.22.0/go.mod h1:9ZiYJ93iEfGRJri8tErNeStPKLXIGBHiqbHV74t5pqI=
github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98=
github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
github.com/oklog/ulid/v2 v2.1.0 h1:+9lhoxAP56we25tyYETBBY1YLA2SaoLvUFgrP2miPJU=
github.com/oklog/ulid/v2 v2.1.0/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
go.uber.org/goleak v1.2.1 h1:NBol2c7O1ZokfZ0LEU9K6Whx/KnwvepVetCUhtKja4A=
go.uber.org/goleak v1.2.1/go.mod h1:qlT2yGI9QafXHhZZLxlSuNsMw3FFLxBr+tBRlmO1xH4=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

21
packages/maitred/main.go Normal file
View File

@@ -0,0 +1,21 @@
package main
import (
"nestri/maitred/pkg/party"
"os"
"github.com/charmbracelet/log"
)
func main() {
var teamSlug string //FIXME: Switch to team-slug as they are more memorable but still unique
if len(os.Args) > 1 {
teamSlug = os.Args[1]
} else {
log.Fatal("Nestri needs a team slug to register this container to")
}
party.Run(teamSlug)
//TODO: On stop here, set the API as the instance is not running (stopped)
}

View File

@@ -0,0 +1,58 @@
package auth
import (
"encoding/json"
"fmt"
"io"
"nestri/maitred/pkg/resource"
"net/http"
"net/url"
"os"
"os/exec"
"github.com/charmbracelet/log"
)
type UserCredentials struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
}
func FetchUserToken(teamSlug string) (*UserCredentials, error) {
hostname, err := os.Hostname()
if err != nil {
log.Fatal("Could not get the hostname")
}
data := url.Values{}
data.Set("grant_type", "client_credentials")
data.Set("client_id", "device")
data.Set("client_secret", resource.Resource.AuthFingerprintKey.Value)
data.Set("team", teamSlug)
data.Set("hostname", hostname)
data.Set("provider", "device")
resp, err := http.PostForm(resource.Resource.Auth.Url+"/token", data)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
fmt.Println(string(body))
return nil, fmt.Errorf("failed to auth: " + string(body))
}
credentials := UserCredentials{}
err = json.NewDecoder(resp.Body).Decode(&credentials)
if err != nil {
return nil, err
}
return &credentials, nil
}
func GetHostname() string {
cmd, err := exec.Command("cat", "/etc/hostname").Output()
if err != nil {
log.Error("error getting container hostname", "err", err)
}
output := string(cmd)
return output
}

View File

@@ -0,0 +1,27 @@
package party
import (
"github.com/charmbracelet/log"
)
// logger implements the paho.Logger interface
type logger struct {
prefix string
}
// Println is the library provided NOOPLogger's
// implementation of the required interface function()
func (l logger) Println(v ...interface{}) {
// fmt.Println(append([]interface{}{l.prefix + ":"}, v...)...)
log.Info(l.prefix, "info", v)
}
// Printf is the library provided NOOPLogger's
// implementation of the required interface function(){}
func (l logger) Printf(format string, v ...interface{}) {
// if len(format) > 0 && format[len(format)-1] != '\n' {
// format = format + "\n" // some log calls in paho do not add \n
// }
// fmt.Printf(l.prefix+":"+format, v...)
log.Info(l.prefix, "info", v)
}

View File

@@ -0,0 +1,129 @@
package party
import (
"context"
"fmt"
"nestri/maitred/pkg/auth"
"nestri/maitred/pkg/resource"
"net/url"
"os"
"os/signal"
"syscall"
"time"
"github.com/charmbracelet/log"
"github.com/eclipse/paho.golang/autopaho"
"github.com/eclipse/paho.golang/paho"
)
func Run(teamSlug string) {
var topic = fmt.Sprintf("%s/%s/%s", resource.Resource.App.Name, resource.Resource.App.Stage, teamSlug)
var serverURL = fmt.Sprintf("wss://%s/mqtt?x-amz-customauthorizer-name=%s", resource.Resource.Party.Endpoint, resource.Resource.Party.Authorizer)
var clientID = generateClientID()
hostname, err := os.Hostname()
if err != nil {
log.Fatal(" Could not get the hostname")
}
// App will run until cancelled by user (e.g. ctrl-c)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
userTokens, err := auth.FetchUserToken(teamSlug)
if err != nil {
log.Error("Error trying to request for credentials", "err", err)
stop()
}
// We will connect to the Eclipse test server (note that you may see messages that other users publish)
u, err := url.Parse(serverURL)
if err != nil {
panic(err)
}
router := paho.NewStandardRouter()
router.DefaultHandler(func(p *paho.Publish) {
infoLogger.Info("Router", "info", fmt.Sprintf("default handler received message with topic: %s\n", p.Topic))
})
cliCfg := autopaho.ClientConfig{
ServerUrls: []*url.URL{u},
ConnectUsername: "", // Must be empty for the authorizer
ConnectPassword: []byte(userTokens.AccessToken),
KeepAlive: 20, // Keepalive message should be sent every 20 seconds
// We don't want the broker to delete any session info when we disconnect
CleanStartOnInitialConnection: true,
SessionExpiryInterval: 60, // Session remains live 60 seconds after disconnect
ReconnectBackoff: autopaho.NewConstantBackoff(time.Second),
OnConnectionUp: func(cm *autopaho.ConnectionManager, connAck *paho.Connack) {
infoLogger.Info("Router", "info", "MQTT connection is up and running")
if _, err := cm.Subscribe(context.Background(), &paho.Subscribe{
Subscriptions: []paho.SubscribeOptions{
{Topic: fmt.Sprintf("%s/#", topic), QoS: 1}, //Listen to all messages from this team
},
}); err != nil {
panic(fmt.Sprintf("failed to subscribe (%s). This is likely to mean no messages will be received.", err))
}
},
Errors: logger{prefix: "subscribe"},
OnConnectError: func(err error) {
infoLogger.Error("Router", "err", fmt.Sprintf("error whilst attempting connection: %s\n", err))
},
// eclipse/paho.golang/paho provides base mqtt functionality, the below config will be passed in for each connection
ClientConfig: paho.ClientConfig{
// If you are using QOS 1/2, then it's important to specify a client id (which must be unique)
ClientID: clientID,
// OnPublishReceived is a slice of functions that will be called when a message is received.
// You can write the function(s) yourself or use the supplied Router
OnPublishReceived: []func(paho.PublishReceived) (bool, error){
func(pr paho.PublishReceived) (bool, error) {
router.Route(pr.Packet.Packet())
return true, nil // we assume that the router handles all messages (todo: amend router API)
}},
OnClientError: func(err error) { infoLogger.Error("Router", "err", fmt.Sprintf("client error: %s\n", err)) },
OnServerDisconnect: func(d *paho.Disconnect) {
if d.Properties != nil {
infoLogger.Info("Router", "info", fmt.Sprintf("server requested disconnect: %s\n", d.Properties.ReasonString))
} else {
infoLogger.Info("Router", "info", fmt.Sprintf("server requested disconnect; reason code: %d\n", d.ReasonCode))
}
},
},
}
c, err := autopaho.NewConnection(ctx, cliCfg) // starts process; will reconnect until context cancelled
if err != nil {
panic(err)
}
if err = c.AwaitConnection(ctx); err != nil {
panic(err)
}
// Handlers can be registered/deregistered at any time. It's important to note that you need to subscribe AND create
// a handler
//TODO: Have different routes for different things, like starting a session, stopping a session, and stopping the container altogether
//TODO: Listen on team-slug/container-hostname topic only
router.RegisterHandler(fmt.Sprintf("%s/%s/start", topic, hostname), func(p *paho.Publish) {
infoLogger.Info("Router", "info", fmt.Sprintf("start a game: %s\n", p.Topic))
})
router.RegisterHandler(fmt.Sprintf("%s/%s/stop", topic, hostname), func(p *paho.Publish) { fmt.Printf("stop the game that is running: %s\n", p.Topic) })
router.RegisterHandler(fmt.Sprintf("%s/%s/download", topic, hostname), func(p *paho.Publish) { fmt.Printf("download a game: %s\n", p.Topic) })
router.RegisterHandler(fmt.Sprintf("%s/%s/quit", topic, hostname), func(p *paho.Publish) { stop() }) // Stop and quit this running container
// We publish three messages to test out the various route handlers
// topics := []string{"test/test", "test/test/foo", "test/xxNoMatch", "test/quit"}
// for _, t := range topics {
// if _, err := c.Publish(ctx, &paho.Publish{
// QoS: 1,
// Topic: fmt.Sprintf("%s/%s", topic, t),
// Payload: []byte("TestMessage on topic: " + t),
// }); err != nil {
// if ctx.Err() == nil {
// panic(err) // Publish will exit when context cancelled or if something went wrong
// }
// }
// }
<-c.Done() // Wait for clean shutdown (cancelling the context triggered the shutdown)
}

View File

@@ -0,0 +1,31 @@
package party
import (
"fmt"
"os"
"time"
"math/rand"
"github.com/charmbracelet/log"
"github.com/oklog/ulid/v2"
)
var (
infoLogger = log.NewWithOptions(os.Stderr, log.Options{
ReportTimestamp: true,
TimeFormat: time.Kitchen,
// Prefix: "Realtime",
})
)
func generateClientID() string {
// Create a source of entropy (use cryptographically secure randomness in production)
entropy := rand.New(rand.NewSource(time.Now().UnixNano()))
// Generate a new ULID
id := ulid.MustNew(ulid.Timestamp(time.Now()), entropy)
// Create the client ID string
return fmt.Sprintf("client_%s", id.String())
}

View File

@@ -0,0 +1,46 @@
package resource
import (
"encoding/json"
"fmt"
"os"
"reflect"
)
type resource struct {
Api struct {
Url string `json:"url"`
}
Auth struct {
Url string `json:"url"`
}
AuthFingerprintKey struct {
Value string `json:"value"`
}
Party struct {
Endpoint string `json:"endpoint"`
Authorizer string `json:"authorizer"`
}
App struct {
Name string `json:"name"`
Stage string `json:"stage"`
}
}
var Resource resource
func init() {
val := reflect.ValueOf(&Resource).Elem()
for i := 0; i < val.NumField(); i++ {
field := val.Field(i)
typeField := val.Type().Field(i)
envVarName := fmt.Sprintf("SST_RESOURCE_%s", typeField.Name)
envValue, exists := os.LookupEnv(envVarName)
if !exists {
panic(fmt.Sprintf("Environment variable %s is required", envVarName))
}
if err := json.Unmarshal([]byte(envValue), field.Addr().Interface()); err != nil {
panic(err)
}
}
}