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.
237 lines
6.4 KiB
Go
237 lines
6.4 KiB
Go
package api
|
|
|
|
import (
|
|
"log/slog"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/alexei/tinyforge/internal/docker"
|
|
"github.com/alexei/tinyforge/internal/store"
|
|
)
|
|
|
|
// listDeploys handles GET /api/deploys.
|
|
func (s *Server) listDeploys(w http.ResponseWriter, r *http.Request) {
|
|
limitStr := r.URL.Query().Get("limit")
|
|
limit := 50
|
|
if limitStr != "" {
|
|
if parsed, err := strconv.Atoi(limitStr); err == nil && parsed > 0 {
|
|
limit = parsed
|
|
}
|
|
}
|
|
|
|
offsetStr := r.URL.Query().Get("offset")
|
|
offset := 0
|
|
if offsetStr != "" {
|
|
if parsed, err := strconv.Atoi(offsetStr); err == nil && parsed >= 0 {
|
|
offset = parsed
|
|
}
|
|
}
|
|
|
|
projectID := r.URL.Query().Get("project_id")
|
|
stageID := r.URL.Query().Get("stage_id")
|
|
|
|
deploys, err := s.store.GetDeploys(projectID, stageID, limit, offset)
|
|
if err != nil {
|
|
slog.Error("failed to list deploys", "error", err)
|
|
respondError(w, http.StatusInternalServerError, "internal server error")
|
|
return
|
|
}
|
|
respondJSON(w, http.StatusOK, deploys)
|
|
}
|
|
|
|
// NOTE: getDeployLogs has been replaced by streamDeployLogs in sse.go.
|
|
// The new handler supports both SSE streaming and JSON fallback via Accept header.
|
|
|
|
// inspectRequest is the expected JSON body for POST /api/deploy/inspect.
|
|
type inspectRequest struct {
|
|
Image string `json:"image"`
|
|
}
|
|
|
|
// inspectResponse is the response body for POST /api/deploy/inspect.
|
|
type inspectResponse struct {
|
|
Image string `json:"image"`
|
|
Port int `json:"port"`
|
|
Healthcheck string `json:"healthcheck"`
|
|
}
|
|
|
|
// inspectImage handles POST /api/deploy/inspect.
|
|
// Pulls the image and inspects it for EXPOSE ports and healthcheck config.
|
|
func (s *Server) inspectImage(w http.ResponseWriter, r *http.Request) {
|
|
var req inspectRequest
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
|
|
if req.Image == "" {
|
|
respondError(w, http.StatusBadRequest, "image is required")
|
|
return
|
|
}
|
|
|
|
ctx := r.Context()
|
|
|
|
// Pull the image first so it's available locally for inspection.
|
|
// Split image:tag for the pull call.
|
|
imageRef, tag := splitImageTag(req.Image)
|
|
if err := s.docker.PullImage(ctx, imageRef, tag, ""); err != nil {
|
|
slog.Warn("pull image for inspect", "image", req.Image, "error", err)
|
|
// Try to inspect anyway in case the image is already local.
|
|
}
|
|
|
|
info, err := s.docker.InspectImage(ctx, req.Image)
|
|
if err != nil {
|
|
slog.Error("failed to inspect image", "image", req.Image, "error", err)
|
|
errMsg := "Failed to inspect image. "
|
|
if strings.Contains(err.Error(), "docker_engine") || strings.Contains(err.Error(), "docker.sock") {
|
|
errMsg += "Docker is not available on this machine. Enter port and project name manually."
|
|
} else {
|
|
errMsg += "Image may not exist or registry requires authentication."
|
|
}
|
|
respondError(w, http.StatusBadGateway, errMsg)
|
|
return
|
|
}
|
|
|
|
port := docker.ExtractPort(info.ExposedPorts)
|
|
|
|
respondJSON(w, http.StatusOK, inspectResponse{
|
|
Image: req.Image,
|
|
Port: port,
|
|
Healthcheck: info.Healthcheck,
|
|
})
|
|
}
|
|
|
|
// quickDeployRequest is the expected JSON body for POST /api/deploy/quick.
|
|
type quickDeployRequest struct {
|
|
Name string `json:"name"`
|
|
Image string `json:"image"`
|
|
Tag string `json:"tag"`
|
|
Registry string `json:"registry"`
|
|
Port int `json:"port"`
|
|
Force bool `json:"force"` // skip duplicate check
|
|
EnableProxy *bool `json:"enable_proxy"` // nil defaults to true
|
|
AutoDeploy *bool `json:"auto_deploy"` // nil defaults to true (deploy immediately)
|
|
}
|
|
|
|
// quickDeploy handles POST /api/deploy/quick.
|
|
// Creates a project, a default stage, and triggers a deploy in one call.
|
|
func (s *Server) quickDeploy(w http.ResponseWriter, r *http.Request) {
|
|
var req quickDeployRequest
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
|
|
if req.Image == "" {
|
|
respondError(w, http.StatusBadRequest, "image is required")
|
|
return
|
|
}
|
|
|
|
// Split tag from image if the image URL contains one (e.g., "registry/app:v1").
|
|
if req.Tag == "" {
|
|
imageRef, tag := splitImageTag(req.Image)
|
|
if tag != "" {
|
|
req.Image = imageRef
|
|
req.Tag = tag
|
|
} else {
|
|
req.Tag = "latest"
|
|
}
|
|
}
|
|
|
|
if req.Name == "" {
|
|
// Derive name from image (without tag).
|
|
parts := strings.Split(req.Image, "/")
|
|
req.Name = parts[len(parts)-1]
|
|
}
|
|
|
|
// Check for existing projects with the same image.
|
|
if !req.Force {
|
|
existing, err := s.store.GetProjectsByImage(req.Image)
|
|
if err != nil {
|
|
slog.Error("failed to check existing projects", "error", err)
|
|
respondError(w, http.StatusInternalServerError, "internal server error")
|
|
return
|
|
}
|
|
if len(existing) > 0 {
|
|
respondJSON(w, http.StatusConflict, map[string]any{
|
|
"message": "A project with this image already exists",
|
|
"existing_projects": existing,
|
|
})
|
|
return
|
|
}
|
|
}
|
|
|
|
// Create project.
|
|
project, err := s.store.CreateProject(store.Project{
|
|
Name: req.Name,
|
|
Image: req.Image,
|
|
Registry: req.Registry,
|
|
Port: req.Port,
|
|
Env: "{}",
|
|
Volumes: "{}",
|
|
})
|
|
if err != nil {
|
|
slog.Error("failed to create project", "error", err)
|
|
respondError(w, http.StatusInternalServerError, "internal server error")
|
|
return
|
|
}
|
|
|
|
// Create default stage.
|
|
enableProxy := true
|
|
if req.EnableProxy != nil {
|
|
enableProxy = *req.EnableProxy
|
|
}
|
|
shouldDeploy := true
|
|
if req.AutoDeploy != nil {
|
|
shouldDeploy = *req.AutoDeploy
|
|
}
|
|
stage, err := s.store.CreateStage(store.Stage{
|
|
ProjectID: project.ID,
|
|
Name: "dev",
|
|
TagPattern: "*",
|
|
AutoDeploy: shouldDeploy,
|
|
MaxInstances: 1,
|
|
EnableProxy: enableProxy,
|
|
})
|
|
if err != nil {
|
|
slog.Error("failed to create stage", "error", err)
|
|
respondError(w, http.StatusInternalServerError, "internal server error")
|
|
return
|
|
}
|
|
|
|
// Only trigger deploy if auto_deploy is enabled.
|
|
var deployID string
|
|
if shouldDeploy {
|
|
deployID, err = s.deployer.AsyncTriggerDeploy(r.Context(), project.ID, stage.ID, req.Tag)
|
|
if err != nil {
|
|
slog.Error("failed to trigger deploy", "error", err)
|
|
respondError(w, http.StatusInternalServerError, "internal server error")
|
|
return
|
|
}
|
|
}
|
|
|
|
status := "created"
|
|
if shouldDeploy {
|
|
status = "deploying"
|
|
}
|
|
|
|
respondJSON(w, http.StatusAccepted, map[string]any{
|
|
"project": project,
|
|
"stage": stage,
|
|
"tag": req.Tag,
|
|
"deploy_id": deployID,
|
|
"status": status,
|
|
})
|
|
}
|
|
|
|
// splitImageTag splits "image:tag" into image and tag parts.
|
|
// Returns the full string and empty tag if no colon separator is found.
|
|
func splitImageTag(ref string) (string, string) {
|
|
if idx := strings.LastIndex(ref, ":"); idx != -1 {
|
|
afterColon := ref[idx+1:]
|
|
if !strings.Contains(afterColon, "/") {
|
|
return ref[:idx], afterColon
|
|
}
|
|
}
|
|
return ref, ""
|
|
}
|
|
|