791cd4d6af
Build / build (push) Successful in 12m20s
Rebrand the project as Tinyforge to reflect its evolution from a Docker container watcher into a self-hosted mini CI/deployment platform. Rename covers: Go module path, Docker labels, DB/config filenames, JWT issuer, Dockerfile binary, docker-compose, CI workflows, frontend i18n, README with static sites docs, and all code comments.
304 lines
8.1 KiB
Go
304 lines
8.1 KiB
Go
package api
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"github.com/alexei/tinyforge/internal/store"
|
|
)
|
|
|
|
// listProjectImages handles GET /api/projects/{id}/images.
|
|
// Returns all local Docker images matching the project's image reference.
|
|
func (s *Server) listProjectImages(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
|
|
project, err := s.store.GetProjectByID(id)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
respondNotFound(w, "project")
|
|
return
|
|
}
|
|
slog.Error("failed to get project", "error", err)
|
|
respondError(w, http.StatusInternalServerError, "internal server error")
|
|
return
|
|
}
|
|
|
|
if s.docker == nil || project.Image == "" {
|
|
respondJSON(w, http.StatusOK, []any{})
|
|
return
|
|
}
|
|
|
|
images, err := s.docker.ListImagesByRef(r.Context(), project.Image)
|
|
if err != nil {
|
|
slog.Warn("list project images", "project", project.Name, "error", err)
|
|
respondJSON(w, http.StatusOK, []any{})
|
|
return
|
|
}
|
|
|
|
respondJSON(w, http.StatusOK, images)
|
|
}
|
|
|
|
// streamContainerLogs handles GET /api/projects/{id}/stages/{stage}/instances/{iid}/logs.
|
|
// Streams container logs via SSE. Supports query params:
|
|
// - tail: number of lines from end (default "200")
|
|
// - follow: "true" to stream new lines in real-time
|
|
func (s *Server) streamContainerLogs(w http.ResponseWriter, r *http.Request) {
|
|
instanceID := chi.URLParam(r, "iid")
|
|
|
|
inst, err := s.store.GetInstanceByID(instanceID)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
respondNotFound(w, "instance")
|
|
return
|
|
}
|
|
slog.Error("failed to get instance", "error", err)
|
|
respondError(w, http.StatusInternalServerError, "internal server error")
|
|
return
|
|
}
|
|
|
|
if inst.ContainerID == "" {
|
|
respondError(w, http.StatusBadRequest, "instance has no container")
|
|
return
|
|
}
|
|
|
|
if s.docker == nil {
|
|
respondError(w, http.StatusServiceUnavailable, "Docker is not available")
|
|
return
|
|
}
|
|
|
|
tail := r.URL.Query().Get("tail")
|
|
if tail == "" {
|
|
tail = "200"
|
|
}
|
|
follow := r.URL.Query().Get("follow") == "true"
|
|
|
|
// Check if client accepts SSE.
|
|
accept := r.Header.Get("Accept")
|
|
isSSE := strings.Contains(accept, "text/event-stream")
|
|
|
|
logReader, err := s.docker.ContainerLogs(r.Context(), inst.ContainerID, follow && isSSE, tail)
|
|
if err != nil {
|
|
slog.Error("failed to get container logs", "instance", instanceID, "error", err)
|
|
respondError(w, http.StatusInternalServerError, "failed to get container logs")
|
|
return
|
|
}
|
|
defer logReader.Close()
|
|
|
|
if !isSSE {
|
|
// JSON mode: read all lines and return as array.
|
|
scanner := bufio.NewScanner(logReader)
|
|
var lines []string
|
|
for scanner.Scan() {
|
|
line := sanitizeDockerLogLine(scanner.Text())
|
|
if line != "" {
|
|
lines = append(lines, line)
|
|
}
|
|
}
|
|
if lines == nil {
|
|
lines = []string{}
|
|
}
|
|
respondJSON(w, http.StatusOK, lines)
|
|
return
|
|
}
|
|
|
|
// SSE mode: stream lines as they arrive.
|
|
flusher, ok := w.(http.Flusher)
|
|
if !ok {
|
|
respondError(w, http.StatusInternalServerError, "streaming not supported")
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
w.Header().Set("Connection", "keep-alive")
|
|
|
|
scanner := bufio.NewScanner(logReader)
|
|
for scanner.Scan() {
|
|
line := sanitizeDockerLogLine(scanner.Text())
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
data, _ := json.Marshal(map[string]string{"line": line})
|
|
fmt.Fprintf(w, "data: %s\n\n", data)
|
|
flusher.Flush()
|
|
|
|
// Check if client disconnected.
|
|
select {
|
|
case <-r.Context().Done():
|
|
return
|
|
default:
|
|
}
|
|
}
|
|
}
|
|
|
|
// sanitizeDockerLogLine strips the Docker log stream header (8-byte prefix)
|
|
// that Docker adds to non-TTY container logs.
|
|
func sanitizeDockerLogLine(line string) string {
|
|
// Docker multiplexed stream: first 8 bytes are header (stream type + size).
|
|
// If the line starts with a non-printable byte followed by 0x00 0x00 0x00, strip 8 bytes.
|
|
if len(line) > 8 && (line[0] == 1 || line[0] == 2) && line[1] == 0 && line[2] == 0 && line[3] == 0 {
|
|
return line[8:]
|
|
}
|
|
return line
|
|
}
|
|
|
|
// unusedImageStats handles GET /api/docker/unused-images.
|
|
// Returns the total size of unused project images and whether the threshold is exceeded.
|
|
func (s *Server) unusedImageStats(w http.ResponseWriter, r *http.Request) {
|
|
if s.docker == nil {
|
|
respondJSON(w, http.StatusOK, map[string]any{
|
|
"total_size_mb": 0, "count": 0, "threshold_mb": 0, "exceeded": false,
|
|
})
|
|
return
|
|
}
|
|
|
|
settings, err := s.store.GetSettings()
|
|
if err != nil {
|
|
slog.Error("unused images: get settings", "error", err)
|
|
respondError(w, http.StatusInternalServerError, "internal server error")
|
|
return
|
|
}
|
|
|
|
projects, err := s.store.GetAllProjects()
|
|
if err != nil {
|
|
slog.Error("unused images: list projects", "error", err)
|
|
respondError(w, http.StatusInternalServerError, "internal server error")
|
|
return
|
|
}
|
|
|
|
// Build set of active image refs.
|
|
activeImages := make(map[string]bool)
|
|
for _, p := range projects {
|
|
stages, _ := s.store.GetStagesByProjectID(p.ID)
|
|
for _, st := range stages {
|
|
instances, _ := s.store.GetInstancesByStageID(st.ID)
|
|
for _, inst := range instances {
|
|
if inst.ImageTag != "" {
|
|
activeImages[p.Image+":"+inst.ImageTag] = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sum unused image sizes.
|
|
ctx := r.Context()
|
|
var totalSize int64
|
|
var count int
|
|
for _, p := range projects {
|
|
if p.Image == "" {
|
|
continue
|
|
}
|
|
images, err := s.docker.ListImagesByRef(ctx, p.Image)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, img := range images {
|
|
if !activeImages[img.Ref] {
|
|
totalSize += img.Size
|
|
count++
|
|
}
|
|
}
|
|
}
|
|
|
|
totalMB := totalSize / (1024 * 1024)
|
|
exceeded := settings.ImagePruneThresholdMB > 0 && int(totalMB) >= settings.ImagePruneThresholdMB
|
|
|
|
respondJSON(w, http.StatusOK, map[string]any{
|
|
"total_size_mb": totalMB,
|
|
"count": count,
|
|
"threshold_mb": settings.ImagePruneThresholdMB,
|
|
"exceeded": exceeded,
|
|
})
|
|
}
|
|
|
|
// pruneImages handles POST /api/docker/prune-images.
|
|
// Only removes images that belong to Tinyforge projects (not all system images).
|
|
func (s *Server) pruneImages(w http.ResponseWriter, r *http.Request) {
|
|
if s.docker == nil {
|
|
respondError(w, http.StatusServiceUnavailable, "Docker is not available")
|
|
return
|
|
}
|
|
|
|
// Collect all image references from our projects.
|
|
projects, err := s.store.GetAllProjects()
|
|
if err != nil {
|
|
slog.Error("prune: failed to list projects", "error", err)
|
|
respondError(w, http.StatusInternalServerError, "internal server error")
|
|
return
|
|
}
|
|
|
|
// Build a set of image refs used by active instances.
|
|
activeImages := make(map[string]bool)
|
|
for _, p := range projects {
|
|
stages, _ := s.store.GetStagesByProjectID(p.ID)
|
|
for _, st := range stages {
|
|
instances, _ := s.store.GetInstancesByStageID(st.ID)
|
|
for _, inst := range instances {
|
|
if inst.ImageTag != "" {
|
|
activeImages[p.Image+":"+inst.ImageTag] = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Collect all unique image bases from projects (without tags).
|
|
projectImages := make(map[string]bool)
|
|
for _, p := range projects {
|
|
if p.Image != "" {
|
|
projectImages[p.Image] = true
|
|
}
|
|
}
|
|
|
|
if len(projectImages) == 0 {
|
|
respondJSON(w, http.StatusOK, map[string]any{
|
|
"images_removed": 0,
|
|
"space_reclaimed_mb": 0,
|
|
"message": "No project images to clean up",
|
|
})
|
|
return
|
|
}
|
|
|
|
// List all local Docker images and find ones matching our projects but not actively used.
|
|
ctx := r.Context()
|
|
removed := 0
|
|
var reclaimedBytes int64
|
|
|
|
for imageBase := range projectImages {
|
|
// List all tags for this image.
|
|
images, err := s.docker.ListImagesByRef(ctx, imageBase)
|
|
if err != nil {
|
|
slog.Warn("prune: list images", "image", imageBase, "error", err)
|
|
continue
|
|
}
|
|
|
|
for _, img := range images {
|
|
// Skip images that are actively used by running instances.
|
|
if activeImages[img.Ref] {
|
|
continue
|
|
}
|
|
|
|
// Remove unused image.
|
|
if err := s.docker.RemoveImage(ctx, img.ID); err != nil {
|
|
slog.Warn("prune: remove image", "image", img.Ref, "error", err)
|
|
continue
|
|
}
|
|
removed++
|
|
reclaimedBytes += img.Size
|
|
slog.Info("prune: removed image", "ref", img.Ref, "size_mb", img.Size/(1024*1024))
|
|
}
|
|
}
|
|
|
|
respondJSON(w, http.StatusOK, map[string]any{
|
|
"images_removed": removed,
|
|
"space_reclaimed_mb": reclaimedBytes / (1024 * 1024),
|
|
})
|
|
}
|