completed phase 2b
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# ─── Required ───────────────────────────────────────────────────────────────
|
# ─── Required ───────────────────────────────────────────────────────────────
|
||||||
# PostgreSQL connection string
|
# PostgreSQL connection string
|
||||||
DATABASE_URL=postgres://forgebucket:password@localhost:5432/forgebucket?sslmode=disable
|
DATABASE_URL=postgres://forgebucket:password@postgres:5432/forgebucket?sslmode=disable
|
||||||
|
|
||||||
# Session cookie signing key — must be at least 32 characters
|
# Session cookie signing key — must be at least 32 characters
|
||||||
# Generate: openssl rand -hex 32
|
# Generate: openssl rand -hex 32
|
||||||
|
|||||||
+16
-1
@@ -17,6 +17,7 @@ import (
|
|||||||
"github.com/forgeo/forgebucket/internal/api"
|
"github.com/forgeo/forgebucket/internal/api"
|
||||||
"github.com/forgeo/forgebucket/internal/config"
|
"github.com/forgeo/forgebucket/internal/config"
|
||||||
"github.com/forgeo/forgebucket/internal/db"
|
"github.com/forgeo/forgebucket/internal/db"
|
||||||
|
"github.com/forgeo/forgebucket/internal/domain/ci"
|
||||||
gitdomain "github.com/forgeo/forgebucket/internal/domain/git"
|
gitdomain "github.com/forgeo/forgebucket/internal/domain/git"
|
||||||
"github.com/forgeo/forgebucket/internal/events"
|
"github.com/forgeo/forgebucket/internal/events"
|
||||||
"github.com/forgeo/forgebucket/internal/models/migrations"
|
"github.com/forgeo/forgebucket/internal/models/migrations"
|
||||||
@@ -43,6 +44,10 @@ func main() {
|
|||||||
|
|
||||||
gitdomain.SetRepoRoot(cfg.RepoRoot)
|
gitdomain.SetRepoRoot(cfg.RepoRoot)
|
||||||
|
|
||||||
|
if err := os.MkdirAll(cfg.ArtifactRoot, 0755); err != nil {
|
||||||
|
log.Fatalf("artifact root: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
bus, err := events.New(cfg.NATSUrl)
|
bus, err := events.New(cfg.NATSUrl)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("events: %v", err)
|
log.Fatalf("events: %v", err)
|
||||||
@@ -58,7 +63,17 @@ func main() {
|
|||||||
SameSite: http.SameSiteLaxMode,
|
SameSite: http.SameSiteLaxMode,
|
||||||
}
|
}
|
||||||
|
|
||||||
handler := api.New(cfg, engine, store, bus, web.FS())
|
// Start CI orchestrator and runner manager in background goroutines.
|
||||||
|
ciCtx, ciCancel := context.WithCancel(context.Background())
|
||||||
|
defer ciCancel()
|
||||||
|
|
||||||
|
orchestrator := ci.NewOrchestrator(engine, bus)
|
||||||
|
go orchestrator.Start(ciCtx)
|
||||||
|
|
||||||
|
runnerMgr := ci.NewRunnerManager(engine, bus, cfg, 4)
|
||||||
|
go runnerMgr.Start(ciCtx)
|
||||||
|
|
||||||
|
handler := api.New(cfg, engine, store, bus, cfg.ArtifactRoot, web.FS())
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: fmt.Sprintf(":%s", cfg.Port),
|
Addr: fmt.Sprintf(":%s", cfg.Port),
|
||||||
|
|||||||
@@ -24,5 +24,6 @@ require (
|
|||||||
github.com/syndtr/goleveldb v1.0.0 // indirect
|
github.com/syndtr/goleveldb v1.0.0 // indirect
|
||||||
golang.org/x/sys v0.43.0 // indirect
|
golang.org/x/sys v0.43.0 // indirect
|
||||||
golang.org/x/tools v0.43.0 // indirect
|
golang.org/x/tools v0.43.0 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
xorm.io/builder v0.3.13 // indirect
|
xorm.io/builder v0.3.13 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"xorm.io/xorm"
|
||||||
|
|
||||||
|
"github.com/forgeo/forgebucket/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ArtifactHandler struct {
|
||||||
|
db *xorm.Engine
|
||||||
|
artifactRoot string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewArtifactHandler(db *xorm.Engine, artifactRoot string) *ArtifactHandler {
|
||||||
|
return &ArtifactHandler{db: db, artifactRoot: artifactRoot}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListArtifacts returns all artifacts for a pipeline run.
|
||||||
|
func (h *ArtifactHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||||
|
repoID, runID, ok := h.resolveRunIDs(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var artifacts []models.Artifact
|
||||||
|
if err := h.db.Where("run_id = ? AND repo_id = ?", runID, repoID).Find(&artifacts); err != nil {
|
||||||
|
jsonError(w, "could not list artifacts", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if artifacts == nil {
|
||||||
|
artifacts = []models.Artifact{}
|
||||||
|
}
|
||||||
|
jsonOK(w, artifacts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload accepts a multipart file upload and stores it as an artifact.
|
||||||
|
// Callers must provide a valid Bearer access token with write scope (runner auth).
|
||||||
|
func (h *ArtifactHandler) Upload(w http.ResponseWriter, r *http.Request) {
|
||||||
|
repoID, runID, ok := h.resolveRunIDs(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, 512<<20) // 512 MB max
|
||||||
|
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
||||||
|
jsonError(w, "multipart parse failed", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file, header, err := r.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, "file field is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
name := r.FormValue("name")
|
||||||
|
if name == "" {
|
||||||
|
name = header.Filename
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize name: no path separators.
|
||||||
|
for _, c := range []byte(name) {
|
||||||
|
if c == '/' || c == '\\' || c == 0 {
|
||||||
|
jsonError(w, "artifact name must not contain path separators", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := filepath.Join(h.artifactRoot, fmt.Sprintf("%d", runID))
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
jsonError(w, "could not create storage directory", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
storagePath := filepath.Join(dir, name)
|
||||||
|
dst, err := os.Create(storagePath)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, "could not create file", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer dst.Close()
|
||||||
|
|
||||||
|
size, err := io.Copy(dst, file)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, "could not write file", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ct := header.Header.Get("Content-Type")
|
||||||
|
if ct == "" {
|
||||||
|
ct = "application/octet-stream"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store path relative to artifactRoot for portability.
|
||||||
|
relPath := fmt.Sprintf("%d/%s", runID, name)
|
||||||
|
|
||||||
|
artifact := &models.Artifact{
|
||||||
|
RunID: runID,
|
||||||
|
RepoID: repoID,
|
||||||
|
Name: name,
|
||||||
|
StoragePath: relPath,
|
||||||
|
Size: size,
|
||||||
|
ContentType: ct,
|
||||||
|
}
|
||||||
|
if _, err := h.db.Insert(artifact); err != nil {
|
||||||
|
jsonError(w, "could not record artifact", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
jsonOK(w, artifact)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download streams the artifact file to the client.
|
||||||
|
func (h *ArtifactHandler) Download(w http.ResponseWriter, r *http.Request) {
|
||||||
|
artifactID, err := strconv.ParseInt(chi.URLParam(r, "artifactID"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, "invalid artifact ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var artifact models.Artifact
|
||||||
|
if found, _ := h.db.ID(artifactID).Get(&artifact); !found {
|
||||||
|
jsonError(w, "artifact not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fullPath := filepath.Join(h.artifactRoot, filepath.FromSlash(artifact.StoragePath))
|
||||||
|
// Ensure the resolved path stays within artifactRoot (traversal guard).
|
||||||
|
if !isUnder(h.artifactRoot, fullPath) {
|
||||||
|
jsonError(w, "forbidden", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.Open(fullPath)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, "artifact file not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
ct := artifact.ContentType
|
||||||
|
if ct == "" {
|
||||||
|
ct = "application/octet-stream"
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", ct)
|
||||||
|
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename=%q`, artifact.Name))
|
||||||
|
w.Header().Set("Content-Length", strconv.FormatInt(artifact.Size, 10))
|
||||||
|
io.Copy(w, f) //nolint:errcheck
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ArtifactHandler) resolveRunIDs(w http.ResponseWriter, r *http.Request) (repoID, runID int64, ok bool) {
|
||||||
|
owner := chi.URLParam(r, "owner")
|
||||||
|
repoName := chi.URLParam(r, "repo")
|
||||||
|
var u models.User
|
||||||
|
if found, _ := h.db.Where("username = ?", owner).Get(&u); !found {
|
||||||
|
jsonError(w, "repository not found", http.StatusNotFound)
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
var repo models.Repository
|
||||||
|
if found, _ := h.db.Where("owner_id = ? AND name = ?", u.ID, repoName).Get(&repo); !found {
|
||||||
|
jsonError(w, "repository not found", http.StatusNotFound)
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
runID, err := strconv.ParseInt(chi.URLParam(r, "runID"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, "invalid run ID", http.StatusBadRequest)
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
return repo.ID, runID, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func isUnder(root, path string) bool {
|
||||||
|
root = filepath.Clean(root)
|
||||||
|
path = filepath.Clean(path)
|
||||||
|
if len(path) <= len(root) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return path[:len(root)] == root && path[len(root)] == filepath.Separator
|
||||||
|
}
|
||||||
@@ -11,22 +11,32 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
"xorm.io/xorm"
|
"xorm.io/xorm"
|
||||||
|
|
||||||
"github.com/forgeo/forgebucket/internal/config"
|
"github.com/forgeo/forgebucket/internal/config"
|
||||||
|
"github.com/forgeo/forgebucket/internal/events"
|
||||||
"github.com/forgeo/forgebucket/internal/models"
|
"github.com/forgeo/forgebucket/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
type GitHTTPHandler struct {
|
type GitHTTPHandler struct {
|
||||||
db *xorm.Engine
|
db *xorm.Engine
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
|
bus events.EventBus
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewGitHTTPHandler(db *xorm.Engine, cfg *config.Config) *GitHTTPHandler {
|
func NewGitHTTPHandler(db *xorm.Engine, cfg *config.Config, bus events.EventBus) *GitHTTPHandler {
|
||||||
return &GitHTTPHandler{db: db, cfg: cfg}
|
return &GitHTTPHandler{db: db, cfg: cfg, bus: bus}
|
||||||
|
}
|
||||||
|
|
||||||
|
// refUpdate captures one ref-update line from a git-receive-pack request.
|
||||||
|
type refUpdate struct {
|
||||||
|
OldRev string
|
||||||
|
NewRev string
|
||||||
|
Ref string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServeGit is the entry point for all git smart-HTTP requests.
|
// ServeGit is the entry point for all git smart-HTTP requests.
|
||||||
@@ -107,13 +117,15 @@ func (h *GitHTTPHandler) ServeGit(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// Branch protection check: parse pkt-lines from the receive-pack body,
|
// Branch protection check: parse pkt-lines from the receive-pack body,
|
||||||
// check each ref against stored protection rules, then restore the body.
|
// check each ref against stored protection rules, then restore the body.
|
||||||
|
var pushedRefs []refUpdate
|
||||||
if service == "git-receive-pack" {
|
if service == "git-receive-pack" {
|
||||||
if reason, newBody := checkProtectionsFromBody(h.db, repo.ID, authedUser, r.Body); reason != "" {
|
reason, refs, newBody := parseAndCheckBody(h.db, repo.ID, authedUser, r.Body)
|
||||||
|
if reason != "" {
|
||||||
http.Error(w, reason, http.StatusForbidden)
|
http.Error(w, reason, http.StatusForbidden)
|
||||||
return
|
return
|
||||||
} else {
|
|
||||||
r.Body = io.NopCloser(newBody)
|
|
||||||
}
|
}
|
||||||
|
pushedRefs = refs
|
||||||
|
r.Body = io.NopCloser(newBody)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build PATH_INFO: /{reponame}.git/{suffix}
|
// Build PATH_INFO: /{reponame}.git/{suffix}
|
||||||
@@ -157,6 +169,27 @@ func (h *GitHTTPHandler) ServeGit(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
if err := runGitBackend(r.Context(), w, r.Body, gitExec, env); err != nil {
|
if err := runGitBackend(r.Context(), w, r.Body, gitExec, env); err != nil {
|
||||||
http.Error(w, fmt.Sprintf("git http-backend: %v", err), http.StatusInternalServerError)
|
http.Error(w, fmt.Sprintf("git http-backend: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish push.received for each ref pushed so the CI orchestrator can react.
|
||||||
|
if service == "git-receive-pack" {
|
||||||
|
zeroOID := strings.Repeat("0", 40)
|
||||||
|
for _, ref := range pushedRefs {
|
||||||
|
if ref.NewRev == zeroOID {
|
||||||
|
continue // branch deletion — skip CI trigger
|
||||||
|
}
|
||||||
|
go h.bus.Publish(events.SubjectPushReceived, events.PushEvent{ //nolint:errcheck
|
||||||
|
RepoID: repo.ID,
|
||||||
|
RepoName: repoName,
|
||||||
|
OwnerName: owner,
|
||||||
|
Ref: ref.Ref,
|
||||||
|
Before: ref.OldRev,
|
||||||
|
After: ref.NewRev,
|
||||||
|
Pusher: authedUser,
|
||||||
|
At: time.Now().UTC(),
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,15 +272,14 @@ func runGitBackend(ctx context.Context, w http.ResponseWriter, body io.Reader, g
|
|||||||
return waitErr
|
return waitErr
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkProtectionsFromBody parses git pkt-line ref updates from a receive-pack body,
|
// parseAndCheckBody parses git pkt-line ref updates from a receive-pack body,
|
||||||
// checks each ref against stored branch protection rules, and returns a denial reason
|
// checks each ref against stored branch protection rules, and returns a denial
|
||||||
// (or "") plus a restored reader so the body can still be passed to http-backend.
|
// reason (or ""), the list of parsed ref updates, and a restored reader.
|
||||||
func checkProtectionsFromBody(db *xorm.Engine, repoID int64, pusher string, body io.Reader) (reason string, restored io.Reader) {
|
func parseAndCheckBody(db *xorm.Engine, repoID int64, pusher string, body io.Reader) (reason string, refs []refUpdate, restored io.Reader) {
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
zeroOID := strings.Repeat("0", 40)
|
zeroOID := strings.Repeat("0", 40)
|
||||||
|
|
||||||
for {
|
for {
|
||||||
// Every pkt-line starts with a 4-hex-digit length that includes itself.
|
|
||||||
lenBuf := make([]byte, 4)
|
lenBuf := make([]byte, 4)
|
||||||
if _, err := io.ReadFull(body, lenBuf); err != nil {
|
if _, err := io.ReadFull(body, lenBuf); err != nil {
|
||||||
break
|
break
|
||||||
@@ -259,8 +291,7 @@ func checkProtectionsFromBody(db *xorm.Engine, repoID int64, pusher string, body
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
if pktLen64 == 0 {
|
if pktLen64 == 0 {
|
||||||
// Flush packet — end of ref-update list.
|
break // flush packet — end of ref-update list
|
||||||
break
|
|
||||||
}
|
}
|
||||||
dataLen := int(pktLen64) - 4
|
dataLen := int(pktLen64) - 4
|
||||||
if dataLen <= 0 {
|
if dataLen <= 0 {
|
||||||
@@ -280,16 +311,15 @@ func checkProtectionsFromBody(db *xorm.Engine, repoID int64, pusher string, body
|
|||||||
}
|
}
|
||||||
oldRev, newRev, refname := parts[0], parts[1], parts[2]
|
oldRev, newRev, refname := parts[0], parts[1], parts[2]
|
||||||
|
|
||||||
// New branches (oldRev all zeros) are not subject to protection.
|
refs = append(refs, refUpdate{OldRev: oldRev, NewRev: newRev, Ref: refname})
|
||||||
if oldRev == zeroOID {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// Detect force push: if newRev is all zeros it's a branch deletion.
|
|
||||||
isForcePush := newRev == zeroOID
|
|
||||||
|
|
||||||
|
if oldRev == zeroOID {
|
||||||
|
continue // new branch — not subject to protection
|
||||||
|
}
|
||||||
|
isForcePush := newRev == zeroOID
|
||||||
if msg := CheckBranchProtection(db, repoID, pusher, refname, isForcePush); msg != "" {
|
if msg := CheckBranchProtection(db, repoID, pusher, refname, isForcePush); msg != "" {
|
||||||
return msg, io.MultiReader(&buf, body)
|
return msg, refs, io.MultiReader(&buf, body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return "", io.MultiReader(&buf, body)
|
return "", refs, io.MultiReader(&buf, body)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"xorm.io/xorm"
|
"xorm.io/xorm"
|
||||||
@@ -17,37 +19,209 @@ func NewPipelineHandler(db *xorm.Engine) *PipelineHandler {
|
|||||||
return &PipelineHandler{db: db}
|
return &PipelineHandler{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *PipelineHandler) List(w http.ResponseWriter, r *http.Request) {
|
// ListPipelines returns all pipeline definitions for a repository.
|
||||||
ownerName := chi.URLParam(r, "owner")
|
func (h *PipelineHandler) ListPipelines(w http.ResponseWriter, r *http.Request) {
|
||||||
repoName := chi.URLParam(r, "repo")
|
repoID, ok := h.repoID(w, r)
|
||||||
|
|
||||||
repoID, ok := h.repoID(w, ownerName, repoName)
|
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = repoID
|
var pipelines []models.Pipeline
|
||||||
// Pipeline records will be added in Phase 3 (CI integration).
|
if err := h.db.Where("repo_id = ?", repoID).Find(&pipelines); err != nil {
|
||||||
// Return empty list so the client doesn't break.
|
jsonError(w, "could not list pipelines", http.StatusInternalServerError)
|
||||||
jsonOK(w, []any{})
|
return
|
||||||
|
}
|
||||||
|
if pipelines == nil {
|
||||||
|
pipelines = []models.Pipeline{}
|
||||||
|
}
|
||||||
|
jsonOK(w, pipelines)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *PipelineHandler) Get(w http.ResponseWriter, r *http.Request) {
|
// ListRuns returns pipeline runs for a repository, most recent first.
|
||||||
jsonError(w, "not implemented", http.StatusNotImplemented)
|
func (h *PipelineHandler) ListRuns(w http.ResponseWriter, r *http.Request) {
|
||||||
|
repoID, ok := h.repoID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limit := 30
|
||||||
|
if l, err := strconv.Atoi(r.URL.Query().Get("limit")); err == nil && l > 0 && l <= 100 {
|
||||||
|
limit = l
|
||||||
|
}
|
||||||
|
var runs []models.PipelineRun
|
||||||
|
if err := h.db.Where("repo_id = ?", repoID).Desc("id").Limit(limit).Find(&runs); err != nil {
|
||||||
|
jsonError(w, "could not list runs", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if runs == nil {
|
||||||
|
runs = []models.PipelineRun{}
|
||||||
|
}
|
||||||
|
jsonOK(w, runs)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *PipelineHandler) repoID(w http.ResponseWriter, ownerName, repoName string) (int64, bool) {
|
type runDetailResponse struct {
|
||||||
var owner models.User
|
models.PipelineRun
|
||||||
found, err := h.db.Where("username = ?", ownerName).Get(&owner)
|
Jobs []jobDetailResponse `json:"jobs"`
|
||||||
if err != nil || !found {
|
}
|
||||||
|
|
||||||
|
type jobDetailResponse struct {
|
||||||
|
models.PipelineJob
|
||||||
|
Steps []models.PipelineStep `json:"steps"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRun returns a run with its full job + step tree.
|
||||||
|
func (h *PipelineHandler) GetRun(w http.ResponseWriter, r *http.Request) {
|
||||||
|
run, ok := h.lookupRun(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var jobs []models.PipelineJob
|
||||||
|
h.db.Where("run_id = ?", run.ID).Asc("id").Find(&jobs)
|
||||||
|
|
||||||
|
jobDetails := make([]jobDetailResponse, len(jobs))
|
||||||
|
for i, job := range jobs {
|
||||||
|
var steps []models.PipelineStep
|
||||||
|
h.db.Where("job_id = ?", job.ID).Asc("seq").Find(&steps)
|
||||||
|
if steps == nil {
|
||||||
|
steps = []models.PipelineStep{}
|
||||||
|
}
|
||||||
|
jobDetails[i] = jobDetailResponse{PipelineJob: job, Steps: steps}
|
||||||
|
}
|
||||||
|
jsonOK(w, runDetailResponse{PipelineRun: *run, Jobs: jobDetails})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetJobLogs returns all log chunks for a job, ordered by step seq and chunk index.
|
||||||
|
func (h *PipelineHandler) GetJobLogs(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, ok := h.lookupRun(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jobID, err := strconv.ParseInt(chi.URLParam(r, "jobID"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, "invalid job ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify job belongs to this run.
|
||||||
|
var job models.PipelineJob
|
||||||
|
runID, _ := strconv.ParseInt(chi.URLParam(r, "runID"), 10, 64)
|
||||||
|
if found, _ := h.db.Where("id = ? AND run_id = ?", jobID, runID).Get(&job); !found {
|
||||||
|
jsonError(w, "job not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var steps []models.PipelineStep
|
||||||
|
h.db.Where("job_id = ?", jobID).Asc("seq").Find(&steps)
|
||||||
|
|
||||||
|
type stepLogs struct {
|
||||||
|
models.PipelineStep
|
||||||
|
Logs []models.PipelineStepLog `json:"logs"`
|
||||||
|
}
|
||||||
|
result := make([]stepLogs, len(steps))
|
||||||
|
for i, step := range steps {
|
||||||
|
var logs []models.PipelineStepLog
|
||||||
|
h.db.Where("step_id = ?", step.ID).Asc("chunk_index").Find(&logs)
|
||||||
|
if logs == nil {
|
||||||
|
logs = []models.PipelineStepLog{}
|
||||||
|
}
|
||||||
|
result[i] = stepLogs{PipelineStep: step, Logs: logs}
|
||||||
|
}
|
||||||
|
jsonOK(w, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CancelRun marks a queued or running run as cancelled.
|
||||||
|
func (h *PipelineHandler) CancelRun(w http.ResponseWriter, r *http.Request) {
|
||||||
|
run, ok := h.lookupRun(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if run.Status != "queued" && run.Status != "running" {
|
||||||
|
jsonError(w, "run is not cancellable in its current state", http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
run.Status = "cancelled"
|
||||||
|
run.FinishedAt = &now
|
||||||
|
if _, err := h.db.ID(run.ID).Cols("status", "finished_at").Update(run); err != nil {
|
||||||
|
jsonError(w, "could not cancel run", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Cancel any queued jobs.
|
||||||
|
h.db.Where("run_id = ? AND status = 'queued'", run.ID). //nolint:errcheck
|
||||||
|
Cols("status").Update(&models.PipelineJob{Status: "cancelled"})
|
||||||
|
jsonOK(w, run)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RetryJob re-queues a failed job by resetting its status and re-publishing job.queued.
|
||||||
|
func (h *PipelineHandler) RetryJob(w http.ResponseWriter, r *http.Request) {
|
||||||
|
run, ok := h.lookupRun(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jobID, err := strconv.ParseInt(chi.URLParam(r, "jobID"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, "invalid job ID", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var job models.PipelineJob
|
||||||
|
if found, _ := h.db.Where("id = ? AND run_id = ?", jobID, run.ID).Get(&job); !found {
|
||||||
|
jsonError(w, "job not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if job.Status != "failed" && job.Status != "cancelled" {
|
||||||
|
jsonError(w, "only failed or cancelled jobs can be retried", http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
job.Status = "queued"
|
||||||
|
job.StartedAt = nil
|
||||||
|
job.FinishedAt = nil
|
||||||
|
h.db.ID(job.ID).Cols("status", "started_at", "finished_at").Update(&job) //nolint:errcheck
|
||||||
|
|
||||||
|
// Reset step statuses.
|
||||||
|
h.db.Where("job_id = ?", job.ID).Cols("status", "exit_code", "started_at", "finished_at"). //nolint:errcheck
|
||||||
|
Update(&models.PipelineStep{Status: "queued"})
|
||||||
|
|
||||||
|
// Also reset the run status if it was failed/cancelled.
|
||||||
|
if run.Status == "failed" || run.Status == "cancelled" {
|
||||||
|
run.Status = "running"
|
||||||
|
run.FinishedAt = nil
|
||||||
|
h.db.ID(run.ID).Cols("status", "finished_at").Update(run) //nolint:errcheck
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonOK(w, job)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (h *PipelineHandler) repoID(w http.ResponseWriter, r *http.Request) (int64, bool) {
|
||||||
|
owner := chi.URLParam(r, "owner")
|
||||||
|
repoName := chi.URLParam(r, "repo")
|
||||||
|
var u models.User
|
||||||
|
if found, _ := h.db.Where("username = ?", owner).Get(&u); !found {
|
||||||
jsonError(w, "repository not found", http.StatusNotFound)
|
jsonError(w, "repository not found", http.StatusNotFound)
|
||||||
return 0, false
|
return 0, false
|
||||||
}
|
}
|
||||||
|
|
||||||
var repo models.Repository
|
var repo models.Repository
|
||||||
found, err = h.db.Where("owner_id = ? AND name = ?", owner.ID, repoName).Get(&repo)
|
if found, _ := h.db.Where("owner_id = ? AND name = ?", u.ID, repoName).Get(&repo); !found {
|
||||||
if err != nil || !found {
|
|
||||||
jsonError(w, "repository not found", http.StatusNotFound)
|
jsonError(w, "repository not found", http.StatusNotFound)
|
||||||
return 0, false
|
return 0, false
|
||||||
}
|
}
|
||||||
return repo.ID, true
|
return repo.ID, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *PipelineHandler) lookupRun(w http.ResponseWriter, r *http.Request) (*models.PipelineRun, bool) {
|
||||||
|
repoID, ok := h.repoID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
runID, err := strconv.ParseInt(chi.URLParam(r, "runID"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, "invalid run ID", http.StatusBadRequest)
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
var run models.PipelineRun
|
||||||
|
if found, _ := h.db.Where("id = ? AND repo_id = ?", runID, repoID).Get(&run); !found {
|
||||||
|
jsonError(w, "run not found", http.StatusNotFound)
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return &run, true
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"xorm.io/xorm"
|
||||||
|
|
||||||
|
"github.com/forgeo/forgebucket/internal/api/middleware"
|
||||||
|
"github.com/forgeo/forgebucket/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RunnerHandler struct{ db *xorm.Engine }
|
||||||
|
|
||||||
|
func NewRunnerHandler(db *xorm.Engine) *RunnerHandler { return &RunnerHandler{db: db} }
|
||||||
|
|
||||||
|
// List returns all registered runners. Admin-only.
|
||||||
|
func (h *RunnerHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !isAdmin(r) {
|
||||||
|
jsonError(w, "admin access required", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var runners []models.Runner
|
||||||
|
if err := h.db.Find(&runners); err != nil {
|
||||||
|
jsonError(w, "could not list runners", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if runners == nil {
|
||||||
|
runners = []models.Runner{}
|
||||||
|
}
|
||||||
|
jsonOK(w, runners)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register creates a new runner record and returns the plaintext registration token
|
||||||
|
// (shown once; the server stores only the bcrypt hash).
|
||||||
|
func (h *RunnerHandler) Register(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !isAdmin(r) {
|
||||||
|
jsonError(w, "admin access required", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Labels []string `json:"labels"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
jsonError(w, "invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Name == "" {
|
||||||
|
jsonError(w, "name is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
raw := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(raw); err != nil {
|
||||||
|
jsonError(w, "could not generate token", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token := base64.RawURLEncoding.EncodeToString(raw)
|
||||||
|
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(token), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
jsonError(w, "could not hash token", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
labelsJSON, _ := json.Marshal(body.Labels)
|
||||||
|
runner := &models.Runner{
|
||||||
|
Name: body.Name,
|
||||||
|
Labels: string(labelsJSON),
|
||||||
|
Status: "idle",
|
||||||
|
TokenHash: string(hash),
|
||||||
|
}
|
||||||
|
if _, err := h.db.Insert(runner); err != nil {
|
||||||
|
jsonError(w, "runner name already taken", http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
jsonOK(w, map[string]any{
|
||||||
|
"id": runner.ID,
|
||||||
|
"name": runner.Name,
|
||||||
|
"token": token, // shown once — store it securely
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func isAdmin(r *http.Request) bool {
|
||||||
|
v, _ := r.Context().Value(middleware.ContextKeyIsAdmin).(bool)
|
||||||
|
return v
|
||||||
|
}
|
||||||
+23
-5
@@ -20,7 +20,7 @@ import (
|
|||||||
"github.com/forgeo/forgebucket/internal/events"
|
"github.com/forgeo/forgebucket/internal/events"
|
||||||
)
|
)
|
||||||
|
|
||||||
func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, bus events.EventBus, staticFiles fs.FS) http.Handler {
|
func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, bus events.EventBus, artifactRoot string, staticFiles fs.FS) http.Handler {
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|
||||||
r.Use(chimiddleware.Logger)
|
r.Use(chimiddleware.Logger)
|
||||||
@@ -43,7 +43,7 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, bus even
|
|||||||
prH := handlers.NewPRHandler(engine)
|
prH := handlers.NewPRHandler(engine)
|
||||||
pipeH := handlers.NewPipelineHandler(engine)
|
pipeH := handlers.NewPipelineHandler(engine)
|
||||||
wsH := handlers.NewWSHandler(bus)
|
wsH := handlers.NewWSHandler(bus)
|
||||||
gitH := handlers.NewGitHTTPHandler(engine, cfg)
|
gitH := handlers.NewGitHTTPHandler(engine, cfg, bus)
|
||||||
issueH := handlers.NewIssueHandler(engine)
|
issueH := handlers.NewIssueHandler(engine)
|
||||||
sshKeyH := handlers.NewSSHKeyHandler(engine)
|
sshKeyH := handlers.NewSSHKeyHandler(engine)
|
||||||
memberH := handlers.NewMemberHandler(engine)
|
memberH := handlers.NewMemberHandler(engine)
|
||||||
@@ -56,6 +56,8 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, bus even
|
|||||||
exploreH := handlers.NewExploreHandler(engine)
|
exploreH := handlers.NewExploreHandler(engine)
|
||||||
dashH := handlers.NewDashboardHandler(engine)
|
dashH := handlers.NewDashboardHandler(engine)
|
||||||
auditH := handlers.NewAuditHandler(engine)
|
auditH := handlers.NewAuditHandler(engine)
|
||||||
|
artifactH := handlers.NewArtifactHandler(engine, artifactRoot)
|
||||||
|
runnerH := handlers.NewRunnerHandler(engine)
|
||||||
|
|
||||||
// ── Git smart-HTTP transport ───────────────────────────────────────────────
|
// ── Git smart-HTTP transport ───────────────────────────────────────────────
|
||||||
// Regex constraint ensures only *.git paths match, so asset/SPA URLs
|
// Regex constraint ensures only *.git paths match, so asset/SPA URLs
|
||||||
@@ -103,6 +105,11 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, bus even
|
|||||||
r.Get("/dashboard", dashH.Get)
|
r.Get("/dashboard", dashH.Get)
|
||||||
r.Get("/audit", auditH.List)
|
r.Get("/audit", auditH.List)
|
||||||
|
|
||||||
|
r.Route("/admin", func(r chi.Router) {
|
||||||
|
r.Get("/runners", runnerH.List)
|
||||||
|
r.With(csrf).Post("/runners/register", runnerH.Register)
|
||||||
|
})
|
||||||
|
|
||||||
// SSH key management
|
// SSH key management
|
||||||
r.Get("/user/keys", sshKeyH.List)
|
r.Get("/user/keys", sshKeyH.List)
|
||||||
r.With(csrf).Post("/user/keys", sshKeyH.Add)
|
r.With(csrf).Post("/user/keys", sshKeyH.Add)
|
||||||
@@ -140,10 +147,21 @@ func New(cfg *config.Config, engine *xorm.Engine, store sessions.Store, bus even
|
|||||||
r.With(csrf).Post("/{issueNum}/close", issueH.Close)
|
r.With(csrf).Post("/{issueNum}/close", issueH.Close)
|
||||||
r.With(csrf).Post("/{issueNum}/reopen", issueH.Reopen)
|
r.With(csrf).Post("/{issueNum}/reopen", issueH.Reopen)
|
||||||
})
|
})
|
||||||
r.Route("/pipelines", func(r chi.Router) {
|
r.Get("/pipelines", pipeH.ListPipelines)
|
||||||
r.Get("/", pipeH.List)
|
r.Route("/runs", func(r chi.Router) {
|
||||||
r.Get("/{runID}", pipeH.Get)
|
r.Get("/", pipeH.ListRuns)
|
||||||
|
r.Route("/{runID}", func(r chi.Router) {
|
||||||
|
r.Get("/", pipeH.GetRun)
|
||||||
|
r.With(csrf).Post("/cancel", pipeH.CancelRun)
|
||||||
|
r.Route("/jobs/{jobID}", func(r chi.Router) {
|
||||||
|
r.Get("/logs", pipeH.GetJobLogs)
|
||||||
|
r.With(csrf).Post("/retry", pipeH.RetryJob)
|
||||||
|
})
|
||||||
|
r.Get("/artifacts", artifactH.List)
|
||||||
|
r.With(csrf).Post("/artifacts", artifactH.Upload)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
r.Get("/artifacts/{artifactID}/download", artifactH.Download)
|
||||||
r.Route("/members", func(r chi.Router) {
|
r.Route("/members", func(r chi.Router) {
|
||||||
r.Get("/", memberH.List)
|
r.Get("/", memberH.List)
|
||||||
r.With(csrf).Post("/", memberH.Add)
|
r.With(csrf).Post("/", memberH.Add)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package config
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -14,7 +15,8 @@ type Config struct {
|
|||||||
DatabaseURL string
|
DatabaseURL string
|
||||||
|
|
||||||
// Storage
|
// Storage
|
||||||
RepoRoot string
|
RepoRoot string
|
||||||
|
ArtifactRoot string
|
||||||
|
|
||||||
// Security
|
// Security
|
||||||
SessionSecret string // must be 32 or 64 bytes for AES-GCM
|
SessionSecret string // must be 32 or 64 bytes for AES-GCM
|
||||||
@@ -37,10 +39,12 @@ type Config struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Load() (*Config, error) {
|
func Load() (*Config, error) {
|
||||||
|
repoRoot := getEnv("REPO_ROOT", "/var/lib/forgebucket/repos")
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
Port: getEnv("PORT", "8080"),
|
Port: getEnv("PORT", "8080"),
|
||||||
RepoRoot: getEnv("REPO_ROOT", "/var/lib/forgebucket/repos"),
|
RepoRoot: repoRoot,
|
||||||
Debug: getEnvBool("DEBUG", false),
|
ArtifactRoot: getEnv("ARTIFACT_ROOT", filepath.Join(filepath.Dir(repoRoot), "artifacts")),
|
||||||
|
Debug: getEnvBool("DEBUG", false),
|
||||||
|
|
||||||
NATSUrl: getEnv("NATS_URL", ""),
|
NATSUrl: getEnv("NATS_URL", ""),
|
||||||
InstanceURL: getEnv("INSTANCE_URL", ""),
|
InstanceURL: getEnv("INSTANCE_URL", ""),
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package ci
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// dagNode holds a job name and its resolved dependencies.
|
||||||
|
type dagNode struct {
|
||||||
|
name string
|
||||||
|
needs []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// TopoSort returns the job names in a valid topological execution order.
|
||||||
|
// Returns an error if the dependency graph has cycles or references unknown jobs.
|
||||||
|
func TopoSort(jobs map[string]WorkflowJob) ([]string, error) {
|
||||||
|
nodes := make(map[string]*dagNode, len(jobs))
|
||||||
|
for name, job := range jobs {
|
||||||
|
nodes[name] = &dagNode{name: name, needs: []string(job.Needs)}
|
||||||
|
}
|
||||||
|
// Validate all dependencies exist.
|
||||||
|
for _, node := range nodes {
|
||||||
|
for _, dep := range node.needs {
|
||||||
|
if _, ok := nodes[dep]; !ok {
|
||||||
|
return nil, fmt.Errorf("job %q depends on unknown job %q", node.name, dep)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var order []string
|
||||||
|
visited := make(map[string]bool, len(nodes))
|
||||||
|
inStack := make(map[string]bool, len(nodes))
|
||||||
|
|
||||||
|
var visit func(name string) error
|
||||||
|
visit = func(name string) error {
|
||||||
|
if inStack[name] {
|
||||||
|
return fmt.Errorf("cycle detected at job %q", name)
|
||||||
|
}
|
||||||
|
if visited[name] {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
inStack[name] = true
|
||||||
|
for _, dep := range nodes[name].needs {
|
||||||
|
if err := visit(dep); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
inStack[name] = false
|
||||||
|
visited[name] = true
|
||||||
|
order = append(order, name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for name := range nodes {
|
||||||
|
if err := visit(name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return order, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadyJobs returns the names of jobs whose dependencies are all in completedJobs.
|
||||||
|
func ReadyJobs(jobs map[string]WorkflowJob, completedJobs map[string]bool, enqueuedJobs map[string]bool) []string {
|
||||||
|
var ready []string
|
||||||
|
for name, job := range jobs {
|
||||||
|
if enqueuedJobs[name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
allDone := true
|
||||||
|
for _, dep := range job.Needs {
|
||||||
|
if !completedJobs[dep] {
|
||||||
|
allDone = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if allDone {
|
||||||
|
ready = append(ready, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ready
|
||||||
|
}
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
package ci
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"xorm.io/xorm"
|
||||||
|
|
||||||
|
"github.com/forgeo/forgebucket/internal/events"
|
||||||
|
"github.com/forgeo/forgebucket/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// JobContext holds everything needed to execute a single pipeline job.
|
||||||
|
type JobContext struct {
|
||||||
|
Run models.PipelineRun
|
||||||
|
Job models.PipelineJob
|
||||||
|
Steps []models.PipelineStep
|
||||||
|
Repo models.Repository
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecuteJob runs all steps of a job inside isolated Docker containers,
|
||||||
|
// streams log output to NATS and the DB, then publishes job.completed or job.failed.
|
||||||
|
func ExecuteJob(ctx context.Context, db *xorm.Engine, bus events.EventBus, jc JobContext, workspaceRoot string) {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
jc.Job.Status = "running"
|
||||||
|
jc.Job.StartedAt = &now
|
||||||
|
db.ID(jc.Job.ID).Cols("status", "started_at").Update(&jc.Job) //nolint:errcheck
|
||||||
|
|
||||||
|
// Extract repo snapshot into a workspace directory.
|
||||||
|
workDir, err := extractWorkspace(jc.Repo.DiskPath, jc.Run.TriggerSHA, workspaceRoot, jc.Run.ID)
|
||||||
|
if err != nil {
|
||||||
|
failJob(db, bus, jc, fmt.Sprintf("workspace setup failed: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(workDir)
|
||||||
|
|
||||||
|
image := jc.Job.Image
|
||||||
|
if image == "" {
|
||||||
|
image = "ubuntu:22.04"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pull image once per job (non-fatal if pull fails and image exists locally).
|
||||||
|
pullCmd := exec.CommandContext(ctx, "docker", "pull", image)
|
||||||
|
pullCmd.Run() //nolint:errcheck
|
||||||
|
|
||||||
|
for i := range jc.Steps {
|
||||||
|
step := &jc.Steps[i]
|
||||||
|
if step.UsesAction == "checkout" {
|
||||||
|
// Built-in checkout: workspace is already set up by extractWorkspace.
|
||||||
|
markStep(db, step, "succeeded", 0)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if step.RunCmd == "" {
|
||||||
|
markStep(db, step, "skipped", 0)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
exitCode, err := runStep(ctx, db, bus, jc.Run.ID, jc.Job.ID, step, image, workDir)
|
||||||
|
if err != nil || exitCode != 0 {
|
||||||
|
if exitCode == 0 {
|
||||||
|
exitCode = 1
|
||||||
|
}
|
||||||
|
markStep(db, step, "failed", exitCode)
|
||||||
|
failJob(db, bus, jc, fmt.Sprintf("step %q exited %d", step.Name, exitCode))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
markStep(db, step, "succeeded", 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fin := time.Now().UTC()
|
||||||
|
jc.Job.Status = "succeeded"
|
||||||
|
jc.Job.FinishedAt = &fin
|
||||||
|
db.ID(jc.Job.ID).Cols("status", "finished_at").Update(&jc.Job) //nolint:errcheck
|
||||||
|
bus.Publish(events.SubjectJobCompleted, events.JobEvent{ //nolint:errcheck
|
||||||
|
RunID: jc.Run.ID, JobID: jc.Job.ID, Status: "succeeded", At: fin,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// runStep runs a single shell-command step inside a Docker container.
|
||||||
|
func runStep(ctx context.Context, db *xorm.Engine, bus events.EventBus,
|
||||||
|
runID, jobID int64, step *models.PipelineStep, image, workDir string) (int, error) {
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
step.Status = "running"
|
||||||
|
step.StartedAt = &now
|
||||||
|
db.ID(step.ID).Cols("status", "started_at").Update(step) //nolint:errcheck
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, "docker", "run", "--rm",
|
||||||
|
"-v", workDir+":/workspace",
|
||||||
|
"-w", "/workspace",
|
||||||
|
"--network=none", // no network by default; Phase 2C will add network scopes
|
||||||
|
image,
|
||||||
|
"/bin/sh", "-ec", step.RunCmd,
|
||||||
|
)
|
||||||
|
|
||||||
|
stdout, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return 1, err
|
||||||
|
}
|
||||||
|
cmd.Stderr = cmd.Stdout // merge stderr into stdout
|
||||||
|
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return 1, fmt.Errorf("docker run: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
chunk := 0
|
||||||
|
scanner := bufio.NewScanner(stdout)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text() + "\n"
|
||||||
|
writeLogChunk(db, bus, runID, jobID, step.ID, chunk, line)
|
||||||
|
chunk++
|
||||||
|
}
|
||||||
|
|
||||||
|
exitCode := 0
|
||||||
|
if err := cmd.Wait(); err != nil {
|
||||||
|
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||||
|
exitCode = exitErr.ExitCode()
|
||||||
|
} else {
|
||||||
|
exitCode = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return exitCode, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractWorkspace uses git-archive to export the repo at a given SHA into a
|
||||||
|
// temporary directory under workspaceRoot.
|
||||||
|
func extractWorkspace(repoPath, sha, workspaceRoot string, runID int64) (string, error) {
|
||||||
|
dir := filepath.Join(workspaceRoot, fmt.Sprintf("run-%d", runID))
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
archive := exec.Command("git", "archive", "--format=tar", sha)
|
||||||
|
archive.Dir = repoPath
|
||||||
|
archive.Env = []string{"GIT_TERMINAL_PROMPT=0", "HOME=/tmp"}
|
||||||
|
|
||||||
|
tar := exec.Command("tar", "-x", "-C", dir)
|
||||||
|
tar.Stdin, _ = archive.StdoutPipe()
|
||||||
|
|
||||||
|
if err := archive.Start(); err != nil {
|
||||||
|
os.RemoveAll(dir)
|
||||||
|
return "", fmt.Errorf("git archive: %w", err)
|
||||||
|
}
|
||||||
|
if err := tar.Start(); err != nil {
|
||||||
|
archive.Process.Kill() //nolint:errcheck
|
||||||
|
os.RemoveAll(dir)
|
||||||
|
return "", fmt.Errorf("tar: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
archiveErr := archive.Wait()
|
||||||
|
tarErr := tar.Wait()
|
||||||
|
if archiveErr != nil {
|
||||||
|
os.RemoveAll(dir)
|
||||||
|
return "", fmt.Errorf("git archive wait: %w", archiveErr)
|
||||||
|
}
|
||||||
|
if tarErr != nil {
|
||||||
|
os.RemoveAll(dir)
|
||||||
|
return "", fmt.Errorf("tar wait: %w", tarErr)
|
||||||
|
}
|
||||||
|
return dir, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeLogChunk(db *xorm.Engine, bus events.EventBus, runID, jobID, stepID int64, idx int, content string) {
|
||||||
|
entry := &models.PipelineStepLog{
|
||||||
|
StepID: stepID,
|
||||||
|
ChunkIndex: idx,
|
||||||
|
Content: content,
|
||||||
|
}
|
||||||
|
db.Insert(entry) //nolint:errcheck
|
||||||
|
bus.Publish(events.SubjectPipelineLog, events.LogChunkEvent{ //nolint:errcheck
|
||||||
|
RunID: runID, JobID: jobID, StepID: stepID, ChunkIndex: idx, Content: content,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func markStep(db *xorm.Engine, step *models.PipelineStep, status string, exitCode int) {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
step.Status = status
|
||||||
|
step.ExitCode = exitCode
|
||||||
|
step.FinishedAt = &now
|
||||||
|
db.ID(step.ID).Cols("status", "exit_code", "finished_at").Update(step) //nolint:errcheck
|
||||||
|
}
|
||||||
|
|
||||||
|
func failJob(db *xorm.Engine, bus events.EventBus, jc JobContext, reason string) {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
jc.Job.Status = "failed"
|
||||||
|
jc.Job.FinishedAt = &now
|
||||||
|
db.ID(jc.Job.ID).Cols("status", "finished_at").Update(&jc.Job) //nolint:errcheck
|
||||||
|
|
||||||
|
// Write the failure reason as a synthetic log line.
|
||||||
|
var lastStep models.PipelineStep
|
||||||
|
if found, _ := db.Where("job_id = ?", jc.Job.ID).Desc("seq").Get(&lastStep); found {
|
||||||
|
writeLogChunk(db, bus, jc.Run.ID, jc.Job.ID, lastStep.ID, 0,
|
||||||
|
"\n[ForgeBucket] Job failed: "+reason+"\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
bus.Publish(events.SubjectJobFailed, events.JobEvent{ //nolint:errcheck
|
||||||
|
RunID: jc.Run.ID, JobID: jc.Job.ID, Status: "failed", At: now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// workspaceDir returns the scratch directory root for CI job workspaces.
|
||||||
|
func workspaceDir(artifactRoot string) string {
|
||||||
|
return filepath.Join(filepath.Dir(artifactRoot), "ci-workspaces")
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsDockerAvailable checks whether the docker CLI is reachable.
|
||||||
|
func IsDockerAvailable() bool {
|
||||||
|
cmd := exec.Command("docker", "info")
|
||||||
|
cmd.Env = []string{"HOME=/tmp"}
|
||||||
|
return cmd.Run() == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// stepsForJob loads PipelineStep rows for a job ordered by seq.
|
||||||
|
func stepsForJob(db *xorm.Engine, jobID int64) ([]models.PipelineStep, error) {
|
||||||
|
var steps []models.PipelineStep
|
||||||
|
err := db.Where("job_id = ?", jobID).Asc("seq").Find(&steps)
|
||||||
|
return steps, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// repoForRun loads the Repository for a given run.
|
||||||
|
func repoForRun(db *xorm.Engine, runID int64) (models.Repository, models.PipelineRun, bool) {
|
||||||
|
var run models.PipelineRun
|
||||||
|
if found, _ := db.ID(runID).Get(&run); !found {
|
||||||
|
return models.Repository{}, run, false
|
||||||
|
}
|
||||||
|
var repo models.Repository
|
||||||
|
if found, _ := db.ID(run.RepoID).Get(&repo); !found {
|
||||||
|
return models.Repository{}, run, false
|
||||||
|
}
|
||||||
|
return repo, run, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildJobContext assembles a JobContext from DB rows.
|
||||||
|
func buildJobContext(db *xorm.Engine, jobID int64) (JobContext, bool) {
|
||||||
|
var job models.PipelineJob
|
||||||
|
if found, _ := db.ID(jobID).Get(&job); !found {
|
||||||
|
return JobContext{}, false
|
||||||
|
}
|
||||||
|
repo, run, ok := repoForRun(db, job.RunID)
|
||||||
|
if !ok {
|
||||||
|
return JobContext{}, false
|
||||||
|
}
|
||||||
|
steps, err := stepsForJob(db, jobID)
|
||||||
|
if err != nil {
|
||||||
|
return JobContext{}, false
|
||||||
|
}
|
||||||
|
return JobContext{Run: run, Job: job, Steps: steps, Repo: repo}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// pipeForRun returns the longest-matching step label for an image.
|
||||||
|
// Phase 2B: unused placeholder for future label matching.
|
||||||
|
func pipeForRun(_ string) string { return "" }
|
||||||
|
|
||||||
|
// sanitizeImage prevents injection in docker image names.
|
||||||
|
func sanitizeImage(image string) string {
|
||||||
|
// Allow only characters valid in Docker image references.
|
||||||
|
var b strings.Builder
|
||||||
|
for _, c := range image {
|
||||||
|
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
|
||||||
|
(c >= '0' && c <= '9') || c == '.' || c == '-' || c == '_' ||
|
||||||
|
c == '/' || c == ':' || c == '@' {
|
||||||
|
b.WriteRune(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
package ci
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"xorm.io/xorm"
|
||||||
|
|
||||||
|
"github.com/forgeo/forgebucket/internal/events"
|
||||||
|
"github.com/forgeo/forgebucket/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Orchestrator listens for push events, creates pipeline run records, and
|
||||||
|
// advances the DAG as jobs complete. It does NOT execute jobs directly —
|
||||||
|
// that is the RunnerManager's responsibility.
|
||||||
|
type Orchestrator struct {
|
||||||
|
db *xorm.Engine
|
||||||
|
bus events.EventBus
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewOrchestrator(db *xorm.Engine, bus events.EventBus) *Orchestrator {
|
||||||
|
return &Orchestrator{db: db, bus: bus}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start subscribes to relevant NATS subjects and blocks until ctx is cancelled.
|
||||||
|
func (o *Orchestrator) Start(ctx context.Context) {
|
||||||
|
o.recoverStaleRuns()
|
||||||
|
|
||||||
|
unsub1, err := o.bus.Subscribe(events.SubjectPushReceived, func(_ string, data []byte) {
|
||||||
|
var evt events.PushEvent
|
||||||
|
if err := json.Unmarshal(data, &evt); err != nil {
|
||||||
|
log.Printf("orchestrator: bad push event: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go o.handlePush(evt)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("orchestrator: subscribe push.received: %v", err)
|
||||||
|
} else {
|
||||||
|
defer unsub1()
|
||||||
|
}
|
||||||
|
|
||||||
|
unsub2, err := o.bus.Subscribe(events.SubjectJobCompleted, func(_ string, data []byte) {
|
||||||
|
var evt events.JobEvent
|
||||||
|
if err := json.Unmarshal(data, &evt); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go o.advanceDAG(evt.RunID, evt.JobID, "succeeded")
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("orchestrator: subscribe job.completed: %v", err)
|
||||||
|
} else {
|
||||||
|
defer unsub2()
|
||||||
|
}
|
||||||
|
|
||||||
|
unsub3, err := o.bus.Subscribe(events.SubjectJobFailed, func(_ string, data []byte) {
|
||||||
|
var evt events.JobEvent
|
||||||
|
if err := json.Unmarshal(data, &evt); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go o.advanceDAG(evt.RunID, evt.JobID, "failed")
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("orchestrator: subscribe job.failed: %v", err)
|
||||||
|
} else {
|
||||||
|
defer unsub3()
|
||||||
|
}
|
||||||
|
|
||||||
|
<-ctx.Done()
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlePush is called for every successful git push. It finds matching workflow
|
||||||
|
// files, creates run records, and enqueues the first wave of jobs.
|
||||||
|
func (o *Orchestrator) handlePush(evt events.PushEvent) {
|
||||||
|
// Ignore branch deletions (new SHA = all zeros).
|
||||||
|
if evt.After == "" || strings.Repeat("0", len(evt.After)) == evt.After {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var repo models.Repository
|
||||||
|
if found, _ := o.db.ID(evt.RepoID).Get(&repo); !found {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
workflowPaths, err := ListWorkflows(repo.DiskPath, evt.After)
|
||||||
|
if err != nil || len(workflowPaths) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, path := range workflowPaths {
|
||||||
|
wf, err := ParseWorkflow(repo.DiskPath, evt.After, path)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("orchestrator: parse workflow %s: %v", path, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !MatchesPushTrigger(wf, evt.Ref) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := o.createRun(repo, evt, wf, path); err != nil {
|
||||||
|
log.Printf("orchestrator: create run for %s: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *Orchestrator) createRun(repo models.Repository, evt events.PushEvent, wf *WorkflowFile, filePath string) error {
|
||||||
|
// Upsert the Pipeline definition record.
|
||||||
|
pipeline := &models.Pipeline{RepoID: repo.ID, FilePath: filePath}
|
||||||
|
has, _ := o.db.Where("repo_id = ? AND file_path = ?", repo.ID, filePath).Get(pipeline)
|
||||||
|
pipeline.Name = wf.Name
|
||||||
|
if pipeline.Name == "" {
|
||||||
|
pipeline.Name = filePath
|
||||||
|
}
|
||||||
|
if has {
|
||||||
|
o.db.ID(pipeline.ID).Cols("name").Update(pipeline) //nolint:errcheck
|
||||||
|
} else {
|
||||||
|
if _, err := o.db.Insert(pipeline); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate DAG before writing anything.
|
||||||
|
if _, err := TopoSort(wf.Jobs); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
run := &models.PipelineRun{
|
||||||
|
PipelineID: pipeline.ID,
|
||||||
|
RepoID: repo.ID,
|
||||||
|
TriggerRef: evt.Ref,
|
||||||
|
TriggerSHA: evt.After,
|
||||||
|
TriggeredBy: evt.Pusher,
|
||||||
|
Status: "queued",
|
||||||
|
StartedAt: &now,
|
||||||
|
}
|
||||||
|
if _, err := o.db.Insert(run); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create job + step records for every job in the workflow.
|
||||||
|
for jobName, wfJob := range wf.Jobs {
|
||||||
|
needsJSON, _ := json.Marshal([]string(wfJob.Needs))
|
||||||
|
job := &models.PipelineJob{
|
||||||
|
RunID: run.ID,
|
||||||
|
Name: jobName,
|
||||||
|
Image: wfJob.RunsOn,
|
||||||
|
Needs: string(needsJSON),
|
||||||
|
Status: "queued",
|
||||||
|
}
|
||||||
|
if _, err := o.db.Insert(job); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for seq, step := range wfJob.Steps {
|
||||||
|
s := &models.PipelineStep{
|
||||||
|
JobID: job.ID,
|
||||||
|
Seq: seq,
|
||||||
|
Name: step.Name,
|
||||||
|
RunCmd: step.Run,
|
||||||
|
UsesAction: step.Uses,
|
||||||
|
Status: "queued",
|
||||||
|
}
|
||||||
|
if _, err := o.db.Insert(s); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enqueue jobs with no dependencies.
|
||||||
|
o.enqueueReadyJobs(run.ID, wf.Jobs)
|
||||||
|
|
||||||
|
o.bus.Publish(events.SubjectPipelineTriggered, events.PipelineEvent{ //nolint:errcheck
|
||||||
|
RunID: run.ID,
|
||||||
|
RepoID: repo.ID,
|
||||||
|
Status: "queued",
|
||||||
|
At: now,
|
||||||
|
})
|
||||||
|
|
||||||
|
log.Printf("orchestrator: created run %d for %s/%s (%s)", run.ID, repo.Name, filePath, evt.After[:7])
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// advanceDAG is called when a job finishes. It marks the job, checks whether
|
||||||
|
// all jobs are done (completing the run) or enqueues the next wave.
|
||||||
|
func (o *Orchestrator) advanceDAG(runID, jobID int64, result string) {
|
||||||
|
var job models.PipelineJob
|
||||||
|
if found, _ := o.db.ID(jobID).Get(&job); !found {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
job.Status = result
|
||||||
|
job.FinishedAt = &now
|
||||||
|
o.db.ID(job.ID).Cols("status", "finished_at").Update(&job) //nolint:errcheck
|
||||||
|
|
||||||
|
var run models.PipelineRun
|
||||||
|
if found, _ := o.db.ID(runID).Get(&run); !found {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load all jobs for this run to check completion.
|
||||||
|
var allJobs []models.PipelineJob
|
||||||
|
o.db.Where("run_id = ?", runID).Find(&allJobs)
|
||||||
|
|
||||||
|
// If any job failed, cancel remaining queued jobs and fail the run.
|
||||||
|
if result == "failed" {
|
||||||
|
for _, j := range allJobs {
|
||||||
|
if j.Status == "queued" {
|
||||||
|
j.Status = "skipped"
|
||||||
|
o.db.ID(j.ID).Cols("status").Update(&j) //nolint:errcheck
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run.Status = "failed"
|
||||||
|
run.FinishedAt = &now
|
||||||
|
o.db.ID(run.ID).Cols("status", "finished_at").Update(&run) //nolint:errcheck
|
||||||
|
o.bus.Publish(events.SubjectPipelineFailed, events.PipelineEvent{RunID: run.ID, RepoID: run.RepoID, Status: "failed", At: now}) //nolint:errcheck
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if all jobs are done.
|
||||||
|
allDone := true
|
||||||
|
for _, j := range allJobs {
|
||||||
|
if j.Status != "succeeded" && j.Status != "failed" && j.Status != "skipped" && j.Status != "cancelled" {
|
||||||
|
allDone = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if allDone {
|
||||||
|
run.Status = "succeeded"
|
||||||
|
run.FinishedAt = &now
|
||||||
|
o.db.ID(run.ID).Cols("status", "finished_at").Update(&run) //nolint:errcheck
|
||||||
|
o.bus.Publish(events.SubjectPipelineCompleted, events.PipelineEvent{RunID: run.ID, RepoID: run.RepoID, Status: "succeeded", At: now}) //nolint:errcheck
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload the workflow to get the job dependency graph, then enqueue next wave.
|
||||||
|
var pipeline models.Pipeline
|
||||||
|
if found, _ := o.db.ID(run.PipelineID).Get(&pipeline); !found {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var repo models.Repository
|
||||||
|
if found, _ := o.db.ID(run.RepoID).Get(&repo); !found {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wf, err := ParseWorkflow(repo.DiskPath, run.TriggerSHA, pipeline.FilePath)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
o.enqueueReadyJobs(runID, wf.Jobs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *Orchestrator) enqueueReadyJobs(runID int64, wfJobs map[string]WorkflowJob) {
|
||||||
|
var dbJobs []models.PipelineJob
|
||||||
|
o.db.Where("run_id = ?", runID).Find(&dbJobs)
|
||||||
|
|
||||||
|
completedNames := make(map[string]bool)
|
||||||
|
enqueuedNames := make(map[string]bool)
|
||||||
|
for _, j := range dbJobs {
|
||||||
|
if j.Status == "succeeded" {
|
||||||
|
completedNames[j.Name] = true
|
||||||
|
}
|
||||||
|
if j.Status == "running" || j.Status == "succeeded" {
|
||||||
|
enqueuedNames[j.Name] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
readyNames := ReadyJobs(wfJobs, completedNames, enqueuedNames)
|
||||||
|
for _, name := range readyNames {
|
||||||
|
for _, j := range dbJobs {
|
||||||
|
if j.Name == name && j.Status == "queued" {
|
||||||
|
o.bus.Publish(events.SubjectJobQueued, events.JobEvent{ //nolint:errcheck
|
||||||
|
RunID: runID,
|
||||||
|
JobID: j.ID,
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// recoverStaleRuns marks any jobs/runs left in "running" state as failed
|
||||||
|
// (they were interrupted by a previous server crash).
|
||||||
|
func (o *Orchestrator) recoverStaleRuns() {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
o.db.Where("status = 'running'").Cols("status", "finished_at").
|
||||||
|
Update(&models.PipelineRun{Status: "failed", FinishedAt: &now}) //nolint:errcheck
|
||||||
|
o.db.Where("status = 'running'").Cols("status", "finished_at").
|
||||||
|
Update(&models.PipelineJob{Status: "failed", FinishedAt: &now}) //nolint:errcheck
|
||||||
|
o.db.Where("status = 'running'").Cols("status", "finished_at").
|
||||||
|
Update(&models.PipelineStep{Status: "failed", FinishedAt: &now}) //nolint:errcheck
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package ci
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
gitdomain "github.com/forgeo/forgebucket/internal/domain/git"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
const workflowDir = ".forgebucket/workflows"
|
||||||
|
|
||||||
|
// ListWorkflows returns the file paths of all workflow YAML files in a repo at a
|
||||||
|
// given ref. Returns nil (no error) when the workflows directory doesn't exist.
|
||||||
|
func ListWorkflows(repoPath, ref string) ([]string, error) {
|
||||||
|
entries, err := gitdomain.TreeLS(repoPath, ref, workflowDir)
|
||||||
|
if err != nil {
|
||||||
|
// Directory does not exist at this ref — no workflows, not an error.
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var paths []string
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.Type == "blob" && (strings.HasSuffix(e.Name, ".yml") || strings.HasSuffix(e.Name, ".yaml")) {
|
||||||
|
paths = append(paths, workflowDir+"/"+e.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return paths, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseWorkflow reads and parses a single workflow YAML file from the repo at ref.
|
||||||
|
func ParseWorkflow(repoPath, ref, filePath string) (*WorkflowFile, error) {
|
||||||
|
data, err := gitdomain.BlobCat(repoPath, ref, filePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read %s: %w", filePath, err)
|
||||||
|
}
|
||||||
|
var wf WorkflowFile
|
||||||
|
if err := yaml.Unmarshal(data, &wf); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse %s: %w", filePath, err)
|
||||||
|
}
|
||||||
|
return &wf, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MatchesPushTrigger reports whether a workflow should run for a push to ref.
|
||||||
|
// ref is the full ref name, e.g. "refs/heads/main".
|
||||||
|
func MatchesPushTrigger(wf *WorkflowFile, ref string) bool {
|
||||||
|
if wf.On.Push == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
trigger := wf.On.Push
|
||||||
|
// No branch filter means "all branches".
|
||||||
|
if len(trigger.Branches) == 0 && len(trigger.Tags) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
branch := strings.TrimPrefix(ref, "refs/heads/")
|
||||||
|
for _, pattern := range trigger.Branches {
|
||||||
|
if matchGlob(pattern, branch) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tag := strings.TrimPrefix(ref, "refs/tags/")
|
||||||
|
for _, pattern := range trigger.Tags {
|
||||||
|
if matchGlob(pattern, tag) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchGlob supports simple "*" wildcards (not full glob).
|
||||||
|
func matchGlob(pattern, s string) bool {
|
||||||
|
if pattern == "*" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if !strings.Contains(pattern, "*") {
|
||||||
|
return pattern == s
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(pattern, "*", 2)
|
||||||
|
return strings.HasPrefix(s, parts[0]) && strings.HasSuffix(s, parts[1])
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package ci
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"xorm.io/xorm"
|
||||||
|
|
||||||
|
"github.com/forgeo/forgebucket/internal/config"
|
||||||
|
"github.com/forgeo/forgebucket/internal/events"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RunnerManager subscribes to job.queued events and dispatches them to the
|
||||||
|
// local Docker executor. A semaphore limits concurrent executions.
|
||||||
|
type RunnerManager struct {
|
||||||
|
db *xorm.Engine
|
||||||
|
bus events.EventBus
|
||||||
|
cfg *config.Config
|
||||||
|
sem chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRunnerManager(db *xorm.Engine, bus events.EventBus, cfg *config.Config, maxConcurrent int) *RunnerManager {
|
||||||
|
if maxConcurrent <= 0 {
|
||||||
|
maxConcurrent = 4
|
||||||
|
}
|
||||||
|
return &RunnerManager{
|
||||||
|
db: db,
|
||||||
|
bus: bus,
|
||||||
|
cfg: cfg,
|
||||||
|
sem: make(chan struct{}, maxConcurrent),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start subscribes to job.queued and dispatches executions until ctx is cancelled.
|
||||||
|
func (m *RunnerManager) Start(ctx context.Context) {
|
||||||
|
if !IsDockerAvailable() {
|
||||||
|
log.Printf("runner: Docker not available — CI execution disabled")
|
||||||
|
<-ctx.Done()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("runner: started (max concurrent jobs: %d)", cap(m.sem))
|
||||||
|
|
||||||
|
wsDir := workspaceDir(m.cfg.ArtifactRoot)
|
||||||
|
|
||||||
|
unsub, err := m.bus.Subscribe(events.SubjectJobQueued, func(_ string, data []byte) {
|
||||||
|
var evt events.JobEvent
|
||||||
|
if err := json.Unmarshal(data, &evt); err != nil {
|
||||||
|
log.Printf("runner: bad job.queued payload: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
jc, ok := buildJobContext(m.db, evt.JobID)
|
||||||
|
if !ok {
|
||||||
|
log.Printf("runner: could not build job context for job %d", evt.JobID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Acquire semaphore slot — blocks if at capacity.
|
||||||
|
select {
|
||||||
|
case m.sem <- struct{}{}:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer func() { <-m.sem }()
|
||||||
|
// Sanitize the Docker image name before execution.
|
||||||
|
jc.Job.Image = sanitizeImage(jc.Job.Image)
|
||||||
|
ExecuteJob(ctx, m.db, m.bus, jc, wsDir)
|
||||||
|
}()
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("runner: subscribe job.queued: %v", err)
|
||||||
|
<-ctx.Done()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer unsub()
|
||||||
|
|
||||||
|
<-ctx.Done()
|
||||||
|
log.Printf("runner: stopping — draining %d active jobs", len(m.sem))
|
||||||
|
// Wait for all running jobs to finish by filling the semaphore.
|
||||||
|
for i := 0; i < cap(m.sem); i++ {
|
||||||
|
m.sem <- struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package ci
|
||||||
|
|
||||||
|
import "gopkg.in/yaml.v3"
|
||||||
|
|
||||||
|
// WorkflowFile is the parsed representation of a .forgebucket/workflows/*.yml file.
|
||||||
|
type WorkflowFile struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
On WorkflowTrigger `yaml:"on"`
|
||||||
|
Jobs map[string]WorkflowJob `yaml:"jobs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorkflowTrigger struct {
|
||||||
|
Push *PushTrigger `yaml:"push"`
|
||||||
|
PullRequest *PRTrigger `yaml:"pull_request"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PushTrigger struct {
|
||||||
|
Branches []string `yaml:"branches"`
|
||||||
|
Tags []string `yaml:"tags"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PRTrigger struct {
|
||||||
|
Branches []string `yaml:"branches"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorkflowJob struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
RunsOn string `yaml:"runs-on"`
|
||||||
|
Needs StringOrSlice `yaml:"needs"`
|
||||||
|
Steps []WorkflowStep `yaml:"steps"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorkflowStep struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Uses string `yaml:"uses"`
|
||||||
|
Run string `yaml:"run"`
|
||||||
|
Env map[string]string `yaml:"env"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StringOrSlice unmarshals a YAML value that may be either a single string
|
||||||
|
// ("needs: test") or a list ("needs: [test, build]").
|
||||||
|
type StringOrSlice []string
|
||||||
|
|
||||||
|
func (s *StringOrSlice) UnmarshalYAML(value *yaml.Node) error {
|
||||||
|
switch value.Kind {
|
||||||
|
case yaml.ScalarNode:
|
||||||
|
if value.Value != "" {
|
||||||
|
*s = []string{value.Value}
|
||||||
|
}
|
||||||
|
case yaml.SequenceNode:
|
||||||
|
var items []string
|
||||||
|
if err := value.Decode(&items); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*s = items
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ const (
|
|||||||
SubjectJobCompleted = "job.completed"
|
SubjectJobCompleted = "job.completed"
|
||||||
SubjectJobFailed = "job.failed"
|
SubjectJobFailed = "job.failed"
|
||||||
SubjectArtifactPublished = "artifact.published"
|
SubjectArtifactPublished = "artifact.published"
|
||||||
|
SubjectPipelineLog = "pipeline.log"
|
||||||
|
|
||||||
// Deployments (Phase 3A)
|
// Deployments (Phase 3A)
|
||||||
SubjectDeploymentStarted = "deployment.started"
|
SubjectDeploymentStarted = "deployment.started"
|
||||||
|
|||||||
@@ -57,6 +57,28 @@ type AuditEvent struct {
|
|||||||
At time.Time `json:"at"`
|
At time.Time `json:"at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PipelineEvent struct {
|
||||||
|
RunID int64 `json:"runId"`
|
||||||
|
RepoID int64 `json:"repoId"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
At time.Time `json:"at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type JobEvent struct {
|
||||||
|
RunID int64 `json:"runId"`
|
||||||
|
JobID int64 `json:"jobId"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
At time.Time `json:"at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LogChunkEvent struct {
|
||||||
|
RunID int64 `json:"runId"`
|
||||||
|
JobID int64 `json:"jobId"`
|
||||||
|
StepID int64 `json:"stepId"`
|
||||||
|
ChunkIndex int `json:"chunkIndex"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
// WSEnvelope wraps any event for delivery over the WebSocket connection.
|
// WSEnvelope wraps any event for delivery over the WebSocket connection.
|
||||||
type WSEnvelope struct {
|
type WSEnvelope struct {
|
||||||
Subject string `json:"subject"`
|
Subject string `json:"subject"`
|
||||||
|
|||||||
@@ -34,5 +34,8 @@ func Run(engine *xorm.Engine) error {
|
|||||||
if err := Run007(engine); err != nil {
|
if err := Run007(engine); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return Run008(engine)
|
if err := Run008(engine); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return Run009(engine)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/forgeo/forgebucket/internal/models"
|
||||||
|
"xorm.io/xorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Run009(engine *xorm.Engine) error {
|
||||||
|
return engine.Sync2(
|
||||||
|
&models.Pipeline{},
|
||||||
|
&models.PipelineRun{},
|
||||||
|
&models.PipelineJob{},
|
||||||
|
&models.PipelineStep{},
|
||||||
|
&models.PipelineStepLog{},
|
||||||
|
&models.Runner{},
|
||||||
|
&models.Artifact{},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Pipeline represents a workflow definition file stored in the repository.
|
||||||
|
type Pipeline struct {
|
||||||
|
ID int64 `xorm:"'id' pk autoincr" json:"id"`
|
||||||
|
RepoID int64 `xorm:"'repo_id' notnull index" json:"repoId"`
|
||||||
|
Name string `xorm:"'name' varchar(255)" json:"name"`
|
||||||
|
FilePath string `xorm:"'file_path' varchar(500)" json:"filePath"`
|
||||||
|
CreatedAt time.Time `xorm:"'created_at' created" json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `xorm:"'updated_at' updated" json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PipelineRun is a single execution of a Pipeline.
|
||||||
|
type PipelineRun struct {
|
||||||
|
ID int64 `xorm:"'id' pk autoincr" json:"id"`
|
||||||
|
PipelineID int64 `xorm:"'pipeline_id' notnull index" json:"pipelineId"`
|
||||||
|
RepoID int64 `xorm:"'repo_id' notnull index" json:"repoId"`
|
||||||
|
TriggerRef string `xorm:"'trigger_ref' varchar(255)" json:"triggerRef"` // refs/heads/main
|
||||||
|
TriggerSHA string `xorm:"'trigger_sha' varchar(40)" json:"triggerSha"`
|
||||||
|
TriggeredBy string `xorm:"'triggered_by' varchar(64)" json:"triggeredBy"`
|
||||||
|
Status string `xorm:"'status' varchar(20)" json:"status"` // queued/running/succeeded/failed/cancelled
|
||||||
|
StartedAt *time.Time `xorm:"'started_at'" json:"startedAt"`
|
||||||
|
FinishedAt *time.Time `xorm:"'finished_at'" json:"finishedAt"`
|
||||||
|
CreatedAt time.Time `xorm:"'created_at' created" json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PipelineJob is a single node in the DAG for a PipelineRun.
|
||||||
|
type PipelineJob struct {
|
||||||
|
ID int64 `xorm:"'id' pk autoincr" json:"id"`
|
||||||
|
RunID int64 `xorm:"'run_id' notnull index" json:"runId"`
|
||||||
|
Name string `xorm:"'name' varchar(255)" json:"name"`
|
||||||
|
Image string `xorm:"'image' varchar(500)" json:"image"` // Docker image
|
||||||
|
Needs string `xorm:"'needs' text" json:"needs"` // JSON array of dependency job names
|
||||||
|
Status string `xorm:"'status' varchar(20)" json:"status"`
|
||||||
|
StartedAt *time.Time `xorm:"'started_at'" json:"startedAt"`
|
||||||
|
FinishedAt *time.Time `xorm:"'finished_at'" json:"finishedAt"`
|
||||||
|
CreatedAt time.Time `xorm:"'created_at' created" json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PipelineStep is a single command within a PipelineJob.
|
||||||
|
type PipelineStep struct {
|
||||||
|
ID int64 `xorm:"'id' pk autoincr" json:"id"`
|
||||||
|
JobID int64 `xorm:"'job_id' notnull index" json:"jobId"`
|
||||||
|
Seq int `xorm:"'seq'" json:"seq"` // execution order within the job
|
||||||
|
Name string `xorm:"'name' varchar(255)" json:"name"`
|
||||||
|
RunCmd string `xorm:"'run_cmd' text" json:"runCmd"` // shell command (run:)
|
||||||
|
UsesAction string `xorm:"'uses_action' varchar(255)" json:"usesAction"` // built-in action (uses:)
|
||||||
|
Status string `xorm:"'status' varchar(20)" json:"status"`
|
||||||
|
ExitCode int `xorm:"'exit_code'" json:"exitCode"`
|
||||||
|
StartedAt *time.Time `xorm:"'started_at'" json:"startedAt"`
|
||||||
|
FinishedAt *time.Time `xorm:"'finished_at'" json:"finishedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PipelineStepLog stores append-only log output for a step.
|
||||||
|
type PipelineStepLog struct {
|
||||||
|
ID int64 `xorm:"'id' pk autoincr" json:"id"`
|
||||||
|
StepID int64 `xorm:"'step_id' notnull index" json:"stepId"`
|
||||||
|
ChunkIndex int `xorm:"'chunk_index'" json:"chunkIndex"`
|
||||||
|
Content string `xorm:"'content' text" json:"content"`
|
||||||
|
CreatedAt time.Time `xorm:"'created_at' created" json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runner is a registered execution backend.
|
||||||
|
type Runner struct {
|
||||||
|
ID int64 `xorm:"'id' pk autoincr" json:"id"`
|
||||||
|
Name string `xorm:"'name' unique varchar(100)" json:"name"`
|
||||||
|
Labels string `xorm:"'labels' text" json:"labels"` // JSON array of capability labels
|
||||||
|
Status string `xorm:"'status' varchar(20)" json:"status"` // idle/busy/offline
|
||||||
|
TokenHash string `xorm:"'token_hash' varchar(64)" json:"-"`
|
||||||
|
LastSeenAt time.Time `xorm:"'last_seen_at'" json:"lastSeenAt"`
|
||||||
|
CreatedAt time.Time `xorm:"'created_at' created" json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Artifact is a file produced by a PipelineRun and stored for later download.
|
||||||
|
type Artifact struct {
|
||||||
|
ID int64 `xorm:"'id' pk autoincr" json:"id"`
|
||||||
|
RunID int64 `xorm:"'run_id' notnull index" json:"runId"`
|
||||||
|
RepoID int64 `xorm:"'repo_id' notnull index" json:"repoId"`
|
||||||
|
Name string `xorm:"'name' varchar(255)" json:"name"`
|
||||||
|
StoragePath string `xorm:"'storage_path' varchar(500)" json:"-"`
|
||||||
|
Size int64 `xorm:"'size'" json:"size"`
|
||||||
|
ContentType string `xorm:"'content_type' varchar(100)" json:"contentType"`
|
||||||
|
CreatedAt time.Time `xorm:"'created_at' created" json:"createdAt"`
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user