blob: 858afa8906e86ee68636d8329029ae6cdff53570 [file] [log] [blame]
package dodoapp
import (
"bytes"
"context"
"embed"
"encoding/json"
"errors"
"fmt"
"html/template"
"io"
"io/fs"
"net/http"
"slices"
"sort"
"strconv"
"strings"
"sync"
"time"
"golang.org/x/crypto/bcrypt"
"golang.org/x/exp/rand"
"github.com/giolekva/pcloud/core/installer"
"github.com/giolekva/pcloud/core/installer/server"
"github.com/giolekva/pcloud/core/installer/soft"
"github.com/giolekva/pcloud/core/installer/tasks"
"cuelang.org/go/cue"
"github.com/gorilla/mux"
"github.com/gorilla/securecookie"
)
//go:embed templates/*
var templates embed.FS
//go:embed all:app-templates
var appTmplsFS embed.FS
//go:embed static/*
var staticAssets embed.FS
//go:embed static/schemas/app.schema.json
var dodoAppJsonSchema []byte
const (
ConfigRepoName = "config"
appConfigsFile = "/apps.json"
loginPath = "/login"
logoutPath = "/logout"
staticPath = "/static/"
schemasPath = "/schemas/"
apiPublicData = "/api/public-data"
apiCreateApp = "/api/apps"
sessionCookie = "dodo-app-session"
userCtx = "user"
initCommitMsg = "init"
)
type tmplts struct {
index *template.Template
appStatus *template.Template
commitStatus *template.Template
logs *template.Template
}
func parseTemplates(fs embed.FS) (tmplts, error) {
base, err := template.ParseFS(fs, "templates/base.html")
if err != nil {
return tmplts{}, err
}
parse := func(path string) (*template.Template, error) {
if b, err := base.Clone(); err != nil {
return nil, err
} else {
return b.ParseFS(fs, path)
}
}
index, err := parse("templates/index.html")
if err != nil {
return tmplts{}, err
}
appStatus, err := parse("templates/app_status.html")
if err != nil {
return tmplts{}, err
}
commitStatus, err := parse("templates/commit_status.html")
if err != nil {
return tmplts{}, err
}
logs, err := parse("templates/logs.html")
if err != nil {
return tmplts{}, err
}
return tmplts{index, appStatus, commitStatus, logs}, nil
}
type Server struct {
l sync.Locker
st Store
nf NetworkFilter
ug UserGetter
port int
apiPort int
self string
selfPublic string
repoPublicAddr string
sshKey string
gitRepoPublicKey string
client soft.Client
namespace string
envAppManagerAddr string
env installer.EnvConfig
nsc installer.NamespaceCreator
jc installer.JobCreator
vpnKeyGen installer.VPNAPIClient
cnc installer.ClusterNetworkConfigurator
workers map[string]map[string]struct{}
appConfigs map[string]appConfig
tmplts tmplts
appTmpls AppTmplStore
external bool
fetchUsersAddr string
reconciler tasks.Reconciler
logs map[string]string
}
type appConfig struct {
Namespace string `json:"namespace"`
Network string `json:"network"`
}
// TODO(gio): Initialize appNs on startup
func NewServer(
st Store,
nf NetworkFilter,
ug UserGetter,
port int,
apiPort int,
self string,
selfPublic string,
repoPublicAddr string,
sshKey string,
gitRepoPublicKey string,
client soft.Client,
namespace string,
envAppManagerAddr string,
nsc installer.NamespaceCreator,
jc installer.JobCreator,
vpnKeyGen installer.VPNAPIClient,
cnc installer.ClusterNetworkConfigurator,
env installer.EnvConfig,
external bool,
fetchUsersAddr string,
reconciler tasks.Reconciler,
) (*Server, error) {
tmplts, err := parseTemplates(templates)
if err != nil {
return nil, err
}
apps, err := fs.Sub(appTmplsFS, "app-templates")
if err != nil {
return nil, err
}
appTmpls, err := NewAppTmplStoreFS(apps)
if err != nil {
return nil, err
}
s := &Server{
&sync.Mutex{},
st,
nf,
ug,
port,
apiPort,
self,
selfPublic,
repoPublicAddr,
sshKey,
gitRepoPublicKey,
client,
namespace,
envAppManagerAddr,
env,
nsc,
jc,
vpnKeyGen,
cnc,
map[string]map[string]struct{}{},
map[string]appConfig{},
tmplts,
appTmpls,
external,
fetchUsersAddr,
reconciler,
map[string]string{},
}
config, err := client.GetRepo(ConfigRepoName)
if err != nil {
return nil, err
}
r, err := config.Reader(appConfigsFile)
if err == nil {
defer r.Close()
if err := json.NewDecoder(r).Decode(&s.appConfigs); err != nil {
return nil, err
}
} else if !errors.Is(err, fs.ErrNotExist) {
return nil, err
}
return s, nil
}
func (s *Server) getAppConfig(app, branch string) appConfig {
return s.appConfigs[fmt.Sprintf("%s-%s", app, branch)]
}
func (s *Server) setAppConfig(app, branch string, cfg appConfig) {
s.appConfigs[fmt.Sprintf("%s-%s", app, branch)] = cfg
}
func (s *Server) Start() error {
// if err := s.client.DisableKeyless(); err != nil {
// return err
// }
// if err := s.client.DisableAnonAccess(); err != nil {
// return err
// }
e := make(chan error)
go func() {
r := mux.NewRouter()
r.Use(s.mwAuth)
r.HandleFunc(schemasPath+"app.schema.json", s.handleSchema).Methods(http.MethodGet)
r.PathPrefix(staticPath).Handler(server.NewCachingHandler(http.FileServer(http.FS(staticAssets))))
r.HandleFunc(logoutPath, s.handleLogout).Methods(http.MethodGet)
r.HandleFunc(apiPublicData, s.handleAPIPublicData)
r.HandleFunc(apiCreateApp, s.handleAPICreateApp).Methods(http.MethodPost)
r.HandleFunc("/{app-name}"+loginPath, s.handleLoginForm).Methods(http.MethodGet)
r.HandleFunc("/{app-name}"+loginPath, s.handleLogin).Methods(http.MethodPost)
r.HandleFunc("/{app-name}/logs", s.handleAppLogs).Methods(http.MethodGet)
r.HandleFunc("/{app-name}/{hash}", s.handleAppCommit).Methods(http.MethodGet)
r.HandleFunc("/{app-name}/dev-branch/create", s.handleCreateDevBranch).Methods(http.MethodPost)
r.HandleFunc("/{app-name}/branch/{branch}", s.handleAppStatus).Methods(http.MethodGet)
r.HandleFunc("/{app-name}/branch/{branch}/delete", s.handleBranchDelete).Methods(http.MethodPost)
r.HandleFunc("/{app-name}", s.handleAppStatus).Methods(http.MethodGet)
r.HandleFunc("/{app-name}/delete", s.handleAppDelete).Methods(http.MethodPost)
r.HandleFunc("/", s.handleStatus).Methods(http.MethodGet)
r.HandleFunc("/", s.handleCreateApp).Methods(http.MethodPost)
e <- http.ListenAndServe(fmt.Sprintf(":%d", s.port), r)
}()
go func() {
r := mux.NewRouter()
r.HandleFunc("/update", s.handleAPIUpdate)
r.HandleFunc("/api/apps/{app-name}/workers", s.handleAPIRegisterWorker).Methods(http.MethodPost)
r.HandleFunc("/api/add-public-key", s.handleAPIAddPublicKey).Methods(http.MethodPost)
r.HandleFunc("/api/apps/{app-name}/branch/{branch}/env-profile", s.handleBranchEnvProfile).Methods(http.MethodGet)
if !s.external {
r.HandleFunc("/api/sync-users", s.handleAPISyncUsers).Methods(http.MethodGet)
}
e <- http.ListenAndServe(fmt.Sprintf(":%d", s.apiPort), r)
}()
if !s.external {
go func() {
s.syncUsers()
for {
delay := time.Duration(rand.Intn(60)+60) * time.Second
time.Sleep(delay)
s.syncUsers()
}
}()
}
return <-e
}
type UserGetter interface {
Get(r *http.Request) string
Encode(w http.ResponseWriter, user string) error
}
type externalUserGetter struct {
sc *securecookie.SecureCookie
}
func NewExternalUserGetter() UserGetter {
return &externalUserGetter{securecookie.New(
securecookie.GenerateRandomKey(64),
securecookie.GenerateRandomKey(32),
)}
}
func (ug *externalUserGetter) Get(r *http.Request) string {
cookie, err := r.Cookie(sessionCookie)
if err != nil {
return ""
}
var user string
if err := ug.sc.Decode(sessionCookie, cookie.Value, &user); err != nil {
return ""
}
return user
}
func (ug *externalUserGetter) Encode(w http.ResponseWriter, user string) error {
if encoded, err := ug.sc.Encode(sessionCookie, user); err == nil {
cookie := &http.Cookie{
Name: sessionCookie,
Value: encoded,
Path: "/",
Secure: true,
HttpOnly: true,
}
http.SetCookie(w, cookie)
return nil
} else {
return err
}
}
type internalUserGetter struct{}
func NewInternalUserGetter() UserGetter {
return internalUserGetter{}
}
func (ug internalUserGetter) Get(r *http.Request) string {
return r.Header.Get("X-Forwarded-User")
}
func (ug internalUserGetter) Encode(w http.ResponseWriter, user string) error {
return nil
}
func (s *Server) mwAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, loginPath) ||
strings.HasPrefix(r.URL.Path, logoutPath) ||
strings.HasPrefix(r.URL.Path, staticPath) ||
strings.HasPrefix(r.URL.Path, schemasPath) ||
strings.HasPrefix(r.URL.Path, apiPublicData) ||
strings.HasPrefix(r.URL.Path, apiCreateApp) {
next.ServeHTTP(w, r)
return
}
user := s.ug.Get(r)
if user == "" {
vars := mux.Vars(r)
appName, ok := vars["app-name"]
if !ok || appName == "" {
http.Error(w, "missing app-name", http.StatusBadRequest)
return
}
http.Redirect(w, r, fmt.Sprintf("/%s%s", appName, loginPath), http.StatusSeeOther)
return
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), userCtx, user)))
})
}
func (s *Server) handleSchema(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/schema+json")
w.Write(dodoAppJsonSchema)
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
// TODO(gio): move to UserGetter
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: "",
Path: "/",
HttpOnly: true,
Secure: true,
})
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (s *Server) handleLoginForm(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
appName, ok := vars["app-name"]
if !ok || appName == "" {
http.Error(w, "missing app-name", http.StatusBadRequest)
return
}
fmt.Fprint(w, `
<!DOCTYPE html>
<html lang='en'>
<head>
<title>dodo: app - login</title>
<meta charset='utf-8'>
</head>
<body>
<form action="" method="POST">
<input type="password" placeholder="Password" name="password" required />
<button type="submit">Login</button>
</form>
</body>
</html>
`)
}
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
appName, ok := vars["app-name"]
if !ok || appName == "" {
http.Error(w, "missing app-name", http.StatusBadRequest)
return
}
password := r.FormValue("password")
if password == "" {
http.Error(w, "missing password", http.StatusBadRequest)
return
}
user, err := s.st.GetAppOwner(appName)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
hashed, err := s.st.GetUserPassword(user)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := bcrypt.CompareHashAndPassword(hashed, []byte(password)); err != nil {
http.Redirect(w, r, r.URL.Path, http.StatusSeeOther)
return
}
if err := s.ug.Encode(w, user); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, fmt.Sprintf("/%s", appName), http.StatusSeeOther)
}
type navItem struct {
Name string
Address string
}
type statusData struct {
Navigation []navItem
Apps []string
Networks []installer.Network
Types []string
}
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
user := r.Context().Value(userCtx)
if user == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
apps, err := s.st.GetUserApps(user.(string))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
networks, err := s.getNetworks(user.(string))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
var types []string
for _, t := range s.appTmpls.Types() {
types = append(types, strings.Replace(t, "-", ":", 1))
}
n := []navItem{navItem{"Home", "/"}}
data := statusData{n, apps, networks, types}
if err := s.tmplts.index.Execute(w, data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
type appStatusData struct {
Navigation []navItem
Name string
Branch string
GitCloneCommand string
Commits []CommitMeta
LastCommit resourceData
Branches []string
}
func (s *Server) handleAppStatus(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
appName, ok := vars["app-name"]
if !ok || appName == "" {
http.Error(w, "missing app-name", http.StatusBadRequest)
return
}
branch, ok := vars["branch"]
if !ok || branch == "" {
branch = "master"
}
u := r.Context().Value(userCtx)
if u == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
user, ok := u.(string)
if !ok {
http.Error(w, "could not get user", http.StatusInternalServerError)
return
}
owner, err := s.st.GetAppOwner(appName)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if owner != user {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
commits, err := s.st.GetCommitHistory(appName, branch)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
var lastCommitResources resourceData
if len(commits) > 0 {
lastCommit, err := s.st.GetCommit(commits[len(commits)-1].Hash)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
r, err := extractResourceData(lastCommit.Resources.Helm)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
lastCommitResources = r
}
branches, err := s.st.GetBranches(appName)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
data := appStatusData{
Navigation: []navItem{
navItem{"Home", "/"},
navItem{appName, "/" + appName},
},
Name: appName,
Branch: branch,
GitCloneCommand: fmt.Sprintf("git clone %s/%s\n\n\n", s.repoPublicAddr, appName),
Commits: commits,
LastCommit: lastCommitResources,
Branches: branches,
}
if branch != "master" {
data.Navigation = append(data.Navigation, navItem{branch, fmt.Sprintf("/%s/branch/%s", appName, branch)})
}
if err := s.tmplts.appStatus.Execute(w, data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
type appEnv struct {
Profile string `json:"envProfile"`
}
func (s *Server) handleBranchEnvProfile(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
appName, ok := vars["app-name"]
if !ok || appName == "" {
http.Error(w, "missing app-name", http.StatusBadRequest)
return
}
branch, ok := vars["branch"]
if !ok || branch == "" {
branch = "master"
}
info, err := s.st.GetLastCommitInfo(appName, branch)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
var e appEnv
if err := json.NewDecoder(bytes.NewReader(info.Resources.RenderedRaw)).Decode(&e); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
fmt.Fprintln(w, e.Profile)
}
type volume struct {
Name string
Size string
}
type postgresql struct {
Name string
Version string
Volume string
}
type ingress struct {
Name string
Host string
Home string
}
type vm struct {
Name string
User string
CPUCores int
Memory string
}
type resourceData struct {
Volume []volume
PostgreSQL []postgresql
Ingress []ingress
VirtualMachine []vm
}
type commitStatusData struct {
Navigation []navItem
AppName string
Commit Commit
Resources resourceData
}
func (s *Server) handleAppCommit(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
appName, ok := vars["app-name"]
if !ok || appName == "" {
http.Error(w, "missing app-name", http.StatusBadRequest)
return
}
hash, ok := vars["hash"]
if !ok || appName == "" {
http.Error(w, "missing app-name", http.StatusBadRequest)
return
}
u := r.Context().Value(userCtx)
if u == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
user, ok := u.(string)
if !ok {
http.Error(w, "could not get user", http.StatusInternalServerError)
return
}
owner, err := s.st.GetAppOwner(appName)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if owner != user {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
commit, err := s.st.GetCommit(hash)
if err != nil {
// TODO(gio): not-found ?
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
var res strings.Builder
if err := json.NewEncoder(&res).Encode(commit.Resources.Helm); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
resData, err := extractResourceData(commit.Resources.Helm)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
data := commitStatusData{
Navigation: []navItem{
navItem{"Home", "/"},
navItem{appName, "/" + appName},
navItem{hash, "/" + appName + "/" + hash},
},
AppName: appName,
Commit: commit,
Resources: resData,
}
if err := s.tmplts.commitStatus.Execute(w, data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
type logData struct {
Navigation []navItem
AppName string
Logs template.HTML
}
func (s *Server) handleAppLogs(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
appName, ok := vars["app-name"]
if !ok || appName == "" {
http.Error(w, "missing app-name", http.StatusBadRequest)
return
}
u := r.Context().Value(userCtx)
if u == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
user, ok := u.(string)
if !ok {
http.Error(w, "could not get user", http.StatusInternalServerError)
return
}
owner, err := s.st.GetAppOwner(appName)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if owner != user {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
data := logData{
Navigation: []navItem{
navItem{"Home", "/"},
navItem{appName, "/" + appName},
navItem{"Logs", "/" + appName + "/logs"},
},
AppName: appName,
Logs: template.HTML(strings.ReplaceAll(s.logs[appName], "\n", "<br/>")),
}
if err := s.tmplts.logs.Execute(w, data); err != nil {
fmt.Println(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
type apiUpdateReq struct {
Ref string `json:"ref"`
Repository struct {
Name string `json:"name"`
} `json:"repository"`
After string `json:"after"`
Commits []struct {
Id string `json:"id"`
Message string `json:"message"`
} `json:"commits"`
}
func (s *Server) handleAPIUpdate(w http.ResponseWriter, r *http.Request) {
fmt.Println("update")
var req apiUpdateReq
var contents strings.Builder
io.Copy(&contents, r.Body)
c := contents.String()
fmt.Println(c)
if err := json.NewDecoder(strings.NewReader(c)).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if strings.HasPrefix(req.Ref, "refs/heads/dodo_") || req.Repository.Name == ConfigRepoName {
return
}
branch, ok := strings.CutPrefix(req.Ref, "refs/heads/")
if !ok {
http.Error(w, "invalid branch", http.StatusBadRequest)
return
}
// TODO(gio): Create commit record on app init as well
go func() {
owner, err := s.st.GetAppOwner(req.Repository.Name)
if err != nil {
return
}
networks, err := s.getNetworks(owner)
if err != nil {
return
}
// TODO(gio): get only available ones by owner
clusters, err := s.getClusters()
if err != nil {
return
}
apps := installer.NewInMemoryAppRepository(installer.CreateAllApps())
instanceAppStatus, err := installer.FindEnvApp(apps, "dodo-app-instance-status")
if err != nil {
return
}
found := false
commitMsg := ""
for _, c := range req.Commits {
if c.Id == req.After {
found = true
commitMsg = c.Message
break
}
}
if !found {
fmt.Printf("Error: could not find commit message")
return
}
s.l.Lock()
defer s.l.Unlock()
resources, err := s.updateDodoApp(instanceAppStatus, req.Repository.Name, branch, s.getAppConfig(req.Repository.Name, branch).Namespace, networks, clusters, owner)
if err = s.createCommit(req.Repository.Name, branch, req.After, commitMsg, err, resources); err != nil {
fmt.Printf("Error: %s\n", err.Error())
return
}
for addr, _ := range s.workers[req.Repository.Name] {
go func() {
// TODO(gio): make port configurable
http.Get(fmt.Sprintf("http://%s/update", addr))
}()
}
}()
}
type apiRegisterWorkerReq struct {
Address string `json:"address"`
Logs string `json:"logs"`
}
func (s *Server) handleAPIRegisterWorker(w http.ResponseWriter, r *http.Request) {
// TODO(gio): lock
vars := mux.Vars(r)
appName, ok := vars["app-name"]
if !ok || appName == "" {
http.Error(w, "missing app-name", http.StatusBadRequest)
return
}
var req apiRegisterWorkerReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if _, ok := s.workers[appName]; !ok {
s.workers[appName] = map[string]struct{}{}
}
s.workers[appName][req.Address] = struct{}{}
s.logs[appName] = req.Logs
}
func (s *Server) handleCreateApp(w http.ResponseWriter, r *http.Request) {
u := r.Context().Value(userCtx)
if u == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
user, ok := u.(string)
if !ok {
http.Error(w, "could not get user", http.StatusInternalServerError)
return
}
network := r.FormValue("network")
if network == "" {
http.Error(w, "missing network", http.StatusBadRequest)
return
}
subdomain := r.FormValue("subdomain")
if subdomain == "" {
http.Error(w, "missing subdomain", http.StatusBadRequest)
return
}
appType := r.FormValue("type")
if appType == "" {
http.Error(w, "missing type", http.StatusBadRequest)
return
}
appName := r.FormValue("name")
var err error
if appName == "" {
g := installer.NewFixedLengthRandomNameGenerator(3)
appName, err = g.Generate()
}
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if ok, err := s.client.UserExists(user); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
} else if !ok {
http.Error(w, "user sync has not finished, please try again in few minutes", http.StatusFailedDependency)
return
}
if err := s.st.CreateUser(user, nil, network); err != nil && !errors.Is(err, ErrorAlreadyExists) {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := s.st.CreateApp(appName, user); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := s.createApp(user, appName, appType, network, subdomain); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, fmt.Sprintf("/%s", appName), http.StatusSeeOther)
}
func (s *Server) handleCreateDevBranch(w http.ResponseWriter, r *http.Request) {
u := r.Context().Value(userCtx)
if u == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
user, ok := u.(string)
if !ok {
http.Error(w, "could not get user", http.StatusInternalServerError)
return
}
vars := mux.Vars(r)
appName, ok := vars["app-name"]
if !ok || appName == "" {
http.Error(w, "missing app-name", http.StatusBadRequest)
return
}
branch := r.FormValue("branch")
if branch == "" {
http.Error(w, "missing branch", http.StatusBadRequest)
return
}
if err := s.createDevBranch(appName, "master", branch, user); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, fmt.Sprintf("/%s/branch/%s", appName, branch), http.StatusSeeOther)
}
func (s *Server) handleBranchDelete(w http.ResponseWriter, r *http.Request) {
u := r.Context().Value(userCtx)
if u == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
vars := mux.Vars(r)
appName, ok := vars["app-name"]
if !ok || appName == "" {
http.Error(w, "missing app-name", http.StatusBadRequest)
return
}
branch, ok := vars["branch"]
if !ok || branch == "" {
http.Error(w, "missing branch", http.StatusBadRequest)
return
}
if err := s.deleteBranch(appName, branch); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, fmt.Sprintf("/%s", appName), http.StatusSeeOther)
}
func (s *Server) handleAppDelete(w http.ResponseWriter, r *http.Request) {
u := r.Context().Value(userCtx)
if u == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
vars := mux.Vars(r)
appName, ok := vars["app-name"]
if !ok || appName == "" {
http.Error(w, "missing app-name", http.StatusBadRequest)
return
}
if err := s.deleteApp(appName); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
type apiCreateAppReq struct {
AppType string `json:"type"`
AdminPublicKey string `json:"adminPublicKey"`
Network string `json:"network"`
Subdomain string `json:"subdomain"`
}
type apiCreateAppResp struct {
AppName string `json:"appName"`
Password string `json:"password"`
}
func (s *Server) handleAPICreateApp(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
var req apiCreateAppReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
g := installer.NewFixedLengthRandomNameGenerator(3)
appName, err := g.Generate()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
user, err := s.client.FindUser(req.AdminPublicKey)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if user != "" {
http.Error(w, "public key already registered", http.StatusBadRequest)
return
}
user = appName
if err := s.client.AddUser(user, req.AdminPublicKey); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
password := generatePassword()
hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := s.st.CreateUser(user, hashed, req.Network); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := s.st.CreateApp(appName, user); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := s.createApp(user, appName, req.AppType, req.Network, req.Subdomain); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
resp := apiCreateAppResp{
AppName: appName,
Password: password,
}
if err := json.NewEncoder(w).Encode(resp); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
func (s *Server) isNetworkUseAllowed(network string) bool {
if !s.external {
return true
}
for _, cfg := range s.appConfigs {
if strings.ToLower(cfg.Network) == network {
return false
}
}
return true
}
func (s *Server) createApp(user, appName, appType, network, subdomain string) error {
s.l.Lock()
defer s.l.Unlock()
fmt.Printf("Creating app: %s\n", appName)
network = strings.ToLower(network)
if !s.isNetworkUseAllowed(network) {
return fmt.Errorf("network already used: %s", network)
}
if ok, err := s.client.RepoExists(appName); err != nil {
return err
} else if ok {
return nil
}
networks, err := s.getNetworks(user)
if err != nil {
return err
}
n, ok := installer.NetworkMap(networks)[network]
if !ok {
return fmt.Errorf("network not found: %s\n", network)
}
if err := s.client.AddRepository(appName); err != nil {
return err
}
appRepo, err := s.client.GetRepo(appName)
if err != nil {
return err
}
files, err := s.renderAppConfigTemplate(appType, n, subdomain)
if err != nil {
return err
}
return s.createAppForBranch(appRepo, appName, "master", user, network, files)
}
func (s *Server) createDevBranch(appName, fromBranch, toBranch, user string) error {
s.l.Lock()
defer s.l.Unlock()
fmt.Printf("Creating dev branch app: %s %s %s\n", appName, fromBranch, toBranch)
appRepo, err := s.client.GetRepoBranch(appName, fromBranch)
if err != nil {
return err
}
appCfg, err := soft.ReadFile(appRepo, "app.json")
if err != nil {
return err
}
network, branchCfg, err := createDevBranchAppConfig(appCfg, toBranch, user)
if err != nil {
return err
}
return s.createAppForBranch(appRepo, appName, toBranch, user, network, map[string][]byte{"app.json": branchCfg})
}
func (s *Server) deleteBranch(appName string, branch string) error {
appBranch := fmt.Sprintf("dodo_%s", branch)
hf := installer.NewGitHelmFetcher()
if err := func() error {
repo, err := s.client.GetRepoBranch(appName, appBranch)
if err != nil {
return err
}
m, err := installer.NewAppManager(repo, s.nsc, s.jc, hf, s.vpnKeyGen, s.cnc, "/.dodo")
if err != nil {
return err
}
return m.Remove("app")
}(); err != nil {
return err
}
configRepo, err := s.client.GetRepo(ConfigRepoName)
if err != nil {
return err
}
m, err := installer.NewAppManager(configRepo, s.nsc, s.jc, hf, s.vpnKeyGen, s.cnc, "/")
if err != nil {
return err
}
appPath := fmt.Sprintf("%s/%s", appName, branch)
if err := m.Remove(appPath); err != nil {
return err
}
if err := s.client.DeleteRepoBranch(appName, appBranch); err != nil {
return err
}
if branch != "master" {
if err := s.client.DeleteRepoBranch(appName, branch); err != nil {
return err
}
}
return s.st.DeleteBranch(appName, branch)
}
func (s *Server) deleteApp(appName string) error {
configRepo, err := s.client.GetRepo(ConfigRepoName)
if err != nil {
return err
}
branches, err := configRepo.ListDir(fmt.Sprintf("/%s", appName))
if err != nil {
return err
}
for _, b := range branches {
if !b.IsDir() || strings.HasPrefix(b.Name(), "dodo_") {
continue
}
if err := s.deleteBranch(appName, b.Name()); err != nil {
return err
}
}
if err := s.client.DeleteRepo(appName); err != nil {
return err
}
return s.st.DeleteApp(appName)
}
func (s *Server) createAppForBranch(
repo soft.RepoIO,
appName string,
branch string,
user string,
network string,
files map[string][]byte,
) error {
commit, err := repo.Do(func(fs soft.RepoFS) (string, error) {
for path, contents := range files {
if err := soft.WriteFile(fs, path, string(contents)); err != nil {
return "", err
}
}
return "init", nil
}, soft.WithCommitToBranch(branch))
if err != nil {
return err
}
networks, err := s.getNetworks(user)
if err != nil {
return err
}
// TODO(gio): get only available ones by owner
clusters, err := s.getClusters()
if err != nil {
return err
}
apps := installer.NewInMemoryAppRepository(installer.CreateAllApps())
instanceApp, err := installer.FindEnvApp(apps, "dodo-app-instance")
if err != nil {
return err
}
instanceAppStatus, err := installer.FindEnvApp(apps, "dodo-app-instance-status")
if err != nil {
return err
}
suffixGen := installer.NewFixedLengthRandomSuffixGenerator(3)
suffix, err := suffixGen.Generate()
if err != nil {
return err
}
namespace := fmt.Sprintf("%s%s%s", s.env.NamespacePrefix, instanceApp.Namespace(), suffix)
s.setAppConfig(appName, branch, appConfig{namespace, network})
resources, err := s.updateDodoApp(instanceAppStatus, appName, branch, namespace, networks, clusters, user)
if err != nil {
fmt.Printf("Error: %s\n", err.Error())
return err
}
if err = s.createCommit(appName, branch, commit, initCommitMsg, err, resources); err != nil {
fmt.Printf("Error: %s\n", err.Error())
return err
}
configRepo, err := s.client.GetRepo(ConfigRepoName)
if err != nil {
return err
}
hf := installer.NewGitHelmFetcher()
m, err := installer.NewAppManager(configRepo, s.nsc, s.jc, hf, s.vpnKeyGen, s.cnc, "/")
if err != nil {
return err
}
appPath := fmt.Sprintf("/%s/%s", appName, branch)
_, err = configRepo.Do(func(fs soft.RepoFS) (string, error) {
w, err := fs.Writer(appConfigsFile)
if err != nil {
return "", err
}
defer w.Close()
if err := json.NewEncoder(w).Encode(s.appConfigs); err != nil {
return "", err
}
if _, err := m.Install(
instanceApp,
appName,
appPath,
namespace,
map[string]any{
"repoAddr": s.client.GetRepoAddress(appName),
"repoHost": strings.Split(s.client.Address(), ":")[0],
"branch": fmt.Sprintf("dodo_%s", branch),
"gitRepoPublicKey": s.gitRepoPublicKey,
},
installer.WithConfig(&s.env),
installer.WithNoNetworks(),
installer.WithNoPublish(),
installer.WithNoLock(),
); err != nil {
return "", err
}
return fmt.Sprintf("Installed app: %s", appName), nil
})
if err != nil {
return err
}
return s.initAppACLs(m, appPath, appName, branch, user)
}
func (s *Server) initAppACLs(m *installer.AppManager, path, appName, branch, user string) error {
cfg, err := m.GetInstance(path)
if err != nil {
return err
}
fluxKeys, ok := cfg.Input["fluxKeys"]
if !ok {
return fmt.Errorf("Fluxcd keys not found")
}
fluxPublicKey, ok := fluxKeys.(map[string]any)["public"]
if !ok {
return fmt.Errorf("Fluxcd keys not found")
}
if ok, err := s.client.UserExists("fluxcd"); err != nil {
return err
} else if ok {
if err := s.client.AddPublicKey("fluxcd", fluxPublicKey.(string)); err != nil {
return err
}
} else {
if err := s.client.AddUser("fluxcd", fluxPublicKey.(string)); err != nil {
return err
}
}
if branch != "master" {
return nil
}
if err := s.client.AddReadOnlyCollaborator(appName, "fluxcd"); err != nil {
return err
}
if err := s.client.AddReadWriteCollaborator(appName, user); err != nil {
return err
}
if err := s.client.AddWebhook(appName, fmt.Sprintf("http://%s/update", s.self), "--active=true", "--events=push", "--content-type=json"); err != nil {
return err
}
if !s.external {
go func() {
users, err := s.client.GetAllUsers()
if err != nil {
fmt.Println(err)
return
}
for _, user := range users {
// TODO(gio): fluxcd should have only read access
if err := s.client.AddReadWriteCollaborator(appName, user); err != nil {
fmt.Println(err)
}
}
}()
}
ctx, _ := context.WithTimeout(context.Background(), 2*time.Minute)
go s.reconciler.Reconcile(ctx, s.namespace, "config")
return nil
}
type apiAddAdminKeyReq struct {
User string `json:"user"`
PublicKey string `json:"publicKey"`
}
func (s *Server) handleAPIAddPublicKey(w http.ResponseWriter, r *http.Request) {
var req apiAddAdminKeyReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if req.User == "" {
http.Error(w, "invalid user", http.StatusBadRequest)
return
}
if req.PublicKey == "" {
http.Error(w, "invalid public key", http.StatusBadRequest)
return
}
if err := s.client.AddPublicKey(req.User, req.PublicKey); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
type dodoAppRendered struct {
App struct {
Ingress struct {
Network string `json:"network"`
Subdomain string `json:"subdomain"`
} `json:"ingress"`
} `json:"app"`
Input struct {
AppId string `json:"appId"`
} `json:"input"`
}
// TODO(gio): must not require owner, now we need it to bootstrap dev vm.
func (s *Server) updateDodoApp(
appStatus installer.EnvApp,
name string,
branch string,
namespace string,
networks []installer.Network,
clusters []installer.Cluster,
owner string,
) (installer.ReleaseResources, error) {
repo, err := s.client.GetRepoBranch(name, branch)
if err != nil {
return installer.ReleaseResources{}, err
}
hf := installer.NewGitHelmFetcher()
m, err := installer.NewAppManager(repo, s.nsc, s.jc, hf, s.vpnKeyGen, s.cnc, "/.dodo")
if err != nil {
return installer.ReleaseResources{}, err
}
appCfg, err := soft.ReadFile(repo, "app.json")
if err != nil {
return installer.ReleaseResources{}, err
}
app, err := installer.NewDodoApp(appCfg)
if err != nil {
return installer.ReleaseResources{}, err
}
lg := installer.GitRepositoryLocalChartGenerator{"app", namespace}
var ret installer.ReleaseResources
if _, err := repo.Do(func(r soft.RepoFS) (string, error) {
ret, err = m.Install(
app,
"app",
"/.dodo/app",
namespace,
map[string]any{
"repoAddr": repo.FullAddress(),
"repoPublicAddr": s.repoPublicAddr,
"managerAddr": fmt.Sprintf("http://%s", s.self),
"appId": name,
"branch": branch,
"sshPrivateKey": s.sshKey,
"username": owner,
},
installer.WithNoPull(),
installer.WithNoPublish(),
installer.WithConfig(&s.env),
installer.WithNetworks(networks),
installer.WithClusters(clusters),
installer.WithLocalChartGenerator(lg),
installer.WithNoLock(),
)
if err != nil {
return "", err
}
var rendered dodoAppRendered
if err := json.NewDecoder(bytes.NewReader(ret.RenderedRaw)).Decode(&rendered); err != nil {
return "", nil
}
if _, err := m.Install(
appStatus,
"status",
"/.dodo/status",
s.namespace,
map[string]any{
"appName": rendered.Input.AppId,
"network": rendered.App.Ingress.Network,
"appSubdomain": rendered.App.Ingress.Subdomain,
},
installer.WithNoPull(),
installer.WithNoPublish(),
installer.WithConfig(&s.env),
installer.WithNetworks(networks),
installer.WithClusters(clusters),
installer.WithLocalChartGenerator(lg),
installer.WithNoLock(),
); err != nil {
return "", err
}
return "install app", nil
},
soft.WithCommitToBranch(fmt.Sprintf("dodo_%s", branch)),
soft.WithForce(),
); err != nil {
return installer.ReleaseResources{}, err
}
ctx, _ := context.WithTimeout(context.Background(), 2*time.Minute)
go s.reconciler.Reconcile(ctx, namespace, "app")
return ret, nil
}
func (s *Server) renderAppConfigTemplate(appType string, network installer.Network, subdomain string) (map[string][]byte, error) {
appType = strings.Replace(appType, ":", "-", 1)
appTmpl, err := s.appTmpls.Find(appType)
if err != nil {
return nil, err
}
return appTmpl.Render(fmt.Sprintf("%s/schemas/app.schema.json", s.selfPublic), network, subdomain)
}
func generatePassword() string {
return "foo"
}
func (s *Server) getNetworks(user string) ([]installer.Network, error) {
addr := fmt.Sprintf("%s/api/networks", s.envAppManagerAddr)
resp, err := http.Get(addr)
if err != nil {
return nil, err
}
networks := []installer.Network{}
if json.NewDecoder(resp.Body).Decode(&networks); err != nil {
return nil, err
}
return s.nf.Filter(user, networks)
}
func (s *Server) getClusters() ([]installer.Cluster, error) {
addr := fmt.Sprintf("%s/api/clusters", s.envAppManagerAddr)
resp, err := http.Get(addr)
if err != nil {
return nil, err
}
clusters := []installer.Cluster{}
if json.NewDecoder(resp.Body).Decode(&clusters); err != nil {
return nil, err
}
fmt.Printf("CLUSTERS %+v\n", clusters)
return clusters, nil
}
type publicNetworkData struct {
Name string `json:"name"`
Domain string `json:"domain"`
}
type publicData struct {
Networks []publicNetworkData `json:"networks"`
Types []string `json:"types"`
}
func (s *Server) handleAPIPublicData(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
s.l.Lock()
defer s.l.Unlock()
networks, err := s.getNetworks("")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
var ret publicData
for _, n := range networks {
if s.isNetworkUseAllowed(strings.ToLower(n.Name)) {
ret.Networks = append(ret.Networks, publicNetworkData{n.Name, n.Domain})
}
}
for _, t := range s.appTmpls.Types() {
ret.Types = append(ret.Types, strings.Replace(t, "-", ":", 1))
}
if err := json.NewEncoder(w).Encode(ret); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
func (s *Server) createCommit(name, branch, hash, message string, err error, resources installer.ReleaseResources) error {
if err != nil {
fmt.Printf("Error: %s\n", err.Error())
if err := s.st.CreateCommit(name, branch, hash, message, "FAILED", err.Error(), nil); err != nil {
fmt.Printf("Error: %s\n", err.Error())
return err
}
return err
}
var resB bytes.Buffer
if err := json.NewEncoder(&resB).Encode(resources); err != nil {
if err := s.st.CreateCommit(name, branch, hash, message, "FAILED", err.Error(), nil); err != nil {
fmt.Printf("Error: %s\n", err.Error())
return err
}
return err
}
if err := s.st.CreateCommit(name, branch, hash, message, "OK", "", resB.Bytes()); err != nil {
fmt.Printf("Error: %s\n", err.Error())
return err
}
return nil
}
func pickNetwork(networks []installer.Network, network string) []installer.Network {
for _, n := range networks {
if n.Name == network {
return []installer.Network{n}
}
}
return []installer.Network{}
}
type NetworkFilter interface {
Filter(user string, networks []installer.Network) ([]installer.Network, error)
}
type noNetworkFilter struct{}
func NewNoNetworkFilter() NetworkFilter {
return noNetworkFilter{}
}
func (f noNetworkFilter) Filter(user string, networks []installer.Network) ([]installer.Network, error) {
return networks, nil
}
type filterByOwner struct {
st Store
}
func NewNetworkFilterByOwner(st Store) NetworkFilter {
return &filterByOwner{st}
}
func (f *filterByOwner) Filter(user string, networks []installer.Network) ([]installer.Network, error) {
if user == "" {
return networks, nil
}
network, err := f.st.GetUserNetwork(user)
if err != nil {
return nil, err
}
ret := []installer.Network{}
for _, n := range networks {
if n.Name == network {
ret = append(ret, n)
}
}
return ret, nil
}
type allowListFilter struct {
allowed []string
}
func NewAllowListFilter(allowed []string) NetworkFilter {
return &allowListFilter{allowed}
}
func (f *allowListFilter) Filter(user string, networks []installer.Network) ([]installer.Network, error) {
ret := []installer.Network{}
for _, n := range networks {
if slices.Contains(f.allowed, n.Name) {
ret = append(ret, n)
}
}
return ret, nil
}
type combinedNetworkFilter struct {
filters []NetworkFilter
}
func NewCombinedFilter(filters ...NetworkFilter) NetworkFilter {
return &combinedNetworkFilter{filters}
}
func (f *combinedNetworkFilter) Filter(user string, networks []installer.Network) ([]installer.Network, error) {
ret := networks
var err error
for _, f := range f.filters {
ret, err = f.Filter(user, ret)
if err != nil {
return nil, err
}
}
return ret, nil
}
type user struct {
Username string `json:"username"`
Email string `json:"email"`
SSHPublicKeys []string `json:"sshPublicKeys,omitempty"`
}
func (s *Server) handleAPISyncUsers(_ http.ResponseWriter, _ *http.Request) {
go s.syncUsers()
}
func (s *Server) syncUsers() {
if s.external {
panic("MUST NOT REACH!")
}
resp, err := http.Get(fmt.Sprintf("%s?selfAddress=%s/api/sync-users", s.fetchUsersAddr, s.self))
if err != nil {
return
}
users := []user{}
if err := json.NewDecoder(resp.Body).Decode(&users); err != nil {
fmt.Println(err)
return
}
validUsernames := make(map[string]user)
for _, u := range users {
validUsernames[u.Username] = u
}
allClientUsers, err := s.client.GetAllUsers()
if err != nil {
fmt.Println(err)
return
}
keyToUser := make(map[string]string)
for _, clientUser := range allClientUsers {
if clientUser == "admin" || clientUser == "fluxcd" {
continue
}
userData, ok := validUsernames[clientUser]
if !ok {
if err := s.client.RemoveUser(clientUser); err != nil {
fmt.Println(err)
return
}
} else {
existingKeys, err := s.client.GetUserPublicKeys(clientUser)
if err != nil {
fmt.Println(err)
return
}
for _, existingKey := range existingKeys {
cleanKey := soft.CleanKey(existingKey)
keyOk := slices.ContainsFunc(userData.SSHPublicKeys, func(key string) bool {
return cleanKey == soft.CleanKey(key)
})
if !keyOk {
if err := s.client.RemovePublicKey(clientUser, existingKey); err != nil {
fmt.Println(err)
}
} else {
keyToUser[cleanKey] = clientUser
}
}
}
}
for _, u := range users {
if err := s.st.CreateUser(u.Username, nil, ""); err != nil && !errors.Is(err, ErrorAlreadyExists) {
fmt.Println(err)
return
}
if len(u.SSHPublicKeys) == 0 {
continue
}
ok, err := s.client.UserExists(u.Username)
if err != nil {
fmt.Println(err)
return
}
if !ok {
if err := s.client.AddUser(u.Username, u.SSHPublicKeys[0]); err != nil {
fmt.Println(err)
return
}
} else {
for _, key := range u.SSHPublicKeys {
cleanKey := soft.CleanKey(key)
if user, ok := keyToUser[cleanKey]; ok {
if u.Username != user {
panic("MUST NOT REACH! IMPOSSIBLE KEY USER RECORD")
}
continue
}
if err := s.client.AddPublicKey(u.Username, cleanKey); err != nil {
fmt.Println(err)
return
}
}
}
}
repos, err := s.client.GetAllRepos()
if err != nil {
return
}
for _, r := range repos {
if r == ConfigRepoName {
continue
}
for _, u := range users {
if err := s.client.AddReadWriteCollaborator(r, u.Username); err != nil {
fmt.Println(err)
continue
}
}
}
}
func extractResourceData(resources []installer.Resource) (resourceData, error) {
var ret resourceData
for _, r := range resources {
t, ok := r.Annotations["dodo.cloud/resource-type"]
if !ok {
continue
}
internal, ok := r.Annotations["dodo.cloud/internal"]
if ok && strings.ToLower(internal) == "true" {
continue
}
switch t {
case "volume":
name, ok := r.Annotations["dodo.cloud/resource.volume.name"]
if !ok {
return resourceData{}, fmt.Errorf("no name")
}
size, ok := r.Annotations["dodo.cloud/resource.volume.size"]
if !ok {
return resourceData{}, fmt.Errorf("no size")
}
ret.Volume = append(ret.Volume, volume{name, size})
case "postgresql":
name, ok := r.Annotations["dodo.cloud/resource.postgresql.name"]
if !ok {
return resourceData{}, fmt.Errorf("no name")
}
version, ok := r.Annotations["dodo.cloud/resource.postgresql.version"]
if !ok {
return resourceData{}, fmt.Errorf("no version")
}
volume, ok := r.Annotations["dodo.cloud/resource.postgresql.volume"]
if !ok {
return resourceData{}, fmt.Errorf("no volume")
}
ret.PostgreSQL = append(ret.PostgreSQL, postgresql{name, version, volume})
case "ingress":
name, ok := r.Annotations["dodo.cloud/resource.ingress.name"]
if !ok {
return resourceData{}, fmt.Errorf("no name")
}
home, ok := r.Annotations["dodo.cloud/resource.ingress.home"]
if !ok {
home = ""
}
host, ok := r.Annotations["dodo.cloud/resource.ingress.host"]
if !ok {
return resourceData{}, fmt.Errorf("no host")
}
ret.Ingress = append(ret.Ingress, ingress{name, host, home})
case "virtual-machine":
name, ok := r.Annotations["dodo.cloud/resource.virtual-machine.name"]
if !ok {
return resourceData{}, fmt.Errorf("no name")
}
user, ok := r.Annotations["dodo.cloud/resource.virtual-machine.user"]
if !ok {
return resourceData{}, fmt.Errorf("no user")
}
cpuCoresS, ok := r.Annotations["dodo.cloud/resource.virtual-machine.cpu-cores"]
if !ok {
return resourceData{}, fmt.Errorf("no cpu cores")
}
cpuCores, err := strconv.Atoi(cpuCoresS)
if err != nil {
return resourceData{}, fmt.Errorf("invalid cpu cores: %s", cpuCoresS)
}
memory, ok := r.Annotations["dodo.cloud/resource.virtual-machine.memory"]
if !ok {
return resourceData{}, fmt.Errorf("no memory")
}
ret.VirtualMachine = append(ret.VirtualMachine, vm{name, user, cpuCores, memory})
default:
fmt.Printf("Unknown resource: %+v\n", r.Annotations)
}
}
sort.Slice(ret.Ingress, func(i, j int) bool {
return strings.Compare(ret.Ingress[i].Name, ret.Ingress[j].Name) < 0
})
return ret, nil
}
func createDevBranchAppConfig(from []byte, branch, username string) (string, []byte, error) {
cfg, err := installer.ParseCueAppConfig(installer.CueAppData{
"app.cue": from,
})
if err != nil {
return "", nil, err
}
if err := cfg.Err(); err != nil {
return "", nil, err
}
if err := cfg.Validate(); err != nil {
return "", nil, err
}
subdomain := cfg.LookupPath(cue.ParsePath("app.ingress.subdomain"))
if err := subdomain.Err(); err != nil {
return "", nil, err
}
subdomainStr, err := subdomain.String()
network := cfg.LookupPath(cue.ParsePath("app.ingress.network"))
if err := network.Err(); err != nil {
return "", nil, err
}
networkStr, err := network.String()
if err != nil {
return "", nil, err
}
newCfg := map[string]any{}
if err := cfg.Decode(&newCfg); err != nil {
return "", nil, err
}
app, ok := newCfg["app"].(map[string]any)
if !ok {
return "", nil, fmt.Errorf("not a map")
}
app["ingress"].(map[string]any)["subdomain"] = fmt.Sprintf("%s-%s", branch, subdomainStr)
app["dev"] = map[string]any{
"enabled": true,
"username": username,
}
buf, err := json.MarshalIndent(newCfg, "", "\t")
if err != nil {
return "", nil, err
}
return networkStr, buf, nil
}