upstream: track prereleases per row; rename gitea adapter to forgejo
Most forges hand back a "latest stable" that ignores prereleases. Fine until something ships only prereleases for a stretch — us, through alpha, where every tag under v0.3.0 goes out as a prerelease. Against that, /releases/latest either freezes or returns nothing, and the dashboard reads like nothing's moving. Add a per-row track_prereleases flag. When it's on, the forgejo adapter walks the full release list and considers prereleases when picking the highest version; off (the default) keeps stable-only behavior, so the other adapters don't change. Seed our own self-row on so we stop looking frozen. Renamed gitea_releases to forgejo_releases while in there — the wire format is Forgejo's, Codeberg runs it, and the old name was a misnomer. Legacy source="gitea" rows still resolve through an alias.
This commit is contained in:
parent
a859fabedf
commit
9e4d59695f
18 changed files with 814 additions and 142 deletions
|
|
@ -22,6 +22,9 @@ Format: version, date, then grouped by category (Added, Changed, Removed, Fixed,
|
|||
|
||||
### Added
|
||||
- Desktop tray updates route through the privileged helper like every other install path.
|
||||
- RedFlag self-tracking: startup seeds a default upstream row for
|
||||
`codeberg.org/Fimeg/RedFlag`, tracks prereleases during alpha, and surfaces a
|
||||
dashboard update banner with the operator-run rebuild command.
|
||||
|
||||
### Changed
|
||||
- Web: interactive/brand surface recolored to steel blue, split cleanly from `danger` so
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@ The full trust model lives in [SECURITY.md](SECURITY.md), including how to repor
|
|||
|
||||
- **Approval workflow** — updates queue for human review before anything runs
|
||||
- **Maintenance windows** — day/time gates on when installs can proceed
|
||||
- **Upstream tracking** — polls GitHub, Gitea, GitLab, Bitbucket for new releases; flags EOL drift
|
||||
- **Upstream tracking** — polls GitHub, Forgejo/Gitea/Codeberg, GitLab, Bitbucket for new releases; flags EOL drift
|
||||
- **Drift detection** — knows what should be installed vs. what is, bridges the gap into update packages
|
||||
- **Agent self-update** — SHA-256 → signature → atomic binary swap → service restart, reconciled server-side
|
||||
- **Dependency dry-run** — checks before installing, not after
|
||||
|
|
@ -144,7 +144,7 @@ The full trust model lives in [SECURITY.md](SECURITY.md), including how to repor
|
|||
- Capability-token minting for dnf/apt with Ed25519-signed token verification
|
||||
- Ed25519 key rotation and replay protection
|
||||
- Maintenance windows
|
||||
- Upstream version tracking (GitHub, Gitea, GitLab, Bitbucket, Repology, endoflife.date)
|
||||
- Upstream version tracking (GitHub, Forgejo/Gitea/Codeberg, GitLab, Bitbucket, Repology, endoflife.date)
|
||||
- Metadata pipeline: CVE details, upstream intelligence, package provenance
|
||||
- Auto-discovery bridge (Repology, container registry, exact match)
|
||||
- Agent self-update via privileged helper (zero agent sudo)
|
||||
|
|
|
|||
|
|
@ -23,9 +23,9 @@ import (
|
|||
"github.com/Fimeg/RedFlag/server/internal/circuitbreaker"
|
||||
"github.com/Fimeg/RedFlag/server/internal/command"
|
||||
"github.com/Fimeg/RedFlag/server/internal/config"
|
||||
"github.com/Fimeg/RedFlag/server/internal/integrations/wazuh"
|
||||
"github.com/Fimeg/RedFlag/server/internal/database"
|
||||
"github.com/Fimeg/RedFlag/server/internal/database/queries"
|
||||
"github.com/Fimeg/RedFlag/server/internal/integrations/wazuh"
|
||||
"github.com/Fimeg/RedFlag/server/internal/logging"
|
||||
"github.com/Fimeg/RedFlag/server/internal/models"
|
||||
"github.com/Fimeg/RedFlag/server/internal/observability"
|
||||
|
|
@ -447,10 +447,32 @@ func main() {
|
|||
upstreamRegistry.Register(upstream.NewRepology())
|
||||
upstreamRegistry.Register(upstream.NewEndOfLife())
|
||||
upstreamRegistry.Register(upstream.NewGithubReleases())
|
||||
upstreamRegistry.Register(upstream.NewForgejoReleases())
|
||||
upstreamRegistry.Register(upstream.NewGiteaReleases())
|
||||
upstreamRegistry.Register(upstream.NewGitlabReleases())
|
||||
upstreamRegistry.Register(upstream.NewBitbucketTags())
|
||||
upstreamRegistry.Register(upstream.NewGitTags())
|
||||
selfEnabled := true
|
||||
trackPrereleases := true
|
||||
selfCurrentVersion := version.AgentVersion
|
||||
if selfCurrentVersion != "" && !strings.HasPrefix(selfCurrentVersion, "v") {
|
||||
selfCurrentVersion = "v" + selfCurrentVersion
|
||||
}
|
||||
if inserted, err := upstreamQueries.SeedSelf(models.TrackedSoftwareInput{
|
||||
Name: "RedFlag",
|
||||
Ecosystem: "system",
|
||||
Source: "forgejo",
|
||||
SourceRef: "codeberg.org/Fimeg/RedFlag",
|
||||
CurrentVersion: &selfCurrentVersion,
|
||||
Enabled: &selfEnabled,
|
||||
TrackPrereleases: &trackPrereleases,
|
||||
RepologySlug: nil,
|
||||
ContainerImagePattern: nil,
|
||||
}); err != nil {
|
||||
log.Printf("[WARN] [server] [upstream] seed RedFlag self-tracking failed: %v", err)
|
||||
} else if inserted {
|
||||
log.Printf("[INFO] [server] [upstream] seeded RedFlag self-tracking source=forgejo ref=codeberg.org/Fimeg/RedFlag current=%s track_prereleases=true", selfCurrentVersion)
|
||||
}
|
||||
upstreamSyncer := upstream.NewSyncer(upstreamQueries, upstreamRegistry, time.Hour, 6*time.Hour, 50, repologyQueries)
|
||||
upstreamSyncer.Start(context.Background())
|
||||
|
||||
|
|
@ -540,14 +562,14 @@ func main() {
|
|||
// Health check
|
||||
recorder.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{
|
||||
"status": "healthy",
|
||||
"version": version.AgentVersion,
|
||||
"status": "healthy",
|
||||
"version": version.AgentVersion,
|
||||
})
|
||||
})
|
||||
recorder.GET("/api/health", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{
|
||||
"status": "healthy",
|
||||
"version": version.AgentVersion,
|
||||
"status": "healthy",
|
||||
"version": version.AgentVersion,
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -689,7 +711,7 @@ func main() {
|
|||
if securitySettingsService != nil {
|
||||
sweepInterval := time.Duration(getOperationalSetting(securitySettingsService, "orchestrator_sweep_interval_seconds", 60)) * time.Second
|
||||
eventLogger := services.NewSystemEventLogger(db.DB)
|
||||
|
||||
|
||||
// Set up external notification dispatch (ntfy, SMTP) if configured.
|
||||
// Best-effort; failures here do not block the orchestrator.
|
||||
disp := notifier.NewDispatcher(5 * time.Minute)
|
||||
|
|
@ -940,11 +962,12 @@ func main() {
|
|||
admin.DELETE("/maintenance-windows/:id", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), maintenanceWindowHandler.DeleteWindow)
|
||||
admin.GET("/maintenance-windows/check", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), maintenanceWindowHandler.CheckWindow)
|
||||
|
||||
// Upstream version sync (Repology + endoflife.date adapters)
|
||||
// Upstream version sync (Repology, endoflife.date, forge releases, tags)
|
||||
admin.GET("/upstream", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), upstreamHandler.List)
|
||||
admin.GET("/upstream/drift", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), upstreamHandler.ListDrifted)
|
||||
admin.GET("/upstream/drift/events", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), upstreamHandler.RecentDrift)
|
||||
admin.POST("/upstream", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), upstreamHandler.Create)
|
||||
admin.PATCH("/upstream/:id", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), upstreamHandler.UpdateSettings)
|
||||
admin.DELETE("/upstream/:id", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), upstreamHandler.Delete)
|
||||
admin.POST("/upstream/:id/sync", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), upstreamHandler.SyncNow)
|
||||
admin.GET("/upstream/:id/installations", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), agentTrackedSoftwareHandler.ListInstallations)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ package handlers
|
|||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/Fimeg/RedFlag/server/internal/database/queries"
|
||||
|
|
@ -71,7 +73,7 @@ func (h *UpstreamHandler) Create(c *gin.Context) {
|
|||
}
|
||||
if _, ok := h.registry.Get(in.Source); !ok {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "unknown source",
|
||||
"error": "unknown source",
|
||||
"available_sources": h.registry.Names(),
|
||||
})
|
||||
return
|
||||
|
|
@ -93,6 +95,42 @@ func (h *UpstreamHandler) Create(c *gin.Context) {
|
|||
c.JSON(http.StatusCreated, row)
|
||||
}
|
||||
|
||||
func (h *UpstreamHandler) UpdateSettings(c *gin.Context) {
|
||||
id, err := uuid.FromString(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
var in models.TrackedSoftwareSettingsInput
|
||||
if err := c.ShouldBindJSON(&in); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request: " + err.Error()})
|
||||
return
|
||||
}
|
||||
row, err := h.queries.UpdateSettings(id, in)
|
||||
if err != nil {
|
||||
// sql.ErrNoRows (wrapped) means the UPDATE matched no row — a real
|
||||
// not-found. Anything else is a connection/constraint failure and must
|
||||
// not be misreported as 404; surface it as a server error so the cause
|
||||
// isn't hidden behind a misleading "not found."
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "tracked software not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// Kick an immediate sync when the operator changed something that affects
|
||||
// latest_version: re-enabling a row, or flipping track_prereleases on an
|
||||
// enabled one. Matches Create's prompt-sync behavior; fire-and-forget and
|
||||
// idempotent, so a redundant kick on a no-op enable is harmless.
|
||||
if row.Enabled && (in.Enabled != nil || in.TrackPrereleases != nil) {
|
||||
h.bg("upstream_sync_one", func() {
|
||||
_ = h.syncer.SyncOne(context.Background(), id)
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, row)
|
||||
}
|
||||
|
||||
func (h *UpstreamHandler) Delete(c *gin.Context) {
|
||||
id, err := uuid.FromString(c.Param("id"))
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,4 @@
|
|||
-- Reverse of 059: drop the prerelease-tracking opt-in column.
|
||||
|
||||
ALTER TABLE tracked_software
|
||||
DROP COLUMN IF EXISTS track_prereleases;
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
-- Migration 059: per-entry prerelease tracking for upstream sync.
|
||||
--
|
||||
-- Most upstream sources expose a "latest stable" shortcut that excludes
|
||||
-- prereleases (Forgejo/Gitea/Codeberg's /releases/latest, GitHub's likewise).
|
||||
-- For software that ships only prereleases for a stretch — RedFlag itself
|
||||
-- during the alpha run, where every tag < v0.3.0 publishes as a prerelease —
|
||||
-- that shortcut would freeze latest_version at the last stable (or never fill
|
||||
-- it at all). track_prereleases opts a row into the full release walk so the
|
||||
-- prerelease-aware adapters (forgejo) consider prereleases when picking latest.
|
||||
--
|
||||
-- Defaults FALSE: existing rows keep stable-only behavior; only entries that
|
||||
-- explicitly opt in (or the seeded self-entry) follow prereleases.
|
||||
|
||||
ALTER TABLE tracked_software
|
||||
ADD COLUMN IF NOT EXISTS track_prereleases BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
-- Existing canonical RedFlag self rows predate this column, so the DEFAULT
|
||||
-- would make them stable-only throughout alpha. Flip only the exact managed
|
||||
-- self-entry during migration; runtime reconciliation preserves later operator
|
||||
-- choices after this one-time upgrade.
|
||||
UPDATE tracked_software
|
||||
SET track_prereleases = TRUE,
|
||||
updated_at = NOW()
|
||||
WHERE source = 'forgejo'
|
||||
AND source_ref = 'codeberg.org/Fimeg/RedFlag';
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
package queries
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/server/internal/models"
|
||||
|
|
@ -78,18 +80,39 @@ func (q *UpstreamQueries) Create(in models.TrackedSoftwareInput) (*models.Tracke
|
|||
if in.Enabled != nil {
|
||||
enabled = *in.Enabled
|
||||
}
|
||||
trackPre := false
|
||||
if in.TrackPrereleases != nil {
|
||||
trackPre = *in.TrackPrereleases
|
||||
}
|
||||
var row models.TrackedSoftware
|
||||
err := q.db.Get(&row, `
|
||||
INSERT INTO tracked_software (name, ecosystem, source, source_ref, current_version, enabled)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
INSERT INTO tracked_software
|
||||
(name, ecosystem, source, source_ref, current_version, enabled, track_prereleases, repology_slug, container_image_pattern, binary_probe)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
RETURNING *`,
|
||||
in.Name, in.Ecosystem, in.Source, in.SourceRef, in.CurrentVersion, enabled)
|
||||
in.Name, in.Ecosystem, in.Source, in.SourceRef, in.CurrentVersion, enabled, trackPre,
|
||||
in.RepologySlug, in.ContainerImagePattern, in.BinaryProbe)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("upstream: create: %w", err)
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
func (q *UpstreamQueries) UpdateSettings(id uuid.UUID, in models.TrackedSoftwareSettingsInput) (*models.TrackedSoftware, error) {
|
||||
var row models.TrackedSoftware
|
||||
err := q.db.Get(&row, `
|
||||
UPDATE tracked_software
|
||||
SET enabled = COALESCE($2, enabled),
|
||||
track_prereleases = COALESCE($3, track_prereleases),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING *`, id, in.Enabled, in.TrackPrereleases)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("upstream: update_settings: %w", err)
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
func (q *UpstreamQueries) Delete(id uuid.UUID) error {
|
||||
res, err := q.db.Exec(`DELETE FROM tracked_software WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
|
|
@ -146,6 +169,140 @@ func (q *UpstreamQueries) InsertDriftEvent(softwareID uuid.UUID, severity string
|
|||
return nil
|
||||
}
|
||||
|
||||
// SeedSelf upserts a tracked_software row keyed on (source, source_ref). It's
|
||||
// used at startup to keep RedFlag's own self-tracking entry honest across
|
||||
// upgrades without clobbering operator intent: on conflict it refreshes only
|
||||
// current_version (so the row reflects the running build) and updated_at. It
|
||||
// deliberately does NOT touch enabled or track_prereleases on conflict — an
|
||||
// operator who disabled the self-entry, or flipped its prerelease tracking,
|
||||
// keeps their choice. The DEFAULTs apply only on first insert.
|
||||
// Known pre-canonical self rows are folded into the canonical key first so
|
||||
// upgrades do not leave duplicate RedFlag entries.
|
||||
//
|
||||
// Returns true when a row was inserted (no prior self-entry), false when an
|
||||
// existing row was refreshed, so the caller can log the first seed once.
|
||||
func (q *UpstreamQueries) SeedSelf(in models.TrackedSoftwareInput) (inserted bool, err error) {
|
||||
enabled := true
|
||||
if in.Enabled != nil {
|
||||
enabled = *in.Enabled
|
||||
}
|
||||
trackPre := false
|
||||
if in.TrackPrereleases != nil {
|
||||
trackPre = *in.TrackPrereleases
|
||||
}
|
||||
tx, err := q.db.Beginx()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("upstream: seed_self begin: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
}
|
||||
}()
|
||||
if isCanonicalRedFlagSelf(in) {
|
||||
if err = normalizeRedFlagSelfRows(tx, in, trackPre); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
// xmax = 0 on the freshly-inserted tuple, non-zero on the row touched by DO
|
||||
// UPDATE — the standard way to tell INSERT from UPDATE in an upsert RETURNING.
|
||||
row := tx.QueryRow(`
|
||||
INSERT INTO tracked_software (name, ecosystem, source, source_ref, current_version, enabled, track_prereleases)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (source, source_ref) DO UPDATE
|
||||
SET current_version = EXCLUDED.current_version,
|
||||
updated_at = NOW()
|
||||
RETURNING (xmax = 0) AS inserted`,
|
||||
in.Name, in.Ecosystem, in.Source, in.SourceRef, in.CurrentVersion, enabled, trackPre)
|
||||
if err = row.Scan(&inserted); err != nil {
|
||||
return false, fmt.Errorf("upstream: seed_self: %w", err)
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return false, fmt.Errorf("upstream: seed_self commit: %w", err)
|
||||
}
|
||||
return inserted, nil
|
||||
}
|
||||
|
||||
func isCanonicalRedFlagSelf(in models.TrackedSoftwareInput) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(in.Name), "RedFlag") &&
|
||||
in.Source == "forgejo" &&
|
||||
strings.Trim(strings.TrimSpace(in.SourceRef), "/") == "codeberg.org/Fimeg/RedFlag"
|
||||
}
|
||||
|
||||
func normalizeRedFlagSelfRows(tx *sqlx.Tx, in models.TrackedSoftwareInput, trackPre bool) error {
|
||||
var canonicalID uuid.UUID
|
||||
err := tx.QueryRow(`
|
||||
SELECT id
|
||||
FROM tracked_software
|
||||
WHERE source = 'forgejo'
|
||||
AND source_ref = 'codeberg.org/Fimeg/RedFlag'
|
||||
LIMIT 1`).Scan(&canonicalID)
|
||||
switch {
|
||||
case err == nil:
|
||||
if _, err := tx.Exec(`
|
||||
DELETE FROM tracked_software
|
||||
WHERE `+redFlagSelfLegacyWhere()+`
|
||||
AND id <> $1`, canonicalID); err != nil {
|
||||
return fmt.Errorf("upstream: seed_self delete legacy duplicates: %w", err)
|
||||
}
|
||||
return nil
|
||||
case err != sql.ErrNoRows:
|
||||
return fmt.Errorf("upstream: seed_self find canonical: %w", err)
|
||||
}
|
||||
|
||||
var legacyID uuid.UUID
|
||||
err = tx.QueryRow(`
|
||||
SELECT id
|
||||
FROM tracked_software
|
||||
WHERE ` + redFlagSelfLegacyWhere() + `
|
||||
ORDER BY created_at
|
||||
LIMIT 1`).Scan(&legacyID)
|
||||
switch {
|
||||
case err == sql.ErrNoRows:
|
||||
return nil
|
||||
case err != nil:
|
||||
return fmt.Errorf("upstream: seed_self find legacy: %w", err)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(`
|
||||
UPDATE tracked_software
|
||||
SET name = $2,
|
||||
ecosystem = $3,
|
||||
source = $4,
|
||||
source_ref = $5,
|
||||
current_version = $6,
|
||||
track_prereleases = $7,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
legacyID, in.Name, in.Ecosystem, in.Source, in.SourceRef, in.CurrentVersion, trackPre); err != nil {
|
||||
return fmt.Errorf("upstream: seed_self canonicalize legacy: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(`
|
||||
DELETE FROM tracked_software
|
||||
WHERE `+redFlagSelfLegacyWhere()+`
|
||||
AND id <> $1`, legacyID); err != nil {
|
||||
return fmt.Errorf("upstream: seed_self delete extra legacy rows: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func redFlagSelfLegacyWhere() string {
|
||||
return `
|
||||
LOWER(name) = 'redflag'
|
||||
AND (
|
||||
(source = 'gitea' AND source_ref IN (
|
||||
'Fimeg/RedFlag',
|
||||
'codeberg.org/Fimeg/RedFlag',
|
||||
'https://codeberg.org/Fimeg/RedFlag',
|
||||
'https://codeberg.org/Fimeg/RedFlag/'
|
||||
))
|
||||
OR (source = 'forgejo' AND source_ref IN (
|
||||
'https://codeberg.org/Fimeg/RedFlag',
|
||||
'https://codeberg.org/Fimeg/RedFlag/'
|
||||
))
|
||||
)`
|
||||
}
|
||||
|
||||
func (q *UpstreamQueries) RecentDriftEvents(limit int) ([]models.UpstreamDriftEvent, error) {
|
||||
if limit <= 0 {
|
||||
limit = 25
|
||||
|
|
|
|||
|
|
@ -10,39 +10,48 @@ import (
|
|||
// server to keep an eye on. The (source, source_ref) pair uniquely
|
||||
// identifies what upstream registry to ask and what to ask it for.
|
||||
type TrackedSoftware struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
Name string `db:"name" json:"name"`
|
||||
Ecosystem string `db:"ecosystem" json:"ecosystem"`
|
||||
Source string `db:"source" json:"source"`
|
||||
SourceRef string `db:"source_ref" json:"source_ref"`
|
||||
CurrentVersion *string `db:"current_version" json:"current_version,omitempty"`
|
||||
LatestVersion *string `db:"latest_version" json:"latest_version,omitempty"`
|
||||
LatestAt *time.Time `db:"latest_at" json:"latest_at,omitempty"`
|
||||
EOLAt *time.Time `db:"eol_at" json:"eol_at,omitempty"`
|
||||
LastCheckedAt *time.Time `db:"last_checked_at" json:"last_checked_at,omitempty"`
|
||||
LastSyncedAt *time.Time `db:"last_synced_at" json:"last_synced_at,omitempty"`
|
||||
LastError *string `db:"last_error" json:"last_error,omitempty"`
|
||||
Enabled bool `db:"enabled" json:"enabled"`
|
||||
RepologySlug *string `db:"repology_slug" json:"repology_slug,omitempty"`
|
||||
ContainerImagePattern *string `db:"container_image_pattern" json:"container_image_pattern,omitempty"`
|
||||
BinaryProbe *string `db:"binary_probe" json:"binary_probe,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
Name string `db:"name" json:"name"`
|
||||
Ecosystem string `db:"ecosystem" json:"ecosystem"`
|
||||
Source string `db:"source" json:"source"`
|
||||
SourceRef string `db:"source_ref" json:"source_ref"`
|
||||
CurrentVersion *string `db:"current_version" json:"current_version,omitempty"`
|
||||
LatestVersion *string `db:"latest_version" json:"latest_version,omitempty"`
|
||||
LatestAt *time.Time `db:"latest_at" json:"latest_at,omitempty"`
|
||||
EOLAt *time.Time `db:"eol_at" json:"eol_at,omitempty"`
|
||||
LastCheckedAt *time.Time `db:"last_checked_at" json:"last_checked_at,omitempty"`
|
||||
LastSyncedAt *time.Time `db:"last_synced_at" json:"last_synced_at,omitempty"`
|
||||
LastError *string `db:"last_error" json:"last_error,omitempty"`
|
||||
Enabled bool `db:"enabled" json:"enabled"`
|
||||
TrackPrereleases bool `db:"track_prereleases" json:"track_prereleases"`
|
||||
RepologySlug *string `db:"repology_slug" json:"repology_slug,omitempty"`
|
||||
ContainerImagePattern *string `db:"container_image_pattern" json:"container_image_pattern,omitempty"`
|
||||
BinaryProbe *string `db:"binary_probe" json:"binary_probe,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
// TrackedSoftwareInput is the create/update payload from the admin API.
|
||||
type TrackedSoftwareInput struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Ecosystem string `json:"ecosystem" binding:"required"`
|
||||
Source string `json:"source" binding:"required"`
|
||||
SourceRef string `json:"source_ref" binding:"required"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
Ecosystem string `json:"ecosystem" binding:"required"`
|
||||
Source string `json:"source" binding:"required"`
|
||||
SourceRef string `json:"source_ref" binding:"required"`
|
||||
CurrentVersion *string `json:"current_version,omitempty"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
TrackPrereleases *bool `json:"track_prereleases,omitempty"`
|
||||
RepologySlug *string `json:"repology_slug,omitempty"`
|
||||
ContainerImagePattern *string `json:"container_image_pattern,omitempty"`
|
||||
BinaryProbe *string `json:"binary_probe,omitempty"`
|
||||
}
|
||||
|
||||
// TrackedSoftwareSettingsInput is the narrow admin patch payload for
|
||||
// operator-owned toggles that should not require recreating the tracked row.
|
||||
type TrackedSoftwareSettingsInput struct {
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
TrackPrereleases *bool `json:"track_prereleases,omitempty"`
|
||||
}
|
||||
|
||||
// UpstreamDriftEvent records a point-in-time observation that latest_version
|
||||
// moved (or that an EOL date passed). Append-only.
|
||||
type UpstreamDriftEvent struct {
|
||||
|
|
|
|||
240
server/internal/services/upstream/forgejo_releases.go
Normal file
240
server/internal/services/upstream/forgejo_releases.go
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
package upstream
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ForgejoReleases adapter — queries a Forgejo/Gitea/Codeberg instance for the
|
||||
// newest versioned release of a repository.
|
||||
//
|
||||
// It walks /api/v1/repos/{owner}/{repo}/releases pages and picks the highest
|
||||
// v-tag by RedFlag's pragmatic version comparator rather than trusting /releases/latest, which on
|
||||
// Forgejo/Gitea/Codeberg EXCLUDES prereleases. During an alpha run where every
|
||||
// tag publishes as a prerelease (RedFlag itself, < v0.3.0), /releases/latest
|
||||
// would freeze or return nothing — so we list and choose. Drafts, non-v* tags,
|
||||
// and the rolling `nightly` tag are filtered out; nightly is republished every
|
||||
// night and would otherwise win on recency. Selection mirrors profile-engine's
|
||||
// latest_release walk.
|
||||
//
|
||||
// Prereleases: when includePrerelease is false (the plain Fetch path), releases
|
||||
// flagged prerelease are skipped. When true (the syncer calls FetchRelease for
|
||||
// rows with track_prereleases), they're considered alongside stable ones.
|
||||
//
|
||||
// source_ref shapes accepted:
|
||||
// - "host/owner/repo" e.g. "codeberg.org/Fimeg/RedFlag"
|
||||
// - "https://host/owner/repo" scheme honored; defaults to https
|
||||
// - "owner/repo" 2-part legacy form (source="gitea" rows): the
|
||||
// host falls back to REDFLAG_GITEA_HOST so existing entries keep working.
|
||||
//
|
||||
// Auth: REDFLAG_GITEA_TOKEN (Bearer) is optional — public repos need none,
|
||||
// private/self-hosted instances supply a token. Codeberg accepts one too.
|
||||
type ForgejoReleases struct {
|
||||
name string
|
||||
fallbackHost string
|
||||
token string
|
||||
}
|
||||
|
||||
// NewForgejoReleases builds the adapter under its canonical "forgejo" name.
|
||||
func NewForgejoReleases() *ForgejoReleases {
|
||||
return &ForgejoReleases{
|
||||
name: "forgejo",
|
||||
fallbackHost: strings.TrimRight(os.Getenv("REDFLAG_GITEA_HOST"), "/"),
|
||||
token: os.Getenv("REDFLAG_GITEA_TOKEN"),
|
||||
}
|
||||
}
|
||||
|
||||
// NewGiteaReleases keeps the legacy constructor available while routing it to
|
||||
// the Forgejo-compatible implementation. Forgejo is a Gitea fork, and the
|
||||
// release API shape RedFlag uses here is shared.
|
||||
func NewGiteaReleases() *ForgejoReleases {
|
||||
return NewForgejoReleases().WithName("gitea")
|
||||
}
|
||||
|
||||
// WithName returns the same adapter configuration under an alias name. The
|
||||
// registry keys by Name(), so registering the forgejo adapter twice — once as
|
||||
// "forgejo", once as "gitea" — keeps legacy source="gitea" rows resolving to
|
||||
// this (now prerelease-aware, list-walking) implementation.
|
||||
func (f *ForgejoReleases) WithName(name string) *ForgejoReleases {
|
||||
clone := *f
|
||||
clone.name = name
|
||||
return &clone
|
||||
}
|
||||
|
||||
func (f ForgejoReleases) Name() string { return f.name }
|
||||
|
||||
type forgejoReleaseEnvelope struct {
|
||||
TagName string `json:"tag_name"`
|
||||
Name string `json:"name"`
|
||||
PublishedAt string `json:"published_at"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
Draft bool `json:"draft"`
|
||||
Prerelease bool `json:"prerelease"`
|
||||
}
|
||||
|
||||
// Fetch is the stable-only path (includePrerelease=false), satisfying
|
||||
// ReleaseSource. The syncer uses FetchRelease directly for prerelease-aware rows.
|
||||
func (f ForgejoReleases) Fetch(ctx context.Context, ref string) (*Release, error) {
|
||||
return f.FetchRelease(ctx, ref, false)
|
||||
}
|
||||
|
||||
func (f ForgejoReleases) FetchRelease(ctx context.Context, ref string, includePrerelease bool) (*Release, error) {
|
||||
host, owner, repo, err := f.resolveRef(ref)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("forgejo: %w", err)
|
||||
}
|
||||
|
||||
envs, err := f.fetchReleasePages(ctx, host, owner, repo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Pick the highest v-tag. Drafts and non-v* / nightly tags are dropped;
|
||||
// prereleases are dropped unless includePrerelease.
|
||||
var best *forgejoReleaseEnvelope
|
||||
for i := range envs {
|
||||
e := &envs[i]
|
||||
if e.Draft {
|
||||
continue
|
||||
}
|
||||
if e.Prerelease && !includePrerelease {
|
||||
continue
|
||||
}
|
||||
if !isVersionTag(e.TagName) {
|
||||
continue
|
||||
}
|
||||
if best == nil || CompareVersions(e.TagName, best.TagName) > 0 {
|
||||
best = e
|
||||
}
|
||||
}
|
||||
if best == nil {
|
||||
return nil, fmt.Errorf("forgejo: no versioned release for %s/%s (prereleases %s)", owner, repo, includeWord(includePrerelease))
|
||||
}
|
||||
|
||||
rel := &Release{Version: best.TagName, SourceURL: best.HTMLURL}
|
||||
if best.PublishedAt != "" {
|
||||
if t, err := time.Parse(time.RFC3339, best.PublishedAt); err == nil {
|
||||
rel.PublishedAt = &t
|
||||
}
|
||||
}
|
||||
return rel, nil
|
||||
}
|
||||
|
||||
func (f ForgejoReleases) fetchReleasePages(ctx context.Context, host, owner, repo string) ([]forgejoReleaseEnvelope, error) {
|
||||
const (
|
||||
pageLimit = 30
|
||||
maxPages = 10
|
||||
)
|
||||
var all []forgejoReleaseEnvelope
|
||||
for page := 1; page <= maxPages; page++ {
|
||||
endpoint := fmt.Sprintf("%s/api/v1/repos/%s/%s/releases?limit=%d&page=%d", host, owner, repo, pageLimit, page)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("forgejo: build request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", "RedFlag/0.2 (+https://codeberg.org/Fimeg/RedFlag)")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if f.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+f.token)
|
||||
}
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("forgejo: fetch %s/%s: %w", owner, repo, err)
|
||||
}
|
||||
|
||||
switch resp.StatusCode {
|
||||
case http.StatusOK:
|
||||
case http.StatusNotFound:
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("forgejo: %s/%s has no releases (or repo private/missing)", owner, repo)
|
||||
case http.StatusUnauthorized, http.StatusForbidden:
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("forgejo: auth required for %s/%s (set REDFLAG_GITEA_TOKEN)", owner, repo)
|
||||
default:
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("forgejo: status %d for %s/%s", resp.StatusCode, owner, repo)
|
||||
}
|
||||
|
||||
var envs []forgejoReleaseEnvelope
|
||||
if err := json.NewDecoder(resp.Body).Decode(&envs); err != nil {
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("forgejo: decode: %w", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
all = append(all, envs...)
|
||||
if len(envs) < pageLimit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return all, nil
|
||||
}
|
||||
|
||||
func includeWord(b bool) string {
|
||||
if b {
|
||||
return "included"
|
||||
}
|
||||
return "excluded"
|
||||
}
|
||||
|
||||
// resolveRef splits a source_ref into (host, owner, repo). Accepts a 3-part
|
||||
// "host/owner/repo" (optionally scheme-prefixed) or a 2-part "owner/repo" that
|
||||
// falls back to REDFLAG_GITEA_HOST. The returned host carries a scheme (https
|
||||
// when none was supplied) and no trailing slash.
|
||||
func (f ForgejoReleases) resolveRef(ref string) (host, owner, repo string, err error) {
|
||||
ref = strings.TrimSpace(ref)
|
||||
if ref == "" {
|
||||
return "", "", "", fmt.Errorf("empty source_ref (want host/owner/repo or owner/repo)")
|
||||
}
|
||||
|
||||
scheme := "https"
|
||||
if i := strings.Index(ref, "://"); i >= 0 {
|
||||
scheme = ref[:i]
|
||||
ref = ref[i+3:]
|
||||
}
|
||||
ref = strings.Trim(ref, "/")
|
||||
|
||||
parts := strings.Split(ref, "/")
|
||||
switch len(parts) {
|
||||
case 2:
|
||||
// Legacy "owner/repo" — host from env.
|
||||
if f.fallbackHost == "" {
|
||||
return "", "", "", fmt.Errorf("source_ref %q is owner/repo but REDFLAG_GITEA_HOST is not set", ref)
|
||||
}
|
||||
host = f.fallbackHost
|
||||
owner, repo = parts[0], parts[1]
|
||||
case 3:
|
||||
host = scheme + "://" + parts[0]
|
||||
owner, repo = parts[1], parts[2]
|
||||
default:
|
||||
return "", "", "", fmt.Errorf("source_ref %q is not host/owner/repo or owner/repo", ref)
|
||||
}
|
||||
if owner == "" || repo == "" {
|
||||
return "", "", "", fmt.Errorf("source_ref %q has empty owner or repo", ref)
|
||||
}
|
||||
// Validate the env-derived fallback host parses (it may lack a scheme).
|
||||
if !strings.Contains(host, "://") {
|
||||
host = "https://" + host
|
||||
}
|
||||
if _, perr := url.Parse(host); perr != nil {
|
||||
return "", "", "", fmt.Errorf("source_ref %q: bad host: %w", ref, perr)
|
||||
}
|
||||
return strings.TrimRight(host, "/"), owner, repo, nil
|
||||
}
|
||||
|
||||
// isVersionTag reports whether tag looks like a v-prefixed numeric version
|
||||
// (v1, v0.2.8.4, ...). Mirrors profile-engine's `^v\d` filter, which also drops
|
||||
// the rolling `nightly` tag.
|
||||
func isVersionTag(tag string) bool {
|
||||
if len(tag) < 2 || tag[0] != 'v' {
|
||||
return false
|
||||
}
|
||||
return tag[1] >= '0' && tag[1] <= '9'
|
||||
}
|
||||
140
server/internal/services/upstream/forgejo_releases_test.go
Normal file
140
server/internal/services/upstream/forgejo_releases_test.go
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
package upstream
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestForgejoReleasesStableOnlySkipsPrereleases(t *testing.T) {
|
||||
withForgejoHTTPClient(t, map[string][]forgejoReleaseEnvelope{
|
||||
"1": {
|
||||
{TagName: "v0.2.8.4", HTMLURL: "https://codeberg.org/Fimeg/RedFlag/releases/tag/v0.2.8.4", Prerelease: true},
|
||||
{TagName: "v0.2.7", HTMLURL: "https://codeberg.org/Fimeg/RedFlag/releases/tag/v0.2.7"},
|
||||
},
|
||||
})
|
||||
|
||||
rel, err := NewForgejoReleases().Fetch(context.Background(), "forgejo.test/Fimeg/RedFlag")
|
||||
if err != nil {
|
||||
t.Fatalf("Fetch returned error: %v", err)
|
||||
}
|
||||
if rel.Version != "v0.2.7" {
|
||||
t.Fatalf("Fetch() version = %q, want stable v0.2.7", rel.Version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForgejoReleasesCanIncludePrereleases(t *testing.T) {
|
||||
withForgejoHTTPClient(t, map[string][]forgejoReleaseEnvelope{
|
||||
"1": {
|
||||
{TagName: "nightly", HTMLURL: "https://codeberg.org/Fimeg/RedFlag/releases/tag/nightly", Prerelease: true},
|
||||
{TagName: "v9.0.0", HTMLURL: "https://codeberg.org/Fimeg/RedFlag/releases/tag/v9.0.0", Draft: true},
|
||||
{TagName: "v0.2.8.4", HTMLURL: "https://codeberg.org/Fimeg/RedFlag/releases/tag/v0.2.8.4", Prerelease: true},
|
||||
{TagName: "v0.2.8.10", HTMLURL: "https://codeberg.org/Fimeg/RedFlag/releases/tag/v0.2.8.10", Prerelease: true},
|
||||
},
|
||||
})
|
||||
|
||||
_, err := NewForgejoReleases().Fetch(context.Background(), "forgejo.test/Fimeg/RedFlag")
|
||||
if err == nil || !strings.Contains(err.Error(), "prereleases excluded") {
|
||||
t.Fatalf("Fetch() error = %v, want prerelease-excluded error", err)
|
||||
}
|
||||
|
||||
rel, err := NewForgejoReleases().FetchRelease(context.Background(), "forgejo.test/Fimeg/RedFlag", true)
|
||||
if err != nil {
|
||||
t.Fatalf("FetchRelease(includePrerelease=true) returned error: %v", err)
|
||||
}
|
||||
if rel.Version != "v0.2.8.10" {
|
||||
t.Fatalf("FetchRelease(includePrerelease=true) version = %q, want v0.2.8.10", rel.Version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForgejoReleasesFinalStableBeatsSameBasePrerelease(t *testing.T) {
|
||||
withForgejoHTTPClient(t, map[string][]forgejoReleaseEnvelope{
|
||||
"1": {
|
||||
{TagName: "v0.3.0-alpha.4", HTMLURL: "https://codeberg.org/Fimeg/RedFlag/releases/tag/v0.3.0-alpha.4", Prerelease: true},
|
||||
{TagName: "v0.3.0", HTMLURL: "https://codeberg.org/Fimeg/RedFlag/releases/tag/v0.3.0"},
|
||||
},
|
||||
})
|
||||
|
||||
rel, err := NewForgejoReleases().FetchRelease(context.Background(), "forgejo.test/Fimeg/RedFlag", true)
|
||||
if err != nil {
|
||||
t.Fatalf("FetchRelease(includePrerelease=true) returned error: %v", err)
|
||||
}
|
||||
if rel.Version != "v0.3.0" {
|
||||
t.Fatalf("FetchRelease(includePrerelease=true) version = %q, want final stable v0.3.0", rel.Version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForgejoReleasesReadsBeyondFirstPage(t *testing.T) {
|
||||
firstPage := make([]forgejoReleaseEnvelope, 30)
|
||||
for i := range firstPage {
|
||||
firstPage[i] = forgejoReleaseEnvelope{TagName: "nightly", HTMLURL: "https://codeberg.org/Fimeg/RedFlag/releases/tag/nightly"}
|
||||
}
|
||||
withForgejoHTTPClient(t, map[string][]forgejoReleaseEnvelope{
|
||||
"1": firstPage,
|
||||
"2": {
|
||||
{TagName: "v1.2.3", HTMLURL: "https://codeberg.org/Fimeg/RedFlag/releases/tag/v1.2.3"},
|
||||
},
|
||||
})
|
||||
|
||||
rel, err := NewForgejoReleases().Fetch(context.Background(), "forgejo.test/Fimeg/RedFlag")
|
||||
if err != nil {
|
||||
t.Fatalf("Fetch returned error: %v", err)
|
||||
}
|
||||
if rel.Version != "v1.2.3" {
|
||||
t.Fatalf("Fetch() version = %q, want v1.2.3 from page 2", rel.Version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGiteaAliasUsesFallbackHost(t *testing.T) {
|
||||
withForgejoHTTPClient(t, map[string][]forgejoReleaseEnvelope{
|
||||
"1": {
|
||||
{TagName: "v1.0.0", HTMLURL: "https://gitea.example/releases/tag/v1.0.0"},
|
||||
},
|
||||
})
|
||||
t.Setenv("REDFLAG_GITEA_HOST", "https://gitea.example")
|
||||
|
||||
rel, err := NewGiteaReleases().Fetch(context.Background(), "Fimeg/RedFlag")
|
||||
if err != nil {
|
||||
t.Fatalf("Fetch returned error: %v", err)
|
||||
}
|
||||
if rel.Version != "v1.0.0" {
|
||||
t.Fatalf("Fetch() version = %q, want v1.0.0", rel.Version)
|
||||
}
|
||||
}
|
||||
|
||||
func withForgejoHTTPClient(t *testing.T, pages map[string][]forgejoReleaseEnvelope) {
|
||||
t.Helper()
|
||||
oldClient := httpClient
|
||||
httpClient = &http.Client{Transport: forgejoRoundTripper{t: t, pages: pages}}
|
||||
t.Cleanup(func() { httpClient = oldClient })
|
||||
}
|
||||
|
||||
type forgejoRoundTripper struct {
|
||||
t *testing.T
|
||||
pages map[string][]forgejoReleaseEnvelope
|
||||
}
|
||||
|
||||
func (f forgejoRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
f.t.Helper()
|
||||
if req.URL.Path != "/api/v1/repos/Fimeg/RedFlag/releases" {
|
||||
f.t.Fatalf("path = %q, want /api/v1/repos/Fimeg/RedFlag/releases", req.URL.Path)
|
||||
}
|
||||
if req.URL.Query().Get("limit") != "30" {
|
||||
f.t.Fatalf("limit = %q, want 30", req.URL.Query().Get("limit"))
|
||||
}
|
||||
page := req.URL.Query().Get("page")
|
||||
releases := f.pages[page]
|
||||
body, err := json.Marshal(releases)
|
||||
if err != nil {
|
||||
f.t.Fatalf("marshal releases: %v", err)
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(string(body))),
|
||||
Request: req,
|
||||
}, nil
|
||||
}
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
package upstream
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// GiteaReleases adapter — queries a self-hosted Gitea instance for the
|
||||
// latest release of a repository.
|
||||
//
|
||||
// source_ref shape: "owner/repo". The host is supplied at adapter
|
||||
// construction time via REDFLAG_GITEA_HOST (e.g. "https://gitea.local"
|
||||
// or "http://10.10.20.120:4455"). If the host env is empty, the adapter
|
||||
// still registers but every Fetch returns an "unconfigured" error so the
|
||||
// operator sees what's missing in tracked_software.last_error.
|
||||
//
|
||||
// Auth via REDFLAG_GITEA_TOKEN env var (personal access token). Gitea
|
||||
// accepts "token <pat>" or "Bearer <pat>" — we use Bearer for parity
|
||||
// with the other adapters.
|
||||
type GiteaReleases struct {
|
||||
host string
|
||||
token string
|
||||
}
|
||||
|
||||
func NewGiteaReleases() *GiteaReleases {
|
||||
return &GiteaReleases{
|
||||
host: strings.TrimRight(os.Getenv("REDFLAG_GITEA_HOST"), "/"),
|
||||
token: os.Getenv("REDFLAG_GITEA_TOKEN"),
|
||||
}
|
||||
}
|
||||
|
||||
func (GiteaReleases) Name() string { return "gitea" }
|
||||
|
||||
type giteaReleaseEnvelope struct {
|
||||
TagName string `json:"tag_name"`
|
||||
Name string `json:"name"`
|
||||
PublishedAt string `json:"published_at"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
Draft bool `json:"draft"`
|
||||
Prerelease bool `json:"prerelease"`
|
||||
}
|
||||
|
||||
func (g GiteaReleases) Fetch(ctx context.Context, ref string) (*Release, error) {
|
||||
if g.host == "" {
|
||||
return nil, fmt.Errorf("gitea: REDFLAG_GITEA_HOST not set")
|
||||
}
|
||||
owner, repo, err := splitOwnerRepo(ref)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gitea: %w", err)
|
||||
}
|
||||
endpoint := fmt.Sprintf("%s/api/v1/repos/%s/%s/releases/latest", g.host, owner, repo)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gitea: build request: %w", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", "RedFlag/0.2 (+https://github.com/Fimeg/RedFlag)")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if g.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+g.token)
|
||||
}
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gitea: fetch %s/%s: %w", owner, repo, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
switch resp.StatusCode {
|
||||
case http.StatusOK:
|
||||
case http.StatusNotFound:
|
||||
return nil, fmt.Errorf("gitea: %s/%s has no releases (or repo private/missing)", owner, repo)
|
||||
case http.StatusUnauthorized, http.StatusForbidden:
|
||||
return nil, fmt.Errorf("gitea: auth required for %s/%s (set REDFLAG_GITEA_TOKEN)", owner, repo)
|
||||
default:
|
||||
return nil, fmt.Errorf("gitea: status %d for %s/%s", resp.StatusCode, owner, repo)
|
||||
}
|
||||
|
||||
var env giteaReleaseEnvelope
|
||||
if err := json.NewDecoder(resp.Body).Decode(&env); err != nil {
|
||||
return nil, fmt.Errorf("gitea: decode: %w", err)
|
||||
}
|
||||
if env.TagName == "" {
|
||||
return nil, fmt.Errorf("gitea: empty tag_name for %s/%s", owner, repo)
|
||||
}
|
||||
|
||||
rel := &Release{Version: env.TagName, SourceURL: env.HTMLURL}
|
||||
if env.PublishedAt != "" {
|
||||
if t, err := time.Parse(time.RFC3339, env.PublishedAt); err == nil {
|
||||
rel.PublishedAt = &t
|
||||
}
|
||||
}
|
||||
return rel, nil
|
||||
}
|
||||
|
|
@ -35,6 +35,19 @@ type ReleaseSource interface {
|
|||
Fetch(ctx context.Context, ref string) (*Release, error)
|
||||
}
|
||||
|
||||
// PrereleaseAwareSource is an optional capability some adapters implement when
|
||||
// their registry distinguishes prereleases from stable releases. The syncer
|
||||
// type-asserts for it and, for rows that opted into track_prereleases, calls
|
||||
// FetchRelease(ctx, ref, true) so prereleases are considered when picking the
|
||||
// latest version. Adapters that don't implement it keep the plain Fetch path
|
||||
// (stable-only); for an adapter that does, Fetch must equal
|
||||
// FetchRelease(ctx, ref, false). The base ReleaseSource.Fetch signature stays
|
||||
// fixed so the other adapters need no changes.
|
||||
type PrereleaseAwareSource interface {
|
||||
ReleaseSource
|
||||
FetchRelease(ctx context.Context, ref string, includePrerelease bool) (*Release, error)
|
||||
}
|
||||
|
||||
// Registry holds the live adapters. Adapters self-register at construction
|
||||
// time so main.go composes the set explicitly.
|
||||
type Registry struct {
|
||||
|
|
|
|||
|
|
@ -194,7 +194,17 @@ func (s *Syncer) syncOne(ctx context.Context, row models.TrackedSoftware) error
|
|||
fetchCtx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
release, err := source.Fetch(fetchCtx, row.SourceRef)
|
||||
var release *Release
|
||||
var err error
|
||||
if row.TrackPrereleases {
|
||||
if prereleaseSource, ok := source.(PrereleaseAwareSource); ok {
|
||||
release, err = prereleaseSource.FetchRelease(fetchCtx, row.SourceRef, true)
|
||||
} else {
|
||||
release, err = source.Fetch(fetchCtx, row.SourceRef)
|
||||
}
|
||||
} else {
|
||||
release, err = source.Fetch(fetchCtx, row.SourceRef)
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("[WARN] [upstream] [syncer] fetch failed source=%s ref=%s err=%v", row.Source, row.SourceRef, err)
|
||||
if dbErr := s.queries.ApplySyncError(row.ID, err.Error()); dbErr != nil {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@ const StackDriftPanel: React.FC = () => {
|
|||
|
||||
const drift: TrackedSoftware[] = drifted ?? [];
|
||||
const eolCount = drift.filter((s) => s.eol_at && new Date(s.eol_at) < new Date()).length;
|
||||
const redflagDrift = drift.find(
|
||||
(s) => s.source === 'forgejo' && s.source_ref === 'codeberg.org/Fimeg/RedFlag',
|
||||
);
|
||||
const worst3 = drift.slice(0, 3);
|
||||
|
||||
return (
|
||||
|
|
@ -53,6 +56,28 @@ const StackDriftPanel: React.FC = () => {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{redflagDrift && (
|
||||
<div className="mb-4 rounded border border-amber-200 bg-amber-50 p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-amber-900">RedFlag update available</p>
|
||||
<p className="text-xs text-amber-800 font-mono mt-0.5">
|
||||
{redflagDrift.current_version ?? '?'} → {redflagDrift.latest_version ?? '?'}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/settings/upstream"
|
||||
className="text-xs text-amber-800 hover:text-amber-950 font-medium whitespace-nowrap"
|
||||
>
|
||||
View row
|
||||
</Link>
|
||||
</div>
|
||||
<code className="mt-2 block overflow-x-auto rounded bg-white/80 px-2 py-1 text-xs text-amber-950">
|
||||
git checkout {redflagDrift.latest_version ?? 'v...'} && docker compose build server && docker compose up -d server
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ul className="space-y-2">
|
||||
{worst3.map((s) => {
|
||||
const eolPassed = s.eol_at && new Date(s.eol_at) < new Date();
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|||
import { toast } from 'react-hot-toast';
|
||||
import { adminApi } from '@/lib/api';
|
||||
import { POLL } from '@/lib/polling';
|
||||
import { CreateTrackedSoftwareRequest } from '@/types';
|
||||
import { CreateTrackedSoftwareRequest, UpdateTrackedSoftwareSettingsRequest } from '@/types';
|
||||
|
||||
export const upstreamKeys = {
|
||||
all: ['upstream'] as const,
|
||||
|
|
@ -66,6 +66,20 @@ export const useRemoveTrackedSoftware = () => {
|
|||
});
|
||||
};
|
||||
|
||||
export const useUpdateTrackedSoftwareSettings = () => {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, input }: { id: string; input: UpdateTrackedSoftwareSettingsRequest }) =>
|
||||
adminApi.upstream.updateSettings(id, input),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: upstreamKeys.all });
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err.response?.data?.error || 'Failed to update tracked software');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useInstallations = (softwareId: string, enabled: boolean = true) => {
|
||||
return useQuery({
|
||||
queryKey: upstreamKeys.installations(softwareId),
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import {
|
|||
RateLimitStatsResponse,
|
||||
TrackedSoftware,
|
||||
CreateTrackedSoftwareRequest,
|
||||
UpdateTrackedSoftwareSettingsRequest,
|
||||
UpstreamDriftEvent,
|
||||
AgentTrackedSoftware,
|
||||
AgentTrackedSoftwareView,
|
||||
|
|
@ -903,6 +904,11 @@ export const adminApi = {
|
|||
return response.data;
|
||||
},
|
||||
|
||||
updateSettings: async (id: string, input: UpdateTrackedSoftwareSettingsRequest): Promise<TrackedSoftware> => {
|
||||
const response = await api.patch(`/admin/upstream/${id}`, input);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
remove: async (id: string): Promise<void> => {
|
||||
await api.delete(`/admin/upstream/${id}`);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
useAddTrackedSoftware,
|
||||
useRemoveTrackedSoftware,
|
||||
useSyncTrackedSoftware,
|
||||
useUpdateTrackedSoftwareSettings,
|
||||
useInstallations,
|
||||
} from '@/hooks/useUpstream';
|
||||
import { CreateTrackedSoftwareRequest, UpstreamSource } from '@/types';
|
||||
|
|
@ -50,6 +51,7 @@ const sourceLabel = (s: UpstreamSource): string => {
|
|||
case 'endoflife': return 'endoflife.date';
|
||||
case 'anitya': return 'Anitya';
|
||||
case 'github': return 'GitHub Releases';
|
||||
case 'forgejo': return 'Forgejo Releases';
|
||||
case 'gitea': return 'Gitea Releases';
|
||||
case 'gitlab': return 'GitLab Releases';
|
||||
case 'bitbucket': return 'Bitbucket Tags';
|
||||
|
|
@ -68,7 +70,8 @@ const sourceRefHint = (s: UpstreamSource): { placeholder: string; help: string }
|
|||
case 'endoflife': return { placeholder: 'postgresql', help: 'endoflife.date product slug' };
|
||||
case 'anitya': return { placeholder: 'nginx', help: 'Anitya project name' };
|
||||
case 'github': return { placeholder: 'kubernetes/kubernetes', help: 'GitHub owner/repo. Set REDFLAG_GITHUB_TOKEN for higher rate limit.' };
|
||||
case 'gitea': return { placeholder: 'Fimeg/RedFlag', help: 'Gitea owner/repo. REDFLAG_GITEA_HOST + REDFLAG_GITEA_TOKEN required.' };
|
||||
case 'forgejo': return { placeholder: 'codeberg.org/Fimeg/RedFlag', help: 'Forgejo host/owner/repo, such as codeberg.org/Fimeg/RedFlag.' };
|
||||
case 'gitea': return { placeholder: 'Fimeg/RedFlag', help: 'Gitea owner/repo, or host/owner/repo. REDFLAG_GITEA_HOST used for two-part refs.' };
|
||||
case 'gitlab': return { placeholder: 'gitlab-org/gitlab', help: 'GitLab group/project path. REDFLAG_GITLAB_TOKEN for private projects.' };
|
||||
case 'bitbucket': return { placeholder: 'atlassian/atlassian-sdk', help: 'Bitbucket workspace/repo. Highest tag picked via semver compare.' };
|
||||
case 'git': return { placeholder: 'https://git.kernel.org/….git', help: 'Any Git clone URL. Anonymous ls-remote; private repos use the dedicated adapters.' };
|
||||
|
|
@ -81,15 +84,26 @@ const sourceRefHint = (s: UpstreamSource): { placeholder: string; help: string }
|
|||
// "Source" link in the actions column. Falls back to the row's
|
||||
// stored source_url when an adapter doesn't have a predictable URL
|
||||
// pattern (or when host is env-configurable).
|
||||
const forgejoHref = (ref: string): string | null => {
|
||||
const clean = ref.trim().replace(/\/+$/, '');
|
||||
if (/^https?:\/\//.test(clean)) return `${clean}/releases`;
|
||||
const parts = clean.split('/');
|
||||
if (parts.length === 3 && parts.every(Boolean)) {
|
||||
return `https://${parts[0]}/${parts[1]}/${parts[2]}/releases`;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const sourceHref = (s: { source: UpstreamSource; source_ref: string }): string | null => {
|
||||
switch (s.source) {
|
||||
case 'repology': return `https://repology.org/project/${s.source_ref}/versions`;
|
||||
case 'endoflife': return `https://endoflife.date/${s.source_ref}`;
|
||||
case 'github': return `https://github.com/${s.source_ref}/releases`;
|
||||
case 'forgejo': return forgejoHref(s.source_ref);
|
||||
case 'gitlab': return `https://gitlab.com/${s.source_ref}/-/releases`;
|
||||
case 'bitbucket': return `https://bitbucket.org/${s.source_ref}/downloads/?tab=tags`;
|
||||
case 'git': return s.source_ref; // already a URL
|
||||
case 'gitea':
|
||||
case 'gitea': return forgejoHref(s.source_ref);
|
||||
case 'anitya':
|
||||
case 'npm':
|
||||
case 'pypi':
|
||||
|
|
@ -97,6 +111,9 @@ const sourceHref = (s: { source: UpstreamSource; source_ref: string }): string |
|
|||
}
|
||||
};
|
||||
|
||||
const supportsPrereleaseToggle = (source: UpstreamSource): boolean =>
|
||||
source === 'forgejo' || source === 'gitea';
|
||||
|
||||
// InstallationsRow lazy-fetches the agent list for a tracked_software entry
|
||||
// when the operator expands the row. Kept inside this file because it's only
|
||||
// the table's expanded-row affordance, not a reusable component.
|
||||
|
|
@ -150,6 +167,7 @@ const UpstreamTracking: React.FC = () => {
|
|||
const add = useAddTrackedSoftware();
|
||||
const remove = useRemoveTrackedSoftware();
|
||||
const sync = useSyncTrackedSoftware();
|
||||
const updateSettings = useUpdateTrackedSoftwareSettings();
|
||||
const confirm = useConfirm();
|
||||
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
|
|
@ -169,6 +187,7 @@ const UpstreamTracking: React.FC = () => {
|
|||
source: 'endoflife',
|
||||
source_ref: '',
|
||||
current_version: '',
|
||||
track_prereleases: false,
|
||||
});
|
||||
|
||||
const software = data?.software ?? [];
|
||||
|
|
@ -178,7 +197,7 @@ const UpstreamTracking: React.FC = () => {
|
|||
if (!form.name || !form.source_ref) return;
|
||||
add.mutate({ ...form, current_version: form.current_version || undefined }, {
|
||||
onSuccess: () => {
|
||||
setForm({ name: '', ecosystem: 'system', source: 'endoflife', source_ref: '', current_version: '' });
|
||||
setForm({ name: '', ecosystem: 'system', source: 'endoflife', source_ref: '', current_version: '', track_prereleases: false });
|
||||
setShowAddForm(false);
|
||||
},
|
||||
});
|
||||
|
|
@ -205,8 +224,8 @@ const UpstreamTracking: React.FC = () => {
|
|||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Upstream Version Tracking</h1>
|
||||
<p className="mt-2 text-gray-600">
|
||||
Compare your deployed versions against canonical upstream releases. Pluggable sources:
|
||||
Repology (cross-distro) and endoflife.date (release lines + EOL dates).
|
||||
Compare your deployed versions against canonical upstream releases across package indexes,
|
||||
lifecycle feeds, forge releases, and repository tags.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
|
|
@ -248,7 +267,14 @@ const UpstreamTracking: React.FC = () => {
|
|||
<label className="block text-sm font-medium text-gray-700 mb-1">Source</label>
|
||||
<select
|
||||
value={form.source}
|
||||
onChange={(e) => setForm({ ...form, source: e.target.value as UpstreamSource })}
|
||||
onChange={(e) => {
|
||||
const nextSource = e.target.value as UpstreamSource;
|
||||
setForm({
|
||||
...form,
|
||||
source: nextSource,
|
||||
track_prereleases: supportsPrereleaseToggle(nextSource) ? form.track_prereleases : false,
|
||||
});
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
{availableSources.map((s) => (
|
||||
|
|
@ -280,6 +306,17 @@ const UpstreamTracking: React.FC = () => {
|
|||
className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono"
|
||||
/>
|
||||
</div>
|
||||
{supportsPrereleaseToggle(form.source) && (
|
||||
<label className="md:col-span-2 inline-flex items-center gap-2 text-sm text-gray-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!form.track_prereleases}
|
||||
onChange={(e) => setForm({ ...form, track_prereleases: e.target.checked })}
|
||||
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
Track prereleases
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 flex gap-2 justify-end">
|
||||
<button
|
||||
|
|
@ -385,6 +422,9 @@ const UpstreamTracking: React.FC = () => {
|
|||
<td className="px-6 py-3 text-sm text-gray-600">
|
||||
{sourceLabel(s.source)}
|
||||
<div className="text-xs text-gray-400 font-mono">{s.source_ref}</div>
|
||||
{s.track_prereleases && (
|
||||
<div className="text-xs text-blue-600">prereleases included</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-3 font-mono text-sm">{s.current_version ?? <span className="text-gray-400">—</span>}</td>
|
||||
<td className="px-6 py-3 font-mono text-sm">{s.latest_version ?? <span className="text-gray-400">—</span>}</td>
|
||||
|
|
@ -408,6 +448,21 @@ const UpstreamTracking: React.FC = () => {
|
|||
)}
|
||||
</td>
|
||||
<td className="px-6 py-3 text-right space-x-2 whitespace-nowrap">
|
||||
{supportsPrereleaseToggle(s.source) && (
|
||||
<label className="inline-flex items-center gap-1 px-2 py-1 text-xs text-gray-600">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={s.track_prereleases}
|
||||
disabled={updateSettings.isPending}
|
||||
onChange={(e) => updateSettings.mutate({
|
||||
id: s.id,
|
||||
input: { track_prereleases: e.target.checked },
|
||||
})}
|
||||
className="h-3 w-3 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
Prereleases
|
||||
</label>
|
||||
)}
|
||||
<button
|
||||
onClick={() => sync.mutate(s.id)}
|
||||
disabled={sync.isPending}
|
||||
|
|
|
|||
|
|
@ -574,6 +574,7 @@ export type UpstreamSource =
|
|||
| 'endoflife'
|
||||
| 'anitya'
|
||||
| 'github'
|
||||
| 'forgejo'
|
||||
| 'gitea'
|
||||
| 'gitlab'
|
||||
| 'bitbucket'
|
||||
|
|
@ -595,6 +596,7 @@ export interface TrackedSoftware {
|
|||
last_synced_at?: string | null;
|
||||
last_error?: string | null;
|
||||
enabled: boolean;
|
||||
track_prereleases: boolean;
|
||||
repology_slug?: string | null;
|
||||
container_image_pattern?: string | null;
|
||||
binary_probe?: string | null;
|
||||
|
|
@ -609,8 +611,15 @@ export interface CreateTrackedSoftwareRequest {
|
|||
source_ref: string;
|
||||
current_version?: string;
|
||||
enabled?: boolean;
|
||||
track_prereleases?: boolean;
|
||||
repology_slug?: string;
|
||||
container_image_pattern?: string;
|
||||
binary_probe?: string;
|
||||
}
|
||||
|
||||
export interface UpdateTrackedSoftwareSettingsRequest {
|
||||
enabled?: boolean;
|
||||
track_prereleases?: boolean;
|
||||
}
|
||||
|
||||
export interface UpstreamDriftEvent {
|
||||
|
|
|
|||
Loading…
Reference in a new issue