init
All checks were successful
build-binaries / build (, amd64, linux) (push) Has been skipped
build-binaries / build (, arm, 7, linux) (push) Has been skipped
build-binaries / build (, arm64, linux) (push) Has been skipped
build-binaries / build (.exe, amd64, windows) (push) Has been skipped
build-binaries / release (push) Has been skipped
release-tag / release-image (push) Successful in 4m24s

This commit is contained in:
2025-10-25 14:13:22 +02:00
parent 0274081670
commit dd19c79aee
6 changed files with 1020 additions and 0 deletions

634
main.go Normal file
View File

@@ -0,0 +1,634 @@
package main
import (
"crypto/sha256"
"embed"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
)
// ---- Data model -------------------------------------------------------------
// Asset describes a downloadable artifact for a release.
// Keep it minimal and verifiable.
// All fields are JSON-tagged for a stable API.
type Asset struct {
URL string `json:"url"`
SHA256 string `json:"sha256"`
Size int64 `json:"size_bytes,omitempty"`
SignatureURL string `json:"signature_url,omitempty"`
ContentType string `json:"content_type,omitempty"`
}
// Release holds the metadata for a specific Branch→Channel→Arch→Bit→OS combo.
// Channel is duplicated in the payload for clarity in responses.
type Release struct {
Version string `json:"version"` // e.g. 12.3.1
Build string `json:"build,omitempty"` // optional build id
ReleasedAt time.Time `json:"released_at"` // RFC3339
NotesURL string `json:"notes_url,omitempty"`
Assets []Asset `json:"assets"`
Meta map[string]string `json:"meta,omitempty"` // optional free-form
ChannelHint string `json:"channel,omitempty"` // echoed by server
}
// Manifest nests by Branch→Channel→Arch→Bit→OS as requested.
// The innermost value is the latest Release for that tuple.
// Example path: Releases["12.x"]["stable"]["amd64"]["64"]["windows"]
type Manifest struct {
Vendor string `json:"vendor"`
Product string `json:"product"`
DefaultBranch string `json:"default_branch,omitempty"`
DefaultChannel string `json:"default_channel,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
Releases map[string]map[string]map[string]map[string]map[string]Release `json:"releases"`
}
// publishRequest is the payload for POST /v1/publish
type publishRequest struct {
Branch string `json:"branch"`
Channel string `json:"channel"` // stable, beta, rc, nightly
Arch string `json:"arch"`
Bit string `json:"bit"` // "32" or "64"
OS string `json:"os"`
Release Release `json:"release"`
}
// latestResponse is returned by GET /v1/latest
// Mirrors the request tuple alongside the release for clarity.
type latestResponse struct {
Branch string `json:"branch"`
Channel string `json:"channel"`
Arch string `json:"arch"`
Bit string `json:"bit"`
OS string `json:"os"`
Release Release `json:"release"`
}
// ---- Store & persistence ----------------------------------------------------
type store struct {
mu sync.RWMutex
manifest Manifest
path string
}
func newStore(path, vendor, product string) *store {
m := Manifest{
Vendor: vendor,
Product: product,
DefaultBranch: "",
DefaultChannel: "stable",
UpdatedAt: time.Now().UTC(),
Releases: make(map[string]map[string]map[string]map[string]map[string]Release),
}
return &store{manifest: m, path: path}
}
// oldManifest is used to migrate v1 manifests (without channels) → channels("stable").
type oldManifest struct {
Vendor string `json:"vendor"`
Product string `json:"product"`
DefaultBranch string `json:"default_branch,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
Releases map[string]map[string]map[string]map[string]Release `json:"releases"`
}
func (s *store) loadIfExists() error {
b, err := os.ReadFile(s.path)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
var m Manifest
if err := json.Unmarshal(b, &m); err == nil && m.Releases != nil {
// Looks like v2 → accept.
s.mu.Lock()
s.manifest = m
s.mu.Unlock()
return nil
}
// Try v1 → migrate into channel "stable".
var ov1 oldManifest
if err := json.Unmarshal(b, &ov1); err != nil {
return fmt.Errorf("invalid manifest json: %w", err)
}
mig := Manifest{
Vendor: ov1.Vendor,
Product: ov1.Product,
DefaultBranch: ov1.DefaultBranch,
DefaultChannel: "stable",
UpdatedAt: time.Now().UTC(),
Releases: make(map[string]map[string]map[string]map[string]map[string]Release),
}
for br, archs := range ov1.Releases {
if _, ok := mig.Releases[br]; !ok {
mig.Releases[br] = make(map[string]map[string]map[string]map[string]Release)
}
ch := mig.Releases[br]
if _, ok := ch["stable"]; !ok {
ch["stable"] = make(map[string]map[string]map[string]Release)
}
for arch, bits := range archs {
if _, ok := ch["stable"][arch]; !ok {
ch["stable"][arch] = make(map[string]map[string]Release)
}
for bit, osmap := range bits {
if _, ok := ch["stable"][arch][bit]; !ok {
ch["stable"][arch][bit] = make(map[string]Release)
}
for osname, rel := range osmap {
ch["stable"][arch][bit][osname] = rel
}
}
}
}
s.mu.Lock()
s.manifest = mig
s.mu.Unlock()
return nil
}
func (s *store) persistLocked() error {
// Caller must hold s.mu (write)
s.manifest.UpdatedAt = time.Now().UTC()
b, err := json.MarshalIndent(s.manifest, "", " ")
if err != nil {
return err
}
// Ensure dir
if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
return err
}
// Write atomically
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, b, 0o644); err != nil {
return err
}
return os.Rename(tmp, s.path)
}
func (s *store) setLatest(pr publishRequest) error {
if err := validateTuple(pr.Branch, pr.Channel, pr.Arch, pr.Bit, pr.OS); err != nil {
return err
}
if err := validateRelease(pr.Release); err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
// Create levels if missing
lvl1, ok := s.manifest.Releases[pr.Branch]
if !ok {
lvl1 = make(map[string]map[string]map[string]map[string]Release)
s.manifest.Releases[pr.Branch] = lvl1
}
lvlCh, ok := lvl1[pr.Channel]
if !ok {
lvlCh = make(map[string]map[string]map[string]Release)
lvl1[pr.Channel] = lvlCh
}
lvl2, ok := lvlCh[pr.Arch]
if !ok {
lvl2 = make(map[string]map[string]Release)
lvlCh[pr.Arch] = lvl2
}
lvl3, ok := lvl2[pr.Bit]
if !ok {
lvl3 = make(map[string]Release)
lvl2[pr.Bit] = lvl3
}
rel := pr.Release
rel.ChannelHint = pr.Channel
lvl3[pr.OS] = rel
return s.persistLocked()
}
func (s *store) getLatest(branch, channel, arch, bit, osname string) (Release, bool) {
if err := validateTuple(branch, channel, arch, bit, osname); err != nil {
return Release{}, false
}
s.mu.RLock()
defer s.mu.RUnlock()
lvl1, ok := s.manifest.Releases[branch]
if !ok {
return Release{}, false
}
lvlCh, ok := lvl1[channel]
if !ok {
return Release{}, false
}
lvl2, ok := lvlCh[arch]
if !ok {
return Release{}, false
}
lvl3, ok := lvl2[bit]
if !ok {
return Release{}, false
}
rel, ok := lvl3[osname]
return rel, ok
}
func (s *store) branches() []string {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]string, 0, len(s.manifest.Releases))
for k := range s.manifest.Releases {
out = append(out, k)
}
sort.Strings(out)
return out
}
// ---- Validation -------------------------------------------------------------
var (
allowedOS = map[string]struct{}{"windows": {}, "linux": {}, "macos": {}, "freebsd": {}}
allowedArch = map[string]struct{}{"amd64": {}, "386": {}, "arm64": {}, "armv7": {}, "ppc64le": {}}
allowedBit = map[string]struct{}{"64": {}, "32": {}}
allowedChannels = map[string]struct{}{"stable": {}, "beta": {}, "rc": {}, "nightly": {}}
)
func validateTuple(branch, channel, arch, bit, osname string) error {
if strings.TrimSpace(branch) == "" {
return errors.New("branch required, e.g. '12.x'")
}
if _, ok := allowedChannels[channel]; !ok {
return fmt.Errorf("invalid channel: %s", channel)
}
if _, ok := allowedArch[arch]; !ok {
return fmt.Errorf("invalid arch: %s", arch)
}
if _, ok := allowedBit[bit]; !ok {
return fmt.Errorf("invalid bit: %s (use '32' or '64')", bit)
}
if _, ok := allowedOS[osname]; !ok {
return fmt.Errorf("invalid os: %s", osname)
}
return nil
}
func validateRelease(r Release) error {
if strings.TrimSpace(r.Version) == "" {
return errors.New("release.version required")
}
if r.ReleasedAt.IsZero() {
return errors.New("release.released_at required (RFC3339)")
}
if len(r.Assets) == 0 {
return errors.New("release.assets must not be empty")
}
for i, a := range r.Assets {
if a.URL == "" {
return fmt.Errorf("assets[%d].url required", i)
}
if a.SHA256 == "" {
return fmt.Errorf("assets[%d].sha256 required", i)
}
}
return nil
}
// ---- HTTP helpers -----------------------------------------------------------
func writeJSON(w http.ResponseWriter, r *http.Request, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
b, err := json.Marshal(v)
if err != nil {
http.Error(w, "json marshal error", http.StatusInternalServerError)
return
}
etag := sha256.Sum256(b)
etagStr := "\"" + hex.EncodeToString(etag[:]) + "\""
w.Header().Set("ETag", etagStr)
if inm := r.Header.Get("If-None-Match"); inm != "" && strings.Contains(inm, strings.Trim(etagStr, "\"")) {
w.WriteHeader(http.StatusNotModified)
return
}
w.WriteHeader(status)
_, _ = w.Write(b)
}
func parseJSON(r *http.Request, dst any) error {
defer r.Body.Close()
lr := io.LimitReader(r.Body, 1<<20) // 1 MiB payload cap
dec := json.NewDecoder(lr)
dec.DisallowUnknownFields()
return dec.Decode(dst)
}
func cors(w http.ResponseWriter, r *http.Request) bool {
// Permissive CORS for simplicity (can be tightened later)
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Vary", "Origin")
if r.Method == http.MethodOptions {
w.Header().Set("Access-Control-Allow-Methods", "GET,POST,OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Authorization,Content-Type")
w.WriteHeader(http.StatusNoContent)
return true
}
return false
}
// ---- Handlers ---------------------------------------------------------------
type server struct {
st *store
apiToken string // optional; if set, required for POST /v1/publish & /v1/config
}
func (s *server) handleHealth(w http.ResponseWriter, r *http.Request) {
if cors(w, r) {
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *server) handleManifest(w http.ResponseWriter, r *http.Request) {
if cors(w, r) {
return
}
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
s.st.mu.RLock()
m := s.st.manifest
s.st.mu.RUnlock()
writeJSON(w, r, http.StatusOK, m)
}
func (s *server) handleBranches(w http.ResponseWriter, r *http.Request) {
if cors(w, r) {
return
}
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
writeJSON(w, r, http.StatusOK, map[string]any{"branches": s.st.branches()})
}
func (s *server) handleChannels(w http.ResponseWriter, r *http.Request) {
if cors(w, r) {
return
}
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
keys := make([]string, 0, len(allowedChannels))
for k := range allowedChannels {
keys = append(keys, k)
}
sort.Strings(keys)
writeJSON(w, r, http.StatusOK, map[string]any{"channels": keys, "default": s.st.manifest.DefaultChannel})
}
func (s *server) handleValues(w http.ResponseWriter, r *http.Request) {
// returns allowed enums + defaults to drive the UI
if cors(w, r) {
return
}
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
archs := keysOf(allowedArch)
bits := keysOf(allowedBit)
oss := keysOf(allowedOS)
chs := keysOf(allowedChannels)
s.st.mu.RLock()
defBr, defCh := s.st.manifest.DefaultBranch, s.st.manifest.DefaultChannel
vendor, product := s.st.manifest.Vendor, s.st.manifest.Product
s.st.mu.RUnlock()
writeJSON(w, r, http.StatusOK, map[string]any{
"arch": archs, "bit": bits, "os": oss, "channels": chs,
"defaults": map[string]string{"branch": defBr, "channel": defCh},
"meta": map[string]string{"vendor": vendor, "product": product},
})
}
func keysOf(m map[string]struct{}) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
func (s *server) handleLatest(w http.ResponseWriter, r *http.Request) {
if cors(w, r) {
return
}
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
q := r.URL.Query()
branch := firstNonEmpty(q.Get("branch"), s.st.manifest.DefaultBranch)
channel := firstNonEmpty(q.Get("channel"), s.st.manifest.DefaultChannel)
arch := q.Get("arch")
bit := q.Get("bit")
osname := q.Get("os")
if branch == "" || channel == "" || arch == "" || bit == "" || osname == "" {
http.Error(w, "missing query params: branch, channel, arch, bit, os", http.StatusBadRequest)
return
}
rel, ok := s.st.getLatest(branch, channel, arch, bit, osname)
if !ok {
http.Error(w, "not found", http.StatusNotFound)
return
}
writeJSON(w, r, http.StatusOK, latestResponse{Branch: branch, Channel: channel, Arch: arch, Bit: bit, OS: osname, Release: rel})
}
func (s *server) handleLatestPath(w http.ResponseWriter, r *http.Request) {
// /v1/latest/{branch}/{channel}/{arch}/{bit}/{os}
if cors(w, r) {
return
}
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/v1/latest/"), "/")
if len(parts) != 5 {
http.Error(w, "expected /v1/latest/{branch}/{channel}/{arch}/{bit}/{os}", http.StatusBadRequest)
return
}
branch, channel, arch, bit, osname := parts[0], parts[1], parts[2], parts[3], parts[4]
rel, ok := s.st.getLatest(branch, channel, arch, bit, osname)
if !ok {
http.Error(w, "not found", http.StatusNotFound)
return
}
writeJSON(w, r, http.StatusOK, latestResponse{Branch: branch, Channel: channel, Arch: arch, Bit: bit, OS: osname, Release: rel})
}
func (s *server) handlePublish(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodOptions {
cors(w, r)
return
}
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
// Auth (if token configured)
if s.apiToken != "" {
auth := r.Header.Get("Authorization")
if !strings.HasPrefix(auth, "Bearer ") || strings.TrimPrefix(auth, "Bearer ") != s.apiToken {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
}
var pr publishRequest
if err := parseJSON(r, &pr); err != nil {
http.Error(w, fmt.Sprintf("invalid json: %v", err), http.StatusBadRequest)
return
}
if err := s.st.setLatest(pr); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
writeJSON(w, r, http.StatusOK, map[string]string{"status": "ok"})
}
// handleConfig allows updating vendor/product/defaults (token required if set)
func (s *server) handleConfig(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodOptions {
cors(w, r)
return
}
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if s.apiToken != "" {
auth := r.Header.Get("Authorization")
if !strings.HasPrefix(auth, "Bearer ") || strings.TrimPrefix(auth, "Bearer ") != s.apiToken {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
}
var req struct {
Vendor string `json:"vendor"`
Product string `json:"product"`
DefaultBranch string `json:"default_branch"`
DefaultChannel string `json:"default_channel"`
}
if err := parseJSON(r, &req); err != nil {
http.Error(w, fmt.Sprintf("invalid json: %v", err), http.StatusBadRequest)
return
}
if req.DefaultChannel != "" {
if _, ok := allowedChannels[req.DefaultChannel]; !ok {
http.Error(w, "invalid default_channel", http.StatusBadRequest)
return
}
}
s.st.mu.Lock()
if req.Vendor != "" {
s.st.manifest.Vendor = req.Vendor
}
if req.Product != "" {
s.st.manifest.Product = req.Product
}
if req.DefaultBranch != "" {
s.st.manifest.DefaultBranch = req.DefaultBranch
}
if req.DefaultChannel != "" {
s.st.manifest.DefaultChannel = req.DefaultChannel
}
if err := s.st.persistLocked(); err != nil {
s.st.mu.Unlock()
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
s.st.mu.Unlock()
writeJSON(w, r, http.StatusOK, map[string]string{"status": "ok"})
}
// ---- Admin UI ---------------------------------------------------------------
//go:embed admin.html
var adminHTML embed.FS
func (s *server) handleAdmin(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
b, err := adminHTML.ReadFile("admin.html")
if err != nil {
http.Error(w, "admin.html not embedded; ensure //go:embed admin.html and file exists: "+err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write(b)
}
// ---- Main ------------------------------------------------------------------
func main() {
addr := envOr("HTTP_PUBLIC", ":8080")
manifestPath := envOr("MANIFEST_PATH", "/data/manifest.json")
vendor := envOr("APP_VENDOR", "YourVendor")
product := envOr("APP_PRODUCT", "YourProduct")
token := os.Getenv("API_TOKEN") // optional; if set, required for POST
st := newStore(manifestPath, vendor, product)
if err := st.loadIfExists(); err != nil {
log.Fatalf("load manifest: %v", err)
}
srv := &server{st: st, apiToken: token}
http.HandleFunc("/healthz", srv.handleHealth)
http.HandleFunc("/admin", srv.handleAdmin)
// Data API
http.HandleFunc("/v1/manifest", srv.handleManifest)
http.HandleFunc("/v1/values", srv.handleValues)
http.HandleFunc("/v1/branches", srv.handleBranches)
http.HandleFunc("/v1/channels", srv.handleChannels)
http.HandleFunc("/v1/latest", srv.handleLatest)
http.HandleFunc("/v1/latest/", srv.handleLatestPath)
http.HandleFunc("/v1/publish", srv.handlePublish)
http.HandleFunc("/v1/config", srv.handleConfig)
log.Printf("agent listening on %s (admin UI at /admin)", addr)
log.Fatal(http.ListenAndServe(addr, nil))
}
// ---- Utils -----------------------------------------------------------------
func envOr(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}