forked from wrenn/wrenn
Implement three new recipe commands for the admin template builder: - USER <name>: creates the user (adduser + passwordless sudo), switches execution context so subsequent RUN/START commands run as that user via su wrapping. Last USER becomes the template's default_user. - COPY <src> <dst>: copies files from an uploaded build archive (tar/tar.gz/zip) into the sandbox. Source paths validated against traversal. Ownership set to the current USER. - ENV persistence: accumulated env vars stored in templates.default_env (JSONB) and injected via PostInit when sandboxes are created from the template, mirroring Docker's image metadata approach. Supporting changes: - Pre-build creates wrenn-user as default (via USER command) - WORKDIR now creates the directory if it doesn't exist (mkdir -p) - Per-step progress updates (ProgressFunc callback) for live UI - Multipart form support on POST /v1/admin/builds for archive upload - Proto: default_user/default_env fields on Create/ResumeSandboxRequest - Host agent: SetDefaults calls PostInitWithDefaults on envd - Control plane: reads template defaults, passes on sandbox create/resume - Frontend: file upload widget, recipe copy button, keyword colors for USER/COPY, fixed Svelte whitespace stripping in step display - Admin panel defaults to /admin/templates instead of /admin/hosts - Migration adds default_user and default_env to templates and template_builds tables
328 lines
9.6 KiB
Go
328 lines
9.6 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"connectrpc.com/connect"
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"git.omukk.dev/wrenn/wrenn/internal/db"
|
|
"git.omukk.dev/wrenn/wrenn/internal/id"
|
|
"git.omukk.dev/wrenn/wrenn/internal/layout"
|
|
"git.omukk.dev/wrenn/wrenn/internal/lifecycle"
|
|
"git.omukk.dev/wrenn/wrenn/internal/service"
|
|
"git.omukk.dev/wrenn/wrenn/internal/validate"
|
|
pb "git.omukk.dev/wrenn/wrenn/proto/hostagent/gen"
|
|
)
|
|
|
|
type buildHandler struct {
|
|
svc *service.BuildService
|
|
db *db.Queries
|
|
pool *lifecycle.HostClientPool
|
|
}
|
|
|
|
func newBuildHandler(svc *service.BuildService, db *db.Queries, pool *lifecycle.HostClientPool) *buildHandler {
|
|
return &buildHandler{svc: svc, db: db, pool: pool}
|
|
}
|
|
|
|
type createBuildRequest struct {
|
|
Name string `json:"name"`
|
|
BaseTemplate string `json:"base_template"`
|
|
Recipe []string `json:"recipe"`
|
|
Healthcheck string `json:"healthcheck"`
|
|
VCPUs int32 `json:"vcpus"`
|
|
MemoryMB int32 `json:"memory_mb"`
|
|
SkipPrePost bool `json:"skip_pre_post"`
|
|
}
|
|
|
|
type buildResponse struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
BaseTemplate string `json:"base_template"`
|
|
Recipe json.RawMessage `json:"recipe"`
|
|
Healthcheck *string `json:"healthcheck,omitempty"`
|
|
VCPUs int32 `json:"vcpus"`
|
|
MemoryMB int32 `json:"memory_mb"`
|
|
Status string `json:"status"`
|
|
CurrentStep int32 `json:"current_step"`
|
|
TotalSteps int32 `json:"total_steps"`
|
|
Logs json.RawMessage `json:"logs"`
|
|
Error *string `json:"error,omitempty"`
|
|
SandboxID *string `json:"sandbox_id,omitempty"`
|
|
HostID *string `json:"host_id,omitempty"`
|
|
DefaultUser string `json:"default_user"`
|
|
DefaultEnv json.RawMessage `json:"default_env"`
|
|
CreatedAt string `json:"created_at"`
|
|
StartedAt *string `json:"started_at,omitempty"`
|
|
CompletedAt *string `json:"completed_at,omitempty"`
|
|
}
|
|
|
|
func buildToResponse(b db.TemplateBuild) buildResponse {
|
|
resp := buildResponse{
|
|
ID: id.FormatBuildID(b.ID),
|
|
Name: b.Name,
|
|
BaseTemplate: b.BaseTemplate,
|
|
Recipe: b.Recipe,
|
|
VCPUs: b.Vcpus,
|
|
MemoryMB: b.MemoryMb,
|
|
Status: b.Status,
|
|
CurrentStep: b.CurrentStep,
|
|
TotalSteps: b.TotalSteps,
|
|
Logs: b.Logs,
|
|
DefaultUser: b.DefaultUser,
|
|
DefaultEnv: b.DefaultEnv,
|
|
}
|
|
if b.Healthcheck != "" {
|
|
resp.Healthcheck = &b.Healthcheck
|
|
}
|
|
if b.Error != "" {
|
|
resp.Error = &b.Error
|
|
}
|
|
if b.SandboxID.Valid {
|
|
s := id.FormatSandboxID(b.SandboxID)
|
|
resp.SandboxID = &s
|
|
}
|
|
if b.HostID.Valid {
|
|
s := id.FormatHostID(b.HostID)
|
|
resp.HostID = &s
|
|
}
|
|
if b.CreatedAt.Valid {
|
|
resp.CreatedAt = b.CreatedAt.Time.Format(time.RFC3339)
|
|
}
|
|
if b.StartedAt.Valid {
|
|
s := b.StartedAt.Time.Format(time.RFC3339)
|
|
resp.StartedAt = &s
|
|
}
|
|
if b.CompletedAt.Valid {
|
|
s := b.CompletedAt.Time.Format(time.RFC3339)
|
|
resp.CompletedAt = &s
|
|
}
|
|
return resp
|
|
}
|
|
|
|
// Create handles POST /v1/admin/builds.
|
|
// Accepts either JSON body or multipart/form-data with a "config" JSON part
|
|
// and an optional "archive" file part (tar/tar.gz/zip for COPY commands).
|
|
func (h *buildHandler) Create(w http.ResponseWriter, r *http.Request) {
|
|
var req createBuildRequest
|
|
var archive []byte
|
|
var archiveName string
|
|
|
|
ct := r.Header.Get("Content-Type")
|
|
if strings.HasPrefix(ct, "multipart/") {
|
|
// 100 MB max for multipart (archive + JSON config).
|
|
if err := r.ParseMultipartForm(100 << 20); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_request", "failed to parse multipart form")
|
|
return
|
|
}
|
|
|
|
// Parse JSON config from "config" field.
|
|
configStr := r.FormValue("config")
|
|
if configStr == "" {
|
|
writeError(w, http.StatusBadRequest, "invalid_request", "multipart form requires a 'config' JSON field")
|
|
return
|
|
}
|
|
if err := json.Unmarshal([]byte(configStr), &req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_request", "invalid config JSON in multipart form")
|
|
return
|
|
}
|
|
|
|
// Read optional archive file (max 100 MB).
|
|
file, header, err := r.FormFile("archive")
|
|
if err == nil {
|
|
defer file.Close()
|
|
const maxArchiveSize = 100 << 20 // 100 MB
|
|
lr := io.LimitReader(file, maxArchiveSize+1)
|
|
archive, err = io.ReadAll(lr)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_request", "failed to read archive file")
|
|
return
|
|
}
|
|
if int64(len(archive)) > maxArchiveSize {
|
|
writeError(w, http.StatusRequestEntityTooLarge, "invalid_request", "archive exceeds 100 MB limit")
|
|
return
|
|
}
|
|
archiveName = header.Filename
|
|
}
|
|
} else {
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_request", "invalid JSON body")
|
|
return
|
|
}
|
|
}
|
|
|
|
if req.Name == "" {
|
|
writeError(w, http.StatusBadRequest, "invalid_request", "name is required")
|
|
return
|
|
}
|
|
if err := validate.SafeName(req.Name); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_request", fmt.Sprintf("invalid template name: %s", err))
|
|
return
|
|
}
|
|
if len(req.Recipe) == 0 {
|
|
writeError(w, http.StatusBadRequest, "invalid_request", "recipe must contain at least one command")
|
|
return
|
|
}
|
|
|
|
build, err := h.svc.Create(r.Context(), service.BuildCreateParams{
|
|
Name: req.Name,
|
|
BaseTemplate: req.BaseTemplate,
|
|
Recipe: req.Recipe,
|
|
Healthcheck: req.Healthcheck,
|
|
VCPUs: req.VCPUs,
|
|
MemoryMB: req.MemoryMB,
|
|
SkipPrePost: req.SkipPrePost,
|
|
Archive: archive,
|
|
ArchiveName: archiveName,
|
|
})
|
|
if err != nil {
|
|
slog.Error("failed to create build", "error", err)
|
|
writeError(w, http.StatusInternalServerError, "build_error", "failed to create build")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusCreated, buildToResponse(build))
|
|
}
|
|
|
|
// List handles GET /v1/admin/builds.
|
|
func (h *buildHandler) List(w http.ResponseWriter, r *http.Request) {
|
|
builds, err := h.svc.List(r.Context())
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "db_error", "failed to list builds")
|
|
return
|
|
}
|
|
|
|
resp := make([]buildResponse, len(builds))
|
|
for i, b := range builds {
|
|
resp[i] = buildToResponse(b)
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
// Get handles GET /v1/admin/builds/{id}.
|
|
func (h *buildHandler) Get(w http.ResponseWriter, r *http.Request) {
|
|
buildIDStr := chi.URLParam(r, "id")
|
|
|
|
buildID, err := id.ParseBuildID(buildIDStr)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_request", "invalid build ID")
|
|
return
|
|
}
|
|
|
|
build, err := h.svc.Get(r.Context(), buildID)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "not_found", "build not found")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, buildToResponse(build))
|
|
}
|
|
|
|
// ListTemplates handles GET /v1/admin/templates — returns all templates across all teams.
|
|
func (h *buildHandler) ListTemplates(w http.ResponseWriter, r *http.Request) {
|
|
templates, err := h.db.ListTemplates(r.Context())
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "db_error", "failed to list templates")
|
|
return
|
|
}
|
|
|
|
type templateResponse struct {
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
VCPUs int32 `json:"vcpus"`
|
|
MemoryMB int32 `json:"memory_mb"`
|
|
SizeBytes int64 `json:"size_bytes"`
|
|
TeamID string `json:"team_id"`
|
|
CreatedAt string `json:"created_at"`
|
|
}
|
|
|
|
resp := make([]templateResponse, len(templates))
|
|
for i, t := range templates {
|
|
resp[i] = templateResponse{
|
|
Name: t.Name,
|
|
Type: t.Type,
|
|
VCPUs: t.Vcpus,
|
|
MemoryMB: t.MemoryMb,
|
|
SizeBytes: t.SizeBytes,
|
|
TeamID: id.FormatTeamID(t.TeamID),
|
|
}
|
|
if t.CreatedAt.Valid {
|
|
resp[i].CreatedAt = t.CreatedAt.Time.Format(time.RFC3339)
|
|
}
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
// DeleteTemplate handles DELETE /v1/admin/templates/{name}.
|
|
func (h *buildHandler) DeleteTemplate(w http.ResponseWriter, r *http.Request) {
|
|
name := chi.URLParam(r, "name")
|
|
if err := validate.SafeName(name); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_request", fmt.Sprintf("invalid template name: %s", err))
|
|
return
|
|
}
|
|
ctx := r.Context()
|
|
|
|
tmpl, err := h.db.GetPlatformTemplateByName(ctx, name)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "not_found", "template not found")
|
|
return
|
|
}
|
|
if layout.IsMinimal(tmpl.TeamID, tmpl.ID) {
|
|
writeError(w, http.StatusForbidden, "forbidden", "the minimal template cannot be deleted")
|
|
return
|
|
}
|
|
|
|
// Broadcast delete to all online hosts.
|
|
hosts, _ := h.db.ListActiveHosts(ctx)
|
|
for _, host := range hosts {
|
|
if host.Status != "online" {
|
|
continue
|
|
}
|
|
agent, err := h.pool.GetForHost(host)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if _, err := agent.DeleteSnapshot(ctx, connect.NewRequest(&pb.DeleteSnapshotRequest{
|
|
TeamId: formatUUIDForRPC(tmpl.TeamID),
|
|
TemplateId: formatUUIDForRPC(tmpl.ID),
|
|
})); err != nil {
|
|
if connect.CodeOf(err) != connect.CodeNotFound {
|
|
slog.Warn("admin: failed to delete template on host", "host_id", id.FormatHostID(host.ID), "name", name, "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
if err := h.db.DeleteTemplate(ctx, tmpl.ID); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "db_error", "failed to delete template record")
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// Cancel handles POST /v1/admin/builds/{id}/cancel.
|
|
func (h *buildHandler) Cancel(w http.ResponseWriter, r *http.Request) {
|
|
buildIDStr := chi.URLParam(r, "id")
|
|
|
|
buildID, err := id.ParseBuildID(buildIDStr)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_request", "invalid build ID")
|
|
return
|
|
}
|
|
|
|
if err := h.svc.Cancel(r.Context(), buildID); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|