Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ func main() {

db := database.New("sandbox.db")
repo := database.NewRepository(db)
dc := docker.New(repo)
dc := docker.New(docker.WithRepository(repo))

// --- Reverse proxy (multi-listen) ---
proxyServer := proxy.New(cfg.BaseDomain, repo)
Expand Down
109 changes: 109 additions & 0 deletions cmd/orchestrator/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package main

import (
"context"
"log"
"net/http"
"os/signal"
"strings"
"syscall"
"time"

"github.com/gin-gonic/gin"
"open-sandbox/internal/api"
"open-sandbox/internal/config"
"open-sandbox/internal/database"
"open-sandbox/internal/orchestrator"
"open-sandbox/internal/proxy"
"open-sandbox/internal/worker"
)

func main() {
cfg := config.LoadOrchestrator()

db := database.New("sandbox.db")
repo := database.NewRepository(db)

// Worker registry (loads active workers from DB).
registry := orchestrator.NewRegistry(repo)

// RemoteDockerClient — implements DockerClient via HTTP to workers.
dc := orchestrator.NewRemoteClient(registry, repo)

// --- Reverse proxy (multi-listen) ---
proxyServer := proxy.New(cfg.BaseDomain, repo)
proxyHandler := proxyServer.Handler()

var proxySrvs []*http.Server
for _, addr := range cfg.ProxyAddrs {
srv := &http.Server{Addr: addr, Handler: proxyHandler}
proxySrvs = append(proxySrvs, srv)
go func(a string) {
log.Printf("proxy listening on %s (domain: *.%s)", a, cfg.BaseDomain)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("proxy listen %s: %v", a, err)
}
}(addr)
}
log.Printf("proxy URLs via %s", strings.Join(cfg.ProxyAddrs, ", "))

// --- API server ---
r := gin.New()
r.Use(gin.Logger(), gin.Recovery())

// Public API (same as all-in-one, but backed by RemoteDockerClient).
v1 := r.Group("/v1")
if cfg.APIKey != "" {
v1.Use(api.APIKeyAuth(cfg.APIKey))
}

h := api.New(dc, cfg.BaseDomain, cfg.PrimaryProxyAddr())
h.RegisterHealthCheck(r)
h.RegisterRoutes(v1)

// Internal API for worker registration.
internal := r.Group("/internal/v1")
if cfg.WorkerKey != "" {
internal.Use(worker.APIKeyAuth(cfg.WorkerKey))
}

oh := orchestrator.NewHandler(registry)
oh.RegisterRoutes(internal)

r.NoRoute(func(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{
"code": "NOT_FOUND",
"message": "route not found",
})
})

// Graceful shutdown.
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()

srv := &http.Server{Addr: cfg.Addr, Handler: r}

go func() {
log.Printf("orchestrator API listening on %s", cfg.Addr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("api listen: %v", err)
}
}()

<-ctx.Done()
log.Println("shutting down orchestrator...")

shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

for _, ps := range proxySrvs {
if err := ps.Shutdown(shutdownCtx); err != nil {
log.Printf("proxy shutdown %s: %v", ps.Addr, err)
}
}
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Fatalf("api shutdown: %v", err)
}

log.Println("orchestrator stopped")
}
152 changes: 152 additions & 0 deletions cmd/worker/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package main

import (
"bytes"
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"os/signal"
"syscall"
"time"

"github.com/gin-gonic/gin"
"open-sandbox/internal/config"
"open-sandbox/internal/docker"
"open-sandbox/internal/worker"
)

func main() {
cfg := config.LoadWorker()

dc := docker.New(docker.WithHostIP(cfg.HostIP))

r := gin.New()
r.Use(gin.Logger(), gin.Recovery())

internal := r.Group("/internal/v1")
if cfg.APIKey != "" {
internal.Use(worker.APIKeyAuth(cfg.APIKey))
}

h := worker.NewHandler(dc)
h.RegisterRoutes(internal)

r.NoRoute(func(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{
"code": "NOT_FOUND",
"message": "route not found",
})
})

// Graceful shutdown context.
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()

// Self-register with orchestrator if configured.
var workerID string
if cfg.OrchestratorURL != "" && cfg.APIKey != "" {
var err error
workerID, err = registerWorker(cfg)
if err != nil {
log.Fatalf("worker registration failed: %v", err)
}
log.Printf("registered with orchestrator as worker %s", workerID)
} else {
log.Println("no orchestrator configured, running standalone")
}

srv := &http.Server{Addr: cfg.Addr, Handler: r}

go func() {
log.Printf("worker listening on %s (host_ip: %s)", cfg.Addr, cfg.HostIP)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("worker listen: %v", err)
}
}()

<-ctx.Done()
log.Println("shutting down worker...")

shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

// Deregister from orchestrator.
if workerID != "" && cfg.OrchestratorURL != "" {
if err := deregisterWorker(cfg, workerID); err != nil {
log.Printf("worker deregistration failed: %v", err)
} else {
log.Printf("deregistered worker %s", workerID)
}
}

dc.Shutdown(shutdownCtx)
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Fatalf("worker shutdown: %v", err)
}

log.Println("worker stopped")
}

// registerWorker calls POST {orchestratorURL}/internal/v1/workers/register.
func registerWorker(cfg *config.WorkerConfig) (string, error) {
body, _ := json.Marshal(map[string]string{
"url": workerURL(cfg),
})

req, err := http.NewRequest(http.MethodPost, cfg.OrchestratorURL+"/internal/v1/workers/register", bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Worker-Key", cfg.APIKey)

resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("connect to orchestrator: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return "", fmt.Errorf("orchestrator returned %d", resp.StatusCode)
}

var result struct {
WorkerID string `json:"worker_id"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", fmt.Errorf("decode response: %w", err)
}
return result.WorkerID, nil
}

// deregisterWorker calls DELETE {orchestratorURL}/internal/v1/workers/{id}.
func deregisterWorker(cfg *config.WorkerConfig, workerID string) error {
req, err := http.NewRequest(http.MethodDelete, cfg.OrchestratorURL+"/internal/v1/workers/"+workerID, nil)
if err != nil {
return err
}
req.Header.Set("X-Worker-Key", cfg.APIKey)

resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
return fmt.Errorf("orchestrator returned %d", resp.StatusCode)
}
return nil
}

// workerURL builds the worker's externally reachable URL from its config.
func workerURL(cfg *config.WorkerConfig) string {
// If addr starts with ":", prepend the hostname or default to the host IP.
addr := cfg.Addr
if addr[0] == ':' {
addr = cfg.HostIP + addr
}
return "http://" + addr
}
14 changes: 13 additions & 1 deletion docs/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,14 +54,22 @@ const docTemplate = `{
"ApiKeyAuth": []
}
],
"description": "Returns all Docker images available locally.",
"description": "Returns all Docker images available locally. In distributed mode, use ?worker_id= to filter by worker.",
"produces": [
"application/json"
],
"tags": [
"images"
],
"summary": "List local images",
"parameters": [
{
"type": "string",
"description": "Filter images by worker ID (distributed mode only)",
"name": "worker_id",
"in": "query"
}
],
"responses": {
"200": {
"description": "List of images",
Expand Down Expand Up @@ -1392,6 +1400,10 @@ const docTemplate = `{
"type": "string",
"example": "node:24"
},
"name": {
"description": "pre-generated name (used by orchestrator → worker). Empty = auto-generate.",
"type": "string"
},
"ports": {
"description": "container ports to expose, e.g. [\"3000\", \"8080/tcp\"]. First port is the default for proxy routing.",
"type": "array",
Expand Down
14 changes: 13 additions & 1 deletion docs/swagger.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,14 +48,22 @@
"ApiKeyAuth": []
}
],
"description": "Returns all Docker images available locally.",
"description": "Returns all Docker images available locally. In distributed mode, use ?worker_id= to filter by worker.",
"produces": [
"application/json"
],
"tags": [
"images"
],
"summary": "List local images",
"parameters": [
{
"type": "string",
"description": "Filter images by worker ID (distributed mode only)",
"name": "worker_id",
"in": "query"
}
],
"responses": {
"200": {
"description": "List of images",
Expand Down Expand Up @@ -1386,6 +1394,10 @@
"type": "string",
"example": "node:24"
},
"name": {
"description": "pre-generated name (used by orchestrator → worker). Empty = auto-generate.",
"type": "string"
},
"ports": {
"description": "container ports to expose, e.g. [\"3000\", \"8080/tcp\"]. First port is the default for proxy routing.",
"type": "array",
Expand Down
11 changes: 10 additions & 1 deletion docs/swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ definitions:
image:
example: node:24
type: string
name:
description: pre-generated name (used by orchestrator → worker). Empty = auto-generate.
type: string
ports:
description: container ports to expose, e.g. ["3000", "8080/tcp"]. First port
is the default for proxy routing.
Expand Down Expand Up @@ -323,7 +326,13 @@ paths:
- system
/images:
get:
description: Returns all Docker images available locally.
description: Returns all Docker images available locally. In distributed mode,
use ?worker_id= to filter by worker.
parameters:
- description: Filter images by worker ID (distributed mode only)
in: query
name: worker_id
type: string
produces:
- application/json
responses:
Expand Down
Loading