diff --git a/cmd/tesseract/main.go b/cmd/tesseract/main.go index 4e3fc4a..ace5c21 100644 --- a/cmd/tesseract/main.go +++ b/cmd/tesseract/main.go @@ -13,7 +13,6 @@ import ( "os" "path/filepath" "tesseract/internal/migration" - "tesseract/internal/reverseproxy" "tesseract/internal/service" "tesseract/internal/template" "tesseract/internal/workspace" @@ -55,36 +54,26 @@ func main() { log.Fatalln(err) } - proxy := reverseproxy.New(services) - err = proxy.Start() - if err != nil { - log.Fatalln(err) - } - log.Println("syncing all workspaces...") - if err = workspace.SyncAll(context.Background(), services); err != nil { + syncCtx, cancel := context.WithCancel(context.Background()) + if err = workspace.SyncAll(syncCtx, services); err != nil { log.Fatalln(err) } + cancel() apiServer := echo.New() - apiServer.Use(services.Middleware(), proxy.Middleware()) + apiServer.Use(services.Middleware()) g := apiServer.Group("/api") - workspace.DefineRoutes(g) + workspace.DefineRoutes(g, services) template.DefineRoutes(g) root := echo.New() - root.Use(middleware.CORS()) + root.Use(services.ReverseProxy.Middleware(), middleware.CORS()) root.Any("/*", func(c echo.Context) error { req := c.Request() res := c.Response() - - if proxy.ShouldHandleRequest(c) { - proxy.ServeHTTP(res, req) - } else { - apiServer.ServeHTTP(res, req) - } - + apiServer.ServeHTTP(res, req) return nil }) diff --git a/internal/docker/docker.go b/internal/docker/docker.go index 46ce943..7034835 100644 --- a/internal/docker/docker.go +++ b/internal/docker/docker.go @@ -2,6 +2,7 @@ package docker import ( "context" + "fmt" "github.com/docker/docker/api/types" "github.com/docker/go-connections/nat" "strconv" @@ -19,3 +20,16 @@ func ContainerSSHHostPort(ctx context.Context, container types.ContainerJSON) in } return port } + +// ContainerHostPort finds the host port that is exposing the given container port +func ContainerHostPort(ctx context.Context, container types.ContainerJSON, port int) int { + ports := container.NetworkSettings.Ports[nat.Port(fmt.Sprintf("%d/tcp", port))] + if len(ports) == 0 { + return -1 + } + port, err := strconv.Atoi(ports[0].HostPort) + if err != nil { + return -1 + } + return port +} diff --git a/internal/migration/sql/1_initial.up.sql b/internal/migration/sql/1_initial.up.sql index 08e4a41..21b28b1 100644 --- a/internal/migration/sql/1_initial.up.sql +++ b/internal/migration/sql/1_initial.up.sql @@ -43,8 +43,7 @@ CREATE TABLE IF NOT EXISTS port_mappings ( workspace_id TEXT NOT NULL, container_port INTEGER NOT NULL, - host_port INTEGER NOT NULL, subdomain TEXT, - CONSTRAINT pk_port_mappings PRIMARY KEY (workspace_id, container_port, host_port) + CONSTRAINT pk_port_mappings PRIMARY KEY (workspace_id, container_port, subdomain) ) \ No newline at end of file diff --git a/internal/reverseproxy/proxy.go b/internal/reverseproxy/proxy.go index a55f052..6a36c92 100644 --- a/internal/reverseproxy/proxy.go +++ b/internal/reverseproxy/proxy.go @@ -8,29 +8,21 @@ import ( "net/url" "regexp" "strings" - "tesseract/internal/service" ) type ReverseProxy struct { *echo.Echo - - services service.Services + hostName string httpProxies map[string]*httputil.ReverseProxy } -type portMapping struct { - subdomain string - containerPort int - hostPort int -} - const keyReverseProxy = "reverseProxy" -func New(services service.Services) *ReverseProxy { +func New(hostName string) *ReverseProxy { e := echo.New() proxy := &ReverseProxy{ e, - services, + hostName, make(map[string]*httputil.ReverseProxy), } @@ -39,55 +31,25 @@ func New(services service.Services) *ReverseProxy { return proxy } -func From(c echo.Context) *ReverseProxy { - return c.Get(keyReverseProxy).(*ReverseProxy) -} - -func (p *ReverseProxy) Start() error { - rows, err := p.services.Database.Query("SELECT container_port, host_port, subdomain FROM port_mappings;") - if err != nil { - return err - } - defer rows.Close() - - var mappings []portMapping - for rows.Next() { - mapping := portMapping{} - err = rows.Scan(&mapping.containerPort, &mapping.hostPort, &mapping.subdomain) - if err != nil { - return err - } - } - - for _, m := range mappings { - if m.subdomain == "" { - continue - } - - u, err := url.Parse(fmt.Sprintf("http://localhost:%d", m.hostPort)) - if err != nil { - continue - } - - proxy := httputil.NewSingleHostReverseProxy(u) - p.httpProxies[m.subdomain] = proxy - } - - return nil -} - func (p *ReverseProxy) Middleware() echo.MiddlewareFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { + if p.shouldHandleRequest(c) { + return p.handleRequest(c) + } c.Set(keyReverseProxy, p) return next(c) } } } -func (p *ReverseProxy) ShouldHandleRequest(c echo.Context) bool { - config := p.services.Config - h := strings.Replace(config.HostName, ".", "\\.", -1) +func (p *ReverseProxy) AddEntry(subdomain string, url *url.URL) { + proxy := httputil.NewSingleHostReverseProxy(url) + p.httpProxies[subdomain] = proxy +} + +func (p *ReverseProxy) shouldHandleRequest(c echo.Context) bool { + h := strings.Replace(p.hostName, ".", "\\.", -1) reg, err := regexp.Compile(".*\\." + h) if err != nil { return false @@ -98,9 +60,8 @@ func (p *ReverseProxy) ShouldHandleRequest(c echo.Context) bool { func (p *ReverseProxy) handleRequest(c echo.Context) error { req := c.Request() res := c.Response() - config := p.services.Config - h := strings.Replace(config.HostName, ".", "\\.", -1) + h := strings.Replace(p.hostName, ".", "\\.", -1) reg, err := regexp.Compile(fmt.Sprintf("(?P.*)\\.%v", h)) if err != nil { return err diff --git a/internal/service/service.go b/internal/service/service.go index f617924..286ece6 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -12,6 +12,7 @@ import ( "github.com/uptrace/bun/extra/bundebug" _ "modernc.org/sqlite" "net/http" + "tesseract/internal/reverseproxy" "tesseract/internal/sshproxy" ) @@ -21,6 +22,7 @@ const ( keyDB = "db" keyConfig = "config" keySSHProxy = "sshProxy" + keyReverseProxy = "reverseProxy" ) type Services struct { @@ -29,6 +31,7 @@ type Services struct { Database *bun.DB Config Config SSHProxy *sshproxy.SSHProxy + ReverseProxy *reverseproxy.ReverseProxy Melody *melody.Melody } @@ -48,6 +51,10 @@ func SSHProxy(c echo.Context) *sshproxy.SSHProxy { return c.Get(keySSHProxy).(*sshproxy.SSHProxy) } +func ReverseProxy(c echo.Context) *reverseproxy.ReverseProxy { + return c.Get(keyReverseProxy).(*reverseproxy.ReverseProxy) +} + func Initialize(config Config) (Services, error) { hc := &http.Client{} @@ -72,6 +79,7 @@ func Initialize(config Config) (Services, error) { Config: config, Melody: melody.New(), SSHProxy: sshProxy, + ReverseProxy: reverseproxy.New(config.HostName), }, nil } @@ -83,6 +91,7 @@ func (s Services) Middleware() echo.MiddlewareFunc { c.Set(keyDB, s.Database) c.Set(keyConfig, s.Config) c.Set(keySSHProxy, s.SSHProxy) + c.Set(keyReverseProxy, s.ReverseProxy) return next(c) } } diff --git a/internal/workspace/http_handlers.go b/internal/workspace/http_handlers.go index 3157df4..a71346d 100644 --- a/internal/workspace/http_handlers.go +++ b/internal/workspace/http_handlers.go @@ -1,20 +1,11 @@ package workspace import ( - "database/sql" "encoding/json" "errors" - "github.com/docker/docker/api/types/container" - "github.com/docker/go-connections/nat" - "github.com/google/uuid" + "fmt" "github.com/labstack/echo/v4" "net/http" - "strconv" - "sync" - "tesseract/internal/docker" - "tesseract/internal/service" - "tesseract/internal/template" - "time" ) type createWorkspaceRequestBody struct { @@ -22,72 +13,16 @@ type createWorkspaceRequestBody struct { } type updateWorkspaceRequestBody struct { - Status string `json:"status"` + Status string `json:"status"` + PortMappings []portMapping `json:"ports"` } func fetchAllWorkspaces(c echo.Context) error { - db := service.Database(c) - ctx := c.Request().Context() - - var workspaces []workspace - err := db.NewSelect().Model(&workspaces).Scan(c.Request().Context()) + mgr := workspaceManagerFrom(c) + workspaces, err := mgr.findAllWorkspaces(c.Request().Context()) if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return c.JSON(http.StatusOK, make([]workspace, 0)) - } return err } - - if len(workspaces) == 0 { - return c.JSON(http.StatusOK, make([]workspace, 0)) - } - - dockerClient := service.DockerClient(c) - sshProxy := service.SSHProxy(c) - - var wg sync.WaitGroup - var mu sync.Mutex - var errs []error - for i, w := range workspaces { - wg.Add(1) - i, w := i, w - go func() { - defer wg.Done() - - inspect, err := dockerClient.ContainerInspect(ctx, w.ContainerID) - if err != nil { - mu.Lock() - errs = append(errs, err) - mu.Unlock() - } else { - switch inspect.State.Status { - case "running": - workspaces[i].Status = statusRunning - case "exited": - workspaces[i].Status = statusStopped - case "paused": - workspaces[i].Status = statusPaused - case "restarting": - workspaces[i].Status = statusRestarting - default: - workspaces[i].Status = statusUnknown - } - - if internalPort := docker.ContainerSSHHostPort(ctx, inspect); internalPort > 0 { - if port := sshProxy.FindExternalPort(internalPort); port > 0 { - workspaces[i].SSHPort = port - } - } - } - }() - } - - wg.Wait() - - if err = errors.Join(errs...); err != nil { - return err - } - return c.JSON(http.StatusOK, workspaces) } @@ -101,59 +36,18 @@ func updateOrCreateWorkspace(c echo.Context) error { return echo.NewHTTPError(http.StatusNotFound) } - db := service.Database(c) ctx := c.Request().Context() + mgr := workspaceManagerFrom(c) - var w workspace - err := db.NewSelect().Model(&w). - Where("name = ?", workspaceName). - Scan(ctx) + exists, err := mgr.hasWorkspace(ctx, workspaceName) if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return createWorkspace(c, workspaceName) - } return err } - - var body updateWorkspaceRequestBody - if err = json.NewDecoder(c.Request().Body).Decode(&body); err != nil { - return err + if !exists { + return createWorkspace(c, workspaceName) } - dockerClient := service.DockerClient(c) - sshProxy := service.SSHProxy(c) - - switch status(body.Status) { - case statusStopped: - if err = stopContainer(ctx, dockerClient, workspaceName); err != nil { - return err - } - w.Status = statusStopped - break - - case statusRunning: - if err = startContainer(ctx, dockerClient, workspaceName); err != nil { - return err - } - - inspect, err := dockerClient.ContainerInspect(ctx, w.ContainerID) - if err != nil { - return err - } - - sshPort := docker.ContainerSSHHostPort(ctx, inspect) - if sshPort > 0 { - if err = sshProxy.NewProxyEntryTo(sshPort); err != nil { - return err - } - } - - w.Status = statusRunning - - break - } - - return c.JSON(http.StatusOK, w) + return updateWorkspace(c, workspaceName) } func createWorkspace(c echo.Context, workspaceName string) error { @@ -162,169 +56,77 @@ func createWorkspace(c echo.Context, workspaceName string) error { return echo.NewHTTPError(http.StatusBadRequest) } - db := service.Database(c) - ctx := c.Request().Context() + mgr := workspaceManagerFrom(c) - tx, err := db.BeginTx(ctx, nil) + w, err := mgr.createWorkspace(c.Request().Context(), createWorkspaceOptions{ + name: workspaceName, + imageID: body.ImageID, + }) if err != nil { - return err - } - - var img template.TemplateImage - err = tx.NewSelect().Model(&img). - Where("image_id = ?", body.ImageID). - Scan(c.Request().Context()) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return echo.NewHTTPError(http.StatusBadRequest, "image id not found") + if errors.Is(err, errImageNotFound) { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("no image with id %v exists", body.ImageID)) } return err } - dockerClient := service.DockerClient(c) - - containerSSHPort := nat.Port("22/tcp") - containerConfig := &container.Config{ - Tty: true, - Image: img.ImageID, - ExposedPorts: nat.PortSet{ - containerSSHPort: {}, - }, - } - - hostConfig := &container.HostConfig{ - PortBindings: nat.PortMap{ - containerSSHPort: { - {"127.0.0.1", ""}, - }, - }, - } - - res, err := dockerClient.ContainerCreate(ctx, containerConfig, hostConfig, nil, nil, workspaceName) - if err != nil { - return err - } - - err = dockerClient.ContainerStart(ctx, res.ID, container.StartOptions{}) - if err != nil { - return err - } - - inspect, err := dockerClient.ContainerInspect(ctx, res.ID) - if err != nil { - return err - } - - ports := inspect.NetworkSettings.Ports[containerSSHPort] - if len(ports) == 0 { - return errors.New("failed to bind ssh port for container") - } - - sshProxy := service.SSHProxy(c) - hostPort, err := strconv.Atoi(ports[0].HostPort) - if err != nil { - return err - } - - if err = sshProxy.NewProxyEntryTo(hostPort); err != nil { - return err - } - - id, err := uuid.NewV7() - if err != nil { - return err - } - - w := workspace{ - ID: id, - Name: workspaceName, - ContainerID: res.ID, - ImageTag: img.ImageTag, - CreatedAt: time.Now().Format(time.RFC3339), - SSHPort: hostPort, - Status: statusRunning, - } - _, err = tx.NewInsert().Model(&w).Exec(c.Request().Context()) - if err != nil { - _ = tx.Rollback() - return err - } - - if err = tx.Commit(); err != nil { - return err - } - return c.JSON(http.StatusOK, w) } +func updateWorkspace(c echo.Context, workspaceName string) error { + ctx := c.Request().Context() + + var body updateWorkspaceRequestBody + err := json.NewDecoder(c.Request().Body).Decode(&body) + if err != nil { + return err + } + + mgr := workspaceManagerFrom(c) + + workspace, err := mgr.findWorkspace(ctx, workspaceName) + if err != nil { + if errors.Is(err, errWorkspaceNotFound) { + return echo.NewHTTPError(http.StatusNotFound) + } + return err + } + + switch status(body.Status) { + case statusStopped: + if err = mgr.stopWorkspace(ctx, workspace); err != nil { + return err + } + break + + case statusRunning: + if err = mgr.startWorkspace(ctx, workspace); err != nil { + return err + } + break + } + + if len(body.PortMappings) > 0 { + if err = mgr.addPortMappings(ctx, workspace, body.PortMappings); err != nil { + return err + } + } + + return c.JSON(http.StatusOK, workspace) +} + func deleteWorkspace(c echo.Context) error { workspaceName := c.Param("workspaceName") if workspaceName == "" { return echo.NewHTTPError(http.StatusNotFound) } - db := service.Database(c) - dockerClient := service.DockerClient(c) - ctx := c.Request().Context() - - tx, err := db.BeginTx(ctx, nil) - if err != nil { - return echo.NewHTTPError(http.StatusNotFound) - } - - var w workspace - if err = tx.NewSelect().Model(&w).Scan(ctx); err != nil { - _ = tx.Rollback() - return echo.NewHTTPError(http.StatusNotFound) - } - - inspect, err := inspectContainer(ctx, dockerClient, w.ContainerID) - if err != nil { - _ = tx.Rollback() - return err - } - - if inspect.State.Running { - if err = stopContainer(ctx, dockerClient, w.ContainerID); err != nil { - _ = tx.Rollback() - return err - } - } - - if err = deleteContainer(ctx, dockerClient, w.ContainerID); err != nil { - return err - } - - res, err := tx.NewDelete(). - Table("workspaces"). - Where("name = ?", workspaceName). - Exec(ctx) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { + mgr := workspaceManagerFrom(c) + if err := mgr.deleteWorkspace(c.Request().Context(), workspaceName); err != nil { + if errors.Is(err, errWorkspaceNotFound) { return echo.NewHTTPError(http.StatusNotFound) } return err } - count, err := res.RowsAffected() - if err != nil { - _ = tx.Rollback() - return err - } - - if count == 0 { - _ = tx.Rollback() - return echo.NewHTTPError(http.StatusNotFound) - } - if count != 1 { - _ = tx.Rollback() - return echo.NewHTTPError(http.StatusNotFound) - } - - if err = tx.Commit(); err != nil { - _ = tx.Rollback() - return err - } - return c.NoContent(http.StatusOK) } diff --git a/internal/workspace/middleware.go b/internal/workspace/middleware.go new file mode 100644 index 0000000..6be7878 --- /dev/null +++ b/internal/workspace/middleware.go @@ -0,0 +1,25 @@ +package workspace + +import ( + "github.com/labstack/echo/v4" + "tesseract/internal/service" +) + +func newWorkspaceManagerMiddleware(services service.Services) echo.MiddlewareFunc { + mgr := workspaceManager{ + db: services.Database, + dockerClient: services.DockerClient, + reverseProxy: services.ReverseProxy, + sshProxy: services.SSHProxy, + } + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + c.Set("workspaceManager", mgr) + return next(c) + } + } +} + +func workspaceManagerFrom(c echo.Context) workspaceManager { + return c.Get("workspaceManager").(workspaceManager) +} diff --git a/internal/workspace/routes.go b/internal/workspace/routes.go index d7167ae..70bc6e3 100644 --- a/internal/workspace/routes.go +++ b/internal/workspace/routes.go @@ -2,9 +2,11 @@ package workspace import ( "github.com/labstack/echo/v4" + "tesseract/internal/service" ) -func DefineRoutes(g *echo.Group) { +func DefineRoutes(g *echo.Group, services service.Services) { + g.Use(newWorkspaceManagerMiddleware(services)) g.GET("/workspaces", fetchAllWorkspaces) g.POST("/workspaces/:workspaceName", updateOrCreateWorkspace) g.DELETE("/workspaces/:workspaceName", deleteWorkspace) diff --git a/internal/workspace/workspace.go b/internal/workspace/workspace.go index a366e7d..f18689a 100644 --- a/internal/workspace/workspace.go +++ b/internal/workspace/workspace.go @@ -4,13 +4,16 @@ import ( "context" "database/sql" "errors" + "fmt" "github.com/docker/docker/api/types/container" "github.com/docker/docker/client" "github.com/google/uuid" "github.com/uptrace/bun" + "net/url" "regexp" "sync" "tesseract/internal/docker" + "tesseract/internal/reverseproxy" "tesseract/internal/service" ) @@ -31,6 +34,18 @@ type workspace struct { SSHPort int `bun:"-" json:"sshPort,omitempty"` Status status `bun:"-" json:"status"` + + PortMappings []portMapping `bun:"rel:has-many,join:id=workspace_id" json:"ports,omitempty"` +} + +type portMapping struct { + bun.BaseModel `bun:"table:port_mappings,alias:port_mapping"` + + WorkspaceID uuid.UUID `bun:",type:uuid,pk" json:"-"` + ContainerPort int `json:"port"` + Subdomain string `json:"subdomain"` + + Workspace workspace `bun:"rel:belongs-to,join:workspace_id=id"` } // status represents the status of a workspace. @@ -122,5 +137,66 @@ func SyncAll(ctx context.Context, services service.Services) error { return err } + if err = initializeHTTPProxies(ctx, services.Database, services.DockerClient, services.ReverseProxy); err != nil { + return err + } + + return nil +} + +func initializeHTTPProxies(ctx context.Context, db *bun.DB, dockerClient *client.Client, proxy *reverseproxy.ReverseProxy) error { + var mappings []portMapping + if err := db.NewSelect(). + Model(&mappings). + Relation("Workspace", func(q *bun.SelectQuery) *bun.SelectQuery { + return q.Column("container_id") + }). + Scan(ctx); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil + } + return err + } + + if len(mappings) == 0 { + return nil + } + + var wg sync.WaitGroup + var errs []error + var mu sync.Mutex + + for _, m := range mappings { + m := m + wg.Add(1) + go func() { + var err error + defer wg.Done() + defer func() { + mu.Lock() + errs = append(errs, err) + mu.Unlock() + }() + + inspect, err := dockerClient.ContainerInspect(ctx, m.Workspace.ContainerID) + if err != nil { + return + } + + u, err := url.Parse(fmt.Sprintf("http://%s:%d", inspect.NetworkSettings.IPAddress, m.ContainerPort)) + if err != nil { + return + } + + proxy.AddEntry(m.Subdomain, u) + }() + } + + wg.Wait() + + if err := errors.Join(errs...); err != nil { + return err + } + return nil } diff --git a/internal/workspace/workspace_manager.go b/internal/workspace/workspace_manager.go new file mode 100644 index 0000000..a42b01a --- /dev/null +++ b/internal/workspace/workspace_manager.go @@ -0,0 +1,359 @@ +package workspace + +import ( + "context" + "database/sql" + "errors" + "fmt" + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/client" + "github.com/docker/go-connections/nat" + "github.com/google/uuid" + "github.com/uptrace/bun" + "net/url" + "strconv" + "sync" + "tesseract/internal/docker" + "tesseract/internal/reverseproxy" + "tesseract/internal/sshproxy" + "tesseract/internal/template" + "time" +) + +// workspaceManager provides functions to manipulate workspaces. +type workspaceManager struct { + db *bun.DB + dockerClient *client.Client + reverseProxy *reverseproxy.ReverseProxy + sshProxy *sshproxy.SSHProxy +} + +type createWorkspaceOptions struct { + name string + imageID string +} + +var errImageNotFound = errors.New("image not found") +var errWorkspaceNotFound = errors.New("workspace not found") + +func (mgr workspaceManager) findAllWorkspaces(ctx context.Context) ([]workspace, error) { + var workspaces []workspace + err := mgr.db.NewSelect().Model(&workspaces).Scan(ctx) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return make([]workspace, 0), nil + } + return nil, err + } + + if len(workspaces) == 0 { + return make([]workspace, 0), nil + } + + var wg sync.WaitGroup + var mu sync.Mutex + var errs []error + + for i := range workspaces { + i := i + wg.Add(1) + go func() { + defer wg.Done() + + inspect, err := mgr.dockerClient.ContainerInspect(ctx, workspaces[i].ContainerID) + if err != nil { + mu.Lock() + errs = append(errs, err) + mu.Unlock() + } else { + switch inspect.State.Status { + case "running": + workspaces[i].Status = statusRunning + case "exited": + workspaces[i].Status = statusStopped + case "paused": + workspaces[i].Status = statusPaused + case "restarting": + workspaces[i].Status = statusRestarting + default: + workspaces[i].Status = statusUnknown + } + + if internalPort := docker.ContainerSSHHostPort(ctx, inspect); internalPort > 0 { + if port := mgr.sshProxy.FindExternalPort(internalPort); port > 0 { + workspaces[i].SSHPort = port + } + } + } + }() + } + + wg.Wait() + + if err = errors.Join(errs...); err != nil { + return nil, err + } + + return workspaces, nil +} + +func (mgr workspaceManager) findWorkspace(ctx context.Context, name string) (*workspace, error) { + var w workspace + err := mgr.db.NewSelect().Model(&w). + Where("name = ?", name). + Scan(ctx) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, errWorkspaceNotFound + } + return nil, err + } + return &w, nil +} + +func (mgr workspaceManager) hasWorkspace(ctx context.Context, name string) (bool, error) { + exists, err := mgr.db.NewSelect().Table("workspaces"). + Where("name = ?", name). + Exists(ctx) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + return false, err + } + return exists, nil +} + +func (mgr workspaceManager) createWorkspace(ctx context.Context, opts createWorkspaceOptions) (*workspace, error) { + tx, err := mgr.db.BeginTx(ctx, nil) + if err != nil { + return nil, err + } + + var img template.TemplateImage + err = tx.NewSelect().Model(&img). + Where("image_id = ?", opts.imageID). + Scan(ctx) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, errImageNotFound + } + return nil, err + } + + containerSSHPort := nat.Port("22/tcp") + containerConfig := &container.Config{ + Tty: true, + Image: img.ImageID, + ExposedPorts: nat.PortSet{ + containerSSHPort: {}, + }, + } + + hostConfig := &container.HostConfig{ + PortBindings: nat.PortMap{ + containerSSHPort: { + {"127.0.0.1", ""}, + }, + }, + } + + res, err := mgr.dockerClient.ContainerCreate(ctx, containerConfig, hostConfig, nil, nil, opts.name) + if err != nil { + return nil, err + } + + err = mgr.dockerClient.ContainerStart(ctx, res.ID, container.StartOptions{}) + if err != nil { + return nil, err + } + + inspect, err := mgr.dockerClient.ContainerInspect(ctx, res.ID) + if err != nil { + return nil, err + } + + ports := inspect.NetworkSettings.Ports[containerSSHPort] + if len(ports) == 0 { + return nil, errors.New("failed to bind ssh port for container") + } + + hostPort, err := strconv.Atoi(ports[0].HostPort) + if err != nil { + return nil, err + } + + if err = mgr.sshProxy.NewProxyEntryTo(hostPort); err != nil { + return nil, err + } + + id, err := uuid.NewV7() + if err != nil { + return nil, err + } + + w := workspace{ + ID: id, + Name: opts.name, + ContainerID: res.ID, + ImageTag: img.ImageTag, + CreatedAt: time.Now().Format(time.RFC3339), + SSHPort: hostPort, + Status: statusRunning, + } + _, err = tx.NewInsert().Model(&w).Exec(ctx) + if err != nil { + _ = tx.Rollback() + return nil, err + } + + if err = tx.Commit(); err != nil { + return nil, err + } + + return &w, nil +} + +func (mgr workspaceManager) deleteWorkspace(ctx context.Context, name string) error { + tx, err := mgr.db.BeginTx(ctx, nil) + if err != nil { + return err + } + + var workspace workspace + if err = tx.NewSelect(). + Model(&workspace). + Where("name = ?", name). + Scan(ctx); err != nil { + _ = tx.Rollback() + if errors.Is(err, sql.ErrNoRows) { + return errWorkspaceNotFound + } + return err + } + + inspect, err := mgr.dockerClient.ContainerInspect(ctx, workspace.ContainerID) + if err != nil { + _ = tx.Rollback() + return err + } + + if inspect.State.Running { + if err = mgr.dockerClient.ContainerStop(ctx, workspace.ContainerID, container.StopOptions{}); err != nil { + _ = tx.Rollback() + return err + } + } + + if err = mgr.dockerClient.ContainerRemove(ctx, workspace.ContainerID, container.RemoveOptions{ + RemoveVolumes: true, + }); err != nil { + return err + } + + res, err := tx.NewDelete(). + Model(&workspace). + WherePK(). + Exec(ctx) + if err != nil { + _ = tx.Rollback() + return err + } + + count, err := res.RowsAffected() + if err != nil { + _ = tx.Rollback() + return err + } + + if count == 0 { + _ = tx.Rollback() + return errWorkspaceNotFound + } + if count != 1 { + _ = tx.Rollback() + return errors.New("unexpected number of workspaces deleted") + } + + if err = tx.Commit(); err != nil { + _ = tx.Rollback() + return err + } + + return nil +} + +func (mgr workspaceManager) startWorkspace(ctx context.Context, workspace *workspace) error { + err := mgr.dockerClient.ContainerStart(ctx, workspace.ContainerID, container.StartOptions{}) + if err != nil { + return err + } + + inspect, err := mgr.dockerClient.ContainerInspect(ctx, workspace.ContainerID) + if err != nil { + return err + } + + sshPort := docker.ContainerSSHHostPort(ctx, inspect) + if sshPort <= 0 { + return nil + } + + if err = mgr.sshProxy.NewProxyEntryTo(sshPort); err != nil { + return err + } + + workspace.Status = statusRunning + + return nil +} + +func (mgr workspaceManager) stopWorkspace(ctx context.Context, workspace *workspace) error { + err := mgr.dockerClient.ContainerStop(ctx, workspace.ContainerID, container.StopOptions{}) + if err != nil { + return err + } + workspace.Status = statusStopped + return nil +} + +func (mgr workspaceManager) addPortMappings(ctx context.Context, workspace *workspace, portMappings []portMapping) error { + inspect, err := mgr.dockerClient.ContainerInspect(ctx, workspace.ContainerID) + if err != nil { + return err + } + + containerIP := inspect.NetworkSettings.IPAddress + + urls := make([]*url.URL, len(portMappings)) + for i, m := range portMappings { + u, err := url.Parse(fmt.Sprintf("http://%s:%d", containerIP, m.ContainerPort)) + if err != nil { + return err + } + urls[i] = u + } + + tx, err := mgr.db.BeginTx(ctx, nil) + if err != nil { + return err + } + + for i := range portMappings { + portMappings[i].WorkspaceID = workspace.ID + mgr.reverseProxy.AddEntry(portMappings[i].Subdomain, urls[i]) + } + + _, err = tx.NewInsert().Model(&portMappings).Exec(ctx) + if err != nil { + return err + } + + if err = tx.Commit(); err != nil { + return err + } + + workspace.PortMappings = portMappings + + return nil +} diff --git a/web/src/workspaces/api.ts b/web/src/workspaces/api.ts index 3d0f588..342192f 100644 --- a/web/src/workspaces/api.ts +++ b/web/src/workspaces/api.ts @@ -1,6 +1,10 @@ import { fetchApi } from "@/api"; import useSWR, { useSWRConfig } from "swr"; -import { WorkspaceStatus, type Workspace } from "./types"; +import { + WorkspaceStatus, + type Workspace, + type WorkspacePortMapping, +} from "./types"; import { useCallback, useState } from "react"; import type { QueryStatus } from "@/lib/query"; @@ -154,9 +158,43 @@ function useDeleteWorkspace() { return { deleteWorkspace, status }; } +function useAddWorkspacePort() { + const [status, setStatus] = useState({ type: "idle" }); + const { mutate } = useSWRConfig(); + + const addWorkspacePort = useCallback( + async (workspaceName: string, ports: WorkspacePortMapping[]) => { + setStatus({ type: "loading" }); + try { + await mutate( + "/workspaces", + fetchApi(`/workspaces/${workspaceName}`, { + method: "POST", + body: JSON.stringify({ ports }), + headers: { + "Content-Type": "application/json", + }, + }).then((res): Promise => res.json()), + { + populateCache: (workspace, workspaces) => + workspaces.map((it: Workspace) => + it.name === workspace.name ? workspace : it, + ), + throwOnError: true, + }, + ); + } catch (err: unknown) {} + }, + [mutate], + ); + + return { addWorkspacePort, status }; +} + export { useWorkspaces, useCreateWorkspace, useChangeWorkspaceStatus, useDeleteWorkspace, + useAddWorkspacePort, }; diff --git a/web/src/workspaces/types.ts b/web/src/workspaces/types.ts index 7fa3bfd..a5309c6 100644 --- a/web/src/workspaces/types.ts +++ b/web/src/workspaces/types.ts @@ -5,6 +5,11 @@ enum WorkspaceStatus { Unknown = "unknown", } +interface WorkspacePortMapping { + subdomain: string; + port: number; +} + interface Workspace { name: string; containerId: string; @@ -12,7 +17,8 @@ interface Workspace { createdAt: string; status: WorkspaceStatus; sshPort?: number; + ports?: WorkspacePortMapping[]; } export { WorkspaceStatus }; -export type { Workspace }; +export type { Workspace, WorkspacePortMapping }; diff --git a/web/src/workspaces/workspace-table.tsx b/web/src/workspaces/workspace-table.tsx index 9570844..cb73339 100644 --- a/web/src/workspaces/workspace-table.tsx +++ b/web/src/workspaces/workspace-table.tsx @@ -12,9 +12,16 @@ import { useToast } from "@/hooks/use-toast"; import { StopIcon } from "@radix-ui/react-icons"; import { ToastAction } from "@radix-ui/react-toast"; import dayjs from "dayjs"; -import { Info, Loader2, Play, Trash2 } from "lucide-react"; -import { useEffect } from "react"; +import { Info, Loader2, Play, Plus, Trash2 } from "lucide-react"; import { + Fragment, + createContext, + useContext, + useEffect, + useState, +} from "react"; +import { + useAddWorkspacePort, useChangeWorkspaceStatus, useDeleteWorkspace, useWorkspaces, @@ -27,6 +34,22 @@ import { PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; +import { Input } from "@/components/ui/input"; +import { useForm } from "react-hook-form"; +import { number, object, pattern, size, string, type Infer } from "superstruct"; +import { superstructResolver } from "@hookform/resolvers/superstruct"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; + +const WorkspaceTableRowContext = createContext( + null as unknown as Workspace, +); function WorkspaceTable() { const { data: workspaces, isLoading } = useWorkspaces(); @@ -66,10 +89,12 @@ function WorkspaceTable() { {workspaces ? ( {workspaces.map((workspace) => ( - + value={workspace} + > + + ))} ) : null} @@ -79,7 +104,9 @@ function WorkspaceTable() { ); } -function WorkspaceTableRow({ workspace }: { workspace: Workspace }) { +function WorkspaceTableRow() { + const workspace = useContext(WorkspaceTableRowContext); + function statusLabel() { switch (workspace.status) { case WorkspaceStatus.Running: @@ -104,15 +131,16 @@ function WorkspaceTableRow({ workspace }: { workspace: Workspace }) { {dayjs(workspace.createdAt).format("YYYY/MM/DD HH:mm")} - - + + ); } -function WorkspaceStatusButton({ workspace }: { workspace: Workspace }) { +function WorkspaceStatusButton() { + const workspace = useContext(WorkspaceTableRowContext); const { toast } = useToast(); const { startWorkspace, stopWorkspace, status } = useChangeWorkspaceStatus(); @@ -212,7 +240,7 @@ function DeleteWorkspaceButton({ workspace }: { workspace: Workspace }) { ); } -function WorkspaceInfoButton({ workspace }: { workspace: Workspace }) { +function WorkspaceInfoButton() { return ( @@ -221,19 +249,145 @@ function WorkspaceInfoButton({ workspace }: { workspace: Workspace }) { -
- {workspace.sshPort ? ( - <> -
-

SSH Port

-
-

{workspace.sshPort}

- - ) : null} -
+
); } +function WorkspaceInfoPopoverContent() { + const workspace = useContext(WorkspaceTableRowContext); + return ( +
+ {workspace.sshPort ? ( + <> +
+

SSH Port

+
+

{workspace.sshPort}

+ + ) : null} + {workspace?.ports?.map(({ port, subdomain }) => ( + +
+

{subdomain}

+
+

{port}

+
+ ))} + +
+ ); +} + +const PortEntryForm = object({ + portName: pattern(string(), /^[\w-]+$/), + port: size(number(), 0, 65536), +}); + +function PortEntry() { + const [isAddingPort, setIsAddingPort] = useState(false); + const { addWorkspacePort, status } = useAddWorkspacePort(); + const workspace = useContext(WorkspaceTableRowContext); + const form = useForm({ + resolver: superstructResolver(PortEntryForm), + disabled: status.type === "loading", + defaultValues: { + port: 1234, + portName: "", + }, + }); + + function onAddPortButtonClick() { + if (isAddingPort) { + } else { + setIsAddingPort(true); + } + } + + async function onSubmit(values: Infer) { + await addWorkspacePort(workspace.name, [ + { subdomain: values.portName, port: values.port }, + ]); + } + + if (!isAddingPort) { + return ( + + ); + } + + return ( +
+ + {isAddingPort ? ( + <> + ( + + Subdomain + + + + + + )} + /> + ( + + Port + + + + + + )} + /> + + ) : null} + + + + ); +} + export { WorkspaceTable };