initial commit
This commit is contained in:
27
internal/migration/migration.go
Normal file
27
internal/migration/migration.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package migration
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
_ "github.com/golang-migrate/migrate/v4/database/sqlite3"
|
||||
_ "github.com/golang-migrate/migrate/v4/source/file"
|
||||
"github.com/golang-migrate/migrate/v4/source/iofs"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
//go:embed sql/*.sql
|
||||
var migrationFS embed.FS
|
||||
|
||||
func Up(url string) error {
|
||||
d, err := iofs.New(migrationFS, "sql")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
m, err := migrate.NewWithSourceInstance("iofs", d, url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return m.Up()
|
||||
}
|
50
internal/migration/sql/1_initial.up.sql
Normal file
50
internal/migration/sql/1_initial.up.sql
Normal file
@@ -0,0 +1,50 @@
|
||||
CREATE TABLE IF NOT EXISTS workspaces
|
||||
(
|
||||
id TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
container_id TEXT NOT NULL,
|
||||
image_tag TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT pk_workspaces PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS templates
|
||||
(
|
||||
id TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
description TEXT NOT NULL,
|
||||
created_on TEXT NOT NULL,
|
||||
last_modified_on TEXT NOT NULL,
|
||||
is_built INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT pk_templates PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS template_files
|
||||
(
|
||||
template_id TEXT NOT NULL,
|
||||
file_path TEXT NOT NULL,
|
||||
content BLOB NOT NULL,
|
||||
|
||||
CONSTRAINT pk_template_files PRIMARY KEY (template_id, file_path)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS template_images
|
||||
(
|
||||
template_id TEXT NOT NULL,
|
||||
image_tag TEXT NOT NULL,
|
||||
image_id TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT pk_template_images PRIMARY KEY (template_id, image_tag, image_id)
|
||||
);
|
||||
|
||||
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)
|
||||
)
|
133
internal/reverseproxy/proxy.go
Normal file
133
internal/reverseproxy/proxy.go
Normal file
@@ -0,0 +1,133 @@
|
||||
package reverseproxy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/labstack/echo/v4"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"tesseract/internal/service"
|
||||
)
|
||||
|
||||
type ReverseProxy struct {
|
||||
*echo.Echo
|
||||
|
||||
services service.Services
|
||||
httpProxies map[string]*httputil.ReverseProxy
|
||||
}
|
||||
|
||||
type portMapping struct {
|
||||
subdomain string
|
||||
containerPort int
|
||||
hostPort int
|
||||
}
|
||||
|
||||
const keyReverseProxy = "reverseProxy"
|
||||
|
||||
func New(services service.Services) *ReverseProxy {
|
||||
e := echo.New()
|
||||
proxy := &ReverseProxy{
|
||||
e,
|
||||
services,
|
||||
make(map[string]*httputil.ReverseProxy),
|
||||
}
|
||||
|
||||
e.Any("/*", proxy.handleRequest)
|
||||
|
||||
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 {
|
||||
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)
|
||||
reg, err := regexp.Compile(".*\\." + h)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return reg.MatchString(c.Request().Host)
|
||||
}
|
||||
|
||||
func (p *ReverseProxy) handleRequest(c echo.Context) error {
|
||||
req := c.Request()
|
||||
res := c.Response()
|
||||
config := p.services.Config
|
||||
|
||||
h := strings.Replace(config.HostName, ".", "\\.", -1)
|
||||
reg, err := regexp.Compile(fmt.Sprintf("(?P<subdomain>.*)\\.%v", h))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
matches := reg.FindStringSubmatch(req.Host)
|
||||
if len(matches) == 0 {
|
||||
return echo.NewHTTPError(http.StatusNotFound)
|
||||
}
|
||||
|
||||
var subdomain string
|
||||
for i, name := range reg.SubexpNames() {
|
||||
if i != 0 && name == "subdomain" {
|
||||
subdomain = matches[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if subdomain == "" {
|
||||
return echo.NewHTTPError(http.StatusNotFound)
|
||||
}
|
||||
|
||||
proxy, ok := p.httpProxies[subdomain]
|
||||
if !ok {
|
||||
return echo.NewHTTPError(http.StatusNotFound)
|
||||
}
|
||||
|
||||
proxy.ServeHTTP(res, req)
|
||||
|
||||
return nil
|
||||
}
|
33
internal/service/config.go
Normal file
33
internal/service/config.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
DatabasePath string `json:"databasePath"`
|
||||
TemplateDirectoryPath string `json:"templateDirectoryPath"`
|
||||
HostName string `json:"hostName"`
|
||||
}
|
||||
|
||||
func ReadConfigFrom(reader io.Reader) (Config, error) {
|
||||
var config Config
|
||||
err := json.NewDecoder(reader).Decode(&config)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
config.DatabasePath, err = filepath.Abs(config.DatabasePath)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
config.TemplateDirectoryPath, err = filepath.Abs(config.TemplateDirectoryPath)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
79
internal/service/service.go
Normal file
79
internal/service/service.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"github.com/docker/docker/client"
|
||||
_ "github.com/golang-migrate/migrate/v4/source/file"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/olahol/melody"
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/dialect/sqlitedialect"
|
||||
"github.com/uptrace/bun/driver/sqliteshim"
|
||||
"github.com/uptrace/bun/extra/bundebug"
|
||||
_ "modernc.org/sqlite"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
const (
|
||||
keyHTTPClient = "httpClient"
|
||||
keyDockerClient = "dockerClient"
|
||||
keyDB = "db"
|
||||
keyConfig = "config"
|
||||
keyMelody = "melody"
|
||||
)
|
||||
|
||||
type Services struct {
|
||||
HTTPClient *http.Client
|
||||
DockerClient *client.Client
|
||||
Database *bun.DB
|
||||
Config Config
|
||||
Melody *melody.Melody
|
||||
}
|
||||
|
||||
func HTTPClient(c echo.Context) *http.Client {
|
||||
return c.Get(keyHTTPClient).(*http.Client)
|
||||
}
|
||||
|
||||
func DockerClient(c echo.Context) *client.Client {
|
||||
return c.Get(keyDockerClient).(*client.Client)
|
||||
}
|
||||
|
||||
func Database(c echo.Context) *bun.DB {
|
||||
return c.Get(keyDB).(*bun.DB)
|
||||
}
|
||||
|
||||
func Initialize(config Config) (Services, error) {
|
||||
hc := &http.Client{}
|
||||
|
||||
docker, err := client.NewClientWithOpts(client.FromEnv)
|
||||
if err != nil {
|
||||
return Services{}, err
|
||||
}
|
||||
|
||||
db, err := sql.Open(sqliteshim.ShimName, config.DatabasePath)
|
||||
if err != nil {
|
||||
return Services{}, err
|
||||
}
|
||||
bundb := bun.NewDB(db, sqlitedialect.New())
|
||||
bundb.AddQueryHook(bundebug.NewQueryHook(bundebug.WithVerbose(true)))
|
||||
|
||||
return Services{
|
||||
HTTPClient: hc,
|
||||
DockerClient: docker,
|
||||
Database: bundb,
|
||||
Config: config,
|
||||
Melody: melody.New(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s Services) Middleware() echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
c.Set(keyHTTPClient, s.HTTPClient)
|
||||
c.Set(keyDockerClient, s.DockerClient)
|
||||
c.Set(keyDB, s.Database)
|
||||
c.Set(keyConfig, s.Config)
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}
|
111
internal/template/docker_template.go
Normal file
111
internal/template/docker_template.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package template
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/client"
|
||||
"github.com/google/uuid"
|
||||
"github.com/uptrace/bun"
|
||||
"io"
|
||||
"time"
|
||||
)
|
||||
|
||||
type createTemplateOptions struct {
|
||||
name string
|
||||
description string
|
||||
}
|
||||
|
||||
type templateBuildOptions struct {
|
||||
imageTag string
|
||||
buildArgs map[string]*string
|
||||
}
|
||||
|
||||
func createDockerTemplate(ctx context.Context, tx bun.Tx, opts createTemplateOptions) (*template, error) {
|
||||
id, err := uuid.NewV7()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
|
||||
t := template{
|
||||
ID: id,
|
||||
Name: opts.name,
|
||||
Description: opts.description,
|
||||
CreatedOn: now,
|
||||
LastModifiedOn: now,
|
||||
IsBuilt: false,
|
||||
}
|
||||
dockerfile := templateFile{
|
||||
TemplateID: id,
|
||||
FilePath: "Dockerfile",
|
||||
Content: make([]byte, 0),
|
||||
}
|
||||
readme := templateFile{
|
||||
TemplateID: id,
|
||||
FilePath: "README.md",
|
||||
Content: make([]byte, 0),
|
||||
}
|
||||
files := []templateFile{dockerfile, readme}
|
||||
|
||||
if err = tx.NewInsert().Model(&t).Returning("*").Scan(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err = tx.NewInsert().Model(&files).Scan(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
func buildDockerTemplate(ctx context.Context, docker *client.Client, tmpl *template, opts templateBuildOptions) (io.ReadCloser, error) {
|
||||
if len(tmpl.Files) == 0 {
|
||||
return nil, errors.New("cannot build docker template: no files in template")
|
||||
}
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
tw := tar.NewWriter(buf)
|
||||
defer tw.Close()
|
||||
|
||||
var dockerfile []byte
|
||||
for _, file := range tmpl.Files {
|
||||
if file.FilePath == "Dockerfile" {
|
||||
dockerfile = file.Content
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(dockerfile) == 0 {
|
||||
return nil, errors.New("cannot build docker template: template does not contain Dockerfile")
|
||||
}
|
||||
|
||||
h := tar.Header{
|
||||
Name: "Dockerfile",
|
||||
Size: int64(len(dockerfile)),
|
||||
}
|
||||
err := tw.WriteHeader(&h)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err = tw.Write(dockerfile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
r := bytes.NewReader(buf.Bytes())
|
||||
|
||||
res, err := docker.ImageBuild(ctx, r, types.ImageBuildOptions{
|
||||
Context: r,
|
||||
Tags: []string{opts.imageTag},
|
||||
BuildArgs: opts.buildArgs,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return res.Body, nil
|
||||
}
|
444
internal/template/http_handlers.go
Normal file
444
internal/template/http_handlers.go
Normal file
@@ -0,0 +1,444 @@
|
||||
package template
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/labstack/echo/v4"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"tesseract/internal/service"
|
||||
)
|
||||
|
||||
type createTemplateRequestBody struct {
|
||||
Description string `json:"description"`
|
||||
Content string `json:"content"`
|
||||
Documentation string `json:"documentation"`
|
||||
}
|
||||
|
||||
type updateTemplateRequestBody struct {
|
||||
Description *string `json:"description"`
|
||||
Files []templateFile `json:"files"`
|
||||
|
||||
ImageTag *string `json:"imageTag"`
|
||||
BuildArgs map[string]*string `json:"buildArgs"`
|
||||
}
|
||||
|
||||
type templateBuildLogEvent struct {
|
||||
Type string `json:"type"`
|
||||
LogContent string `json:"logContent"`
|
||||
}
|
||||
|
||||
type templateBuildFinishedEvent struct {
|
||||
Type string `json:"type"`
|
||||
Template *template `json:"template"`
|
||||
}
|
||||
|
||||
func fetchAllTemplates(c echo.Context) error {
|
||||
db := service.Database(c)
|
||||
|
||||
var templates []template
|
||||
err := db.NewSelect().Model(&templates).Scan(c.Request().Context())
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return c.JSON(http.StatusOK, make([]template, 0))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if len(templates) == 0 {
|
||||
return c.JSON(http.StatusOK, make([]template, 0))
|
||||
}
|
||||
return c.JSON(http.StatusOK, templates)
|
||||
}
|
||||
|
||||
func fetchTemplate(c echo.Context) error {
|
||||
db := service.Database(c)
|
||||
|
||||
name := c.Param("templateName")
|
||||
if strings.TrimSpace(name) == "" {
|
||||
return echo.NewHTTPError(http.StatusNotFound)
|
||||
}
|
||||
|
||||
var tmpl template
|
||||
err := db.NewSelect().Model(&tmpl).
|
||||
Relation("Files").
|
||||
Where("name = ?", name).
|
||||
Scan(c.Request().Context())
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return echo.NewHTTPError(http.StatusNotFound)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if len(tmpl.Files) > 0 {
|
||||
tmpl.FileMap = make(map[string]*templateFile)
|
||||
}
|
||||
for _, f := range tmpl.Files {
|
||||
tmpl.FileMap[f.FilePath] = f
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, tmpl)
|
||||
}
|
||||
|
||||
func createOrUpdateTemplate(c echo.Context) error {
|
||||
db := service.Database(c)
|
||||
|
||||
name := c.Param("templateName")
|
||||
if strings.TrimSpace(name) == "" {
|
||||
return echo.NewHTTPError(http.StatusNotFound)
|
||||
}
|
||||
|
||||
exists, err := db.NewSelect().
|
||||
Table("templates").
|
||||
Where("name = ?", name).
|
||||
Exists(c.Request().Context())
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return createTemplate(c)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return createTemplate(c)
|
||||
}
|
||||
|
||||
return updateTemplate(c)
|
||||
}
|
||||
|
||||
func createTemplate(c echo.Context) error {
|
||||
db := service.Database(c)
|
||||
name := c.Param("templateName")
|
||||
|
||||
var body createTemplateRequestBody
|
||||
err := json.NewDecoder(c.Request().Body).Decode(&body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tx, err := db.BeginTx(c.Request().Context(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
createdTemplate, err := createDockerTemplate(c.Request().Context(), tx, createTemplateOptions{
|
||||
name: name,
|
||||
description: body.Description,
|
||||
})
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
if err = tx.Commit(); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, createdTemplate)
|
||||
}
|
||||
|
||||
func updateTemplate(c echo.Context) error {
|
||||
db := service.Database(c)
|
||||
name := c.Param("templateName")
|
||||
|
||||
var body updateTemplateRequestBody
|
||||
err := json.NewDecoder(c.Request().Body).Decode(&body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if body.BuildArgs != nil && body.ImageTag == nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Image tag must be specified if buildArgs is passed")
|
||||
}
|
||||
|
||||
tx, err := db.BeginTx(c.Request().Context(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var tmpl template
|
||||
err = tx.NewSelect().Model(&tmpl).
|
||||
Where("name = ?", name).
|
||||
Scan(c.Request().Context())
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return echo.NewHTTPError(http.StatusNotFound)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if body.Description != nil {
|
||||
tmpl.Description = *body.Description
|
||||
_, err = tx.NewUpdate().Model(&tmpl).
|
||||
Column("description").
|
||||
WherePK().
|
||||
Exec(c.Request().Context())
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
if err = tx.Commit(); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if body.ImageTag != nil {
|
||||
err = tx.NewSelect().Model(&tmpl.Files).
|
||||
Where("template_id = ?", tmpl.ID).
|
||||
Scan(c.Request().Context())
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
docker := service.DockerClient(c)
|
||||
log, err := buildDockerTemplate(c.Request().Context(), docker, &tmpl, templateBuildOptions{
|
||||
imageTag: *body.ImageTag,
|
||||
buildArgs: body.BuildArgs,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
w := c.Response()
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
|
||||
scanner := bufio.NewScanner(log)
|
||||
|
||||
var imageID string
|
||||
|
||||
for scanner.Scan() {
|
||||
t := scanner.Text()
|
||||
|
||||
fmt.Println("DOCKER LOG: ", t)
|
||||
|
||||
var msg map[string]any
|
||||
err = json.Unmarshal([]byte(t), &msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if stream, ok := msg["stream"].(string); ok {
|
||||
if _, err = w.Write([]byte(stream)); err != nil {
|
||||
return err
|
||||
}
|
||||
w.Flush()
|
||||
} else if errmsg, ok := msg["error"].(string); ok {
|
||||
if _, err = w.Write([]byte(errmsg + "\n")); err != nil {
|
||||
return err
|
||||
}
|
||||
w.Flush()
|
||||
} else if status, ok := msg["status"].(string); ok {
|
||||
var text string
|
||||
if progress, ok := msg["progress"].(string); ok {
|
||||
text = fmt.Sprintf("%v: %v\n", status, progress)
|
||||
} else {
|
||||
text = status + "\n"
|
||||
}
|
||||
if _, err = w.Write([]byte(text)); err != nil {
|
||||
return err
|
||||
}
|
||||
w.Flush()
|
||||
} else if aux, ok := msg["aux"].(map[string]any); ok {
|
||||
if id, ok := aux["ID"].(string); ok {
|
||||
imageID = id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if imageID != "" {
|
||||
img := TemplateImage{
|
||||
TemplateID: tmpl.ID,
|
||||
ImageTag: *body.ImageTag,
|
||||
ImageID: imageID,
|
||||
}
|
||||
|
||||
_, err = tx.NewInsert().Model(&img).Exec(c.Request().Context())
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err = tx.Commit(); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if err = tx.Commit(); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, &tmpl)
|
||||
}
|
||||
|
||||
func deleteTemplate(c echo.Context) error {
|
||||
templateName := c.Param("templateName")
|
||||
if templateName == "" {
|
||||
return echo.NewHTTPError(http.StatusNotFound)
|
||||
}
|
||||
|
||||
db := service.Database(c)
|
||||
|
||||
tx, err := db.BeginTx(c.Request().Context(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
res, err := tx.NewDelete().Table("templates").
|
||||
Where("name = ?", templateName).
|
||||
Exec(c.Request().Context())
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return echo.NewHTTPError(http.StatusNotFound)
|
||||
}
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
count, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
if count != 1 {
|
||||
_ = tx.Rollback()
|
||||
return echo.NewHTTPError(http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
if err = tx.Commit(); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusOK)
|
||||
}
|
||||
|
||||
func fetchTemplateFile(c echo.Context) error {
|
||||
templateName := c.Param("templateName")
|
||||
if templateName == "" {
|
||||
return echo.NewHTTPError(http.StatusNotFound)
|
||||
}
|
||||
|
||||
filePath := c.Param("filePath")
|
||||
if filePath == "" {
|
||||
return echo.NewHTTPError(http.StatusNotFound)
|
||||
}
|
||||
|
||||
db := service.Database(c)
|
||||
|
||||
var tmpl template
|
||||
err := db.NewSelect().Model(&tmpl).
|
||||
Column("id").
|
||||
Where("name = ?", templateName).
|
||||
Scan(c.Request().Context())
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return echo.NewHTTPError(http.StatusNotFound)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
var file templateFile
|
||||
err = db.NewSelect().Model(&file).
|
||||
Where("template_id = ?", tmpl.ID).
|
||||
Where("file_path = ?", filePath).
|
||||
Scan(c.Request().Context())
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return echo.NewHTTPError(http.StatusNotFound)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
return c.Blob(http.StatusOK, "application/octet-stream", file.Content)
|
||||
}
|
||||
|
||||
func updateTemplateFile(c echo.Context) error {
|
||||
templateName := c.Param("templateName")
|
||||
if templateName == "" {
|
||||
return echo.NewHTTPError(http.StatusNotFound)
|
||||
}
|
||||
|
||||
filePath := c.Param("filePath")
|
||||
if filePath == "" {
|
||||
return echo.NewHTTPError(http.StatusNotFound)
|
||||
}
|
||||
|
||||
db := service.Database(c)
|
||||
|
||||
tx, err := db.BeginTx(c.Request().Context(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var tmpl template
|
||||
err = tx.NewSelect().Model(&tmpl).
|
||||
Column("id").
|
||||
Where("name = ?", templateName).
|
||||
Scan(c.Request().Context())
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return echo.NewHTTPError(http.StatusNotFound)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
newContent, err := io.ReadAll(c.Request().Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.NewUpdate().Table("template_files").
|
||||
Set("content = ?", newContent).
|
||||
Where("template_id = ?", tmpl.ID).
|
||||
Where("file_path = ?", filePath).
|
||||
Exec(c.Request().Context())
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return echo.NewHTTPError(http.StatusNotFound)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if err = tx.Commit(); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusOK)
|
||||
}
|
||||
|
||||
func fetchAllTemplateImages(c echo.Context) error {
|
||||
db := service.Database(c)
|
||||
|
||||
var images []TemplateImage
|
||||
err := db.NewSelect().Model(&images).Scan(c.Request().Context())
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return c.JSON(http.StatusOK, make([]TemplateImage, 0))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if len(images) == 0 {
|
||||
return c.JSON(http.StatusOK, make([]TemplateImage, 0))
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, images)
|
||||
}
|
15
internal/template/routes.go
Normal file
15
internal/template/routes.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package template
|
||||
|
||||
import (
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
func DefineRoutes(g *echo.Group) {
|
||||
g.GET("/templates", fetchAllTemplates)
|
||||
g.GET("/templates/:templateName", fetchTemplate)
|
||||
g.POST("/templates/:templateName", createOrUpdateTemplate)
|
||||
g.DELETE("/templates/:templateName", deleteTemplate)
|
||||
g.GET("/templates/:templateName/:filePath", fetchTemplateFile)
|
||||
g.POST("/templates/:templateName/:filePath", updateTemplateFile)
|
||||
g.GET("/template-images", fetchAllTemplateImages)
|
||||
}
|
36
internal/template/template.go
Normal file
36
internal/template/template.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package template
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
type template struct {
|
||||
bun.BaseModel `bun:"table:templates,alias:template"`
|
||||
|
||||
ID uuid.UUID `bun:"type:uuid,pk" json:"-"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
CreatedOn string `json:"createdOn"`
|
||||
LastModifiedOn string `json:"lastModifiedOn"`
|
||||
IsBuilt bool `json:"isBuilt"`
|
||||
|
||||
Files []*templateFile `bun:"rel:has-many,join:id=template_id" json:"-"`
|
||||
FileMap map[string]*templateFile `bun:"-" json:"files"`
|
||||
}
|
||||
|
||||
type templateFile struct {
|
||||
bun.BaseModel `bun:"table:template_files,alias:template_file"`
|
||||
|
||||
TemplateID uuid.UUID `bun:"type:uuid" json:"-"`
|
||||
FilePath string `json:"path"`
|
||||
Content []byte `bun:"type:blob" json:"content"`
|
||||
}
|
||||
|
||||
type TemplateImage struct {
|
||||
bun.BaseModel `bun:"table:template_images,alias:template_images"`
|
||||
|
||||
TemplateID uuid.UUID `bun:"type:uuid" json:"-"`
|
||||
ImageTag string `json:"imageTag"`
|
||||
ImageID string `json:"imageId"`
|
||||
}
|
110
internal/workspace/http_handlers.go
Normal file
110
internal/workspace/http_handlers.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package workspace
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/google/uuid"
|
||||
"github.com/labstack/echo/v4"
|
||||
"net/http"
|
||||
"tesseract/internal/service"
|
||||
"tesseract/internal/template"
|
||||
"time"
|
||||
)
|
||||
|
||||
type createWorkspaceRequestBody struct {
|
||||
ImageID string `json:"imageId"`
|
||||
}
|
||||
|
||||
func fetchAllWorkspaces(c echo.Context) error {
|
||||
db := service.Database(c)
|
||||
|
||||
var workspaces []workspace
|
||||
err := db.NewSelect().Model(&workspaces).Scan(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))
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, workspaces)
|
||||
}
|
||||
|
||||
func createWorkspace(c echo.Context) error {
|
||||
workspaceName := c.Param("workspaceName")
|
||||
if workspaceName == "" {
|
||||
return echo.NewHTTPError(http.StatusNotFound)
|
||||
}
|
||||
|
||||
if !workspaceNameRegex.MatchString(workspaceName) {
|
||||
return echo.NewHTTPError(http.StatusNotFound)
|
||||
}
|
||||
|
||||
body := createWorkspaceRequestBody{}
|
||||
err := json.NewDecoder(c.Request().Body).Decode(&body)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest)
|
||||
}
|
||||
|
||||
db := service.Database(c)
|
||||
|
||||
tx, err := db.BeginTx(c.Request().Context(), nil)
|
||||
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")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
docker := service.DockerClient(c)
|
||||
|
||||
res, err := docker.ContainerCreate(c.Request().Context(), &container.Config{
|
||||
Image: img.ImageID,
|
||||
}, nil, nil, nil, workspaceName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = docker.ContainerStart(c.Request().Context(), res.ID, container.StartOptions{})
|
||||
if 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),
|
||||
}
|
||||
_, 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)
|
||||
}
|
10
internal/workspace/routes.go
Normal file
10
internal/workspace/routes.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package workspace
|
||||
|
||||
import (
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
func DefineRoutes(g *echo.Group) {
|
||||
g.GET("/workspaces", fetchAllWorkspaces)
|
||||
g.POST("/workspaces/:workspaceName", createWorkspace)
|
||||
}
|
24
internal/workspace/workspace.go
Normal file
24
internal/workspace/workspace.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package workspace
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
"github.com/uptrace/bun"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
type workspace struct {
|
||||
bun.BaseModel `bun:"table:workspaces,alias:workspace"`
|
||||
|
||||
ID uuid.UUID `bun:",type:uuid,pk"`
|
||||
|
||||
Name string `json:"name"`
|
||||
|
||||
// containerId is the ID of the docker container
|
||||
ContainerID string `json:"containerId"`
|
||||
|
||||
ImageTag string `json:"imageTag"`
|
||||
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
var workspaceNameRegex = regexp.MustCompile("^[\\w-]+$")
|
Reference in New Issue
Block a user