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
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:
51
.gitea/workflows/registry.yml
Normal file
51
.gitea/workflows/registry.yml
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
name: release-tag
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- 'main'
|
||||||
|
jobs:
|
||||||
|
release-image:
|
||||||
|
runs-on: ubuntu-fast
|
||||||
|
env:
|
||||||
|
DOCKER_ORG: ${{ vars.DOCKER_ORG }}
|
||||||
|
DOCKER_LATEST: latest
|
||||||
|
RUNNER_TOOL_CACHE: /toolcache
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v2
|
||||||
|
|
||||||
|
- name: Set up Docker BuildX
|
||||||
|
uses: docker/setup-buildx-action@v2
|
||||||
|
with: # replace it with your local IP
|
||||||
|
config-inline: |
|
||||||
|
[registry."${{ vars.DOCKER_REGISTRY }}"]
|
||||||
|
http = true
|
||||||
|
insecure = true
|
||||||
|
|
||||||
|
- name: Login to DockerHub
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
registry: ${{ vars.DOCKER_REGISTRY }} # replace it with your local IP
|
||||||
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Get Meta
|
||||||
|
id: meta
|
||||||
|
run: |
|
||||||
|
echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT
|
||||||
|
echo REPO_VERSION=$(git describe --tags --always | sed 's/^v//') >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v4
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile
|
||||||
|
platforms: |
|
||||||
|
linux/amd64
|
||||||
|
push: true
|
||||||
|
tags: | # replace it with your local IP and tags
|
||||||
|
${{ vars.DOCKER_REGISTRY }}/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ steps.meta.outputs.REPO_VERSION }}
|
||||||
|
${{ vars.DOCKER_REGISTRY }}/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ env.DOCKER_LATEST }}
|
||||||
124
.gitea/workflows/release.yml
Normal file
124
.gitea/workflows/release.yml
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
# Git(tea) Actions workflow: Build and publish standalone binaries **plus** bundled `static/` assets
|
||||||
|
# ────────────────────────────────────────────────────────────────────
|
||||||
|
# ✧ Builds the Go‑based WoL server for four targets **and** packt das Verzeichnis
|
||||||
|
# `static` zusammen mit der Binary, sodass es relativ zur ausführbaren Datei
|
||||||
|
# liegt (wichtig für die eingebauten Bootstrap‑Assets & favicon).
|
||||||
|
#
|
||||||
|
# • linux/amd64 → wol-server-linux-amd64.tar.gz
|
||||||
|
# • linux/arm64 → wol-server-linux-arm64.tar.gz
|
||||||
|
# • linux/arm/v7 → wol-server-linux-armv7.tar.gz
|
||||||
|
# • windows/amd64 → wol-server-windows-amd64.zip
|
||||||
|
#
|
||||||
|
# ✧ Artefakte landen im Workflow und – bei Tag‑Push (vX.Y.Z) – als Release‑Assets.
|
||||||
|
#
|
||||||
|
# Secrets/variables:
|
||||||
|
# GITEA_TOKEN – optional, falls default token keine Release‑Rechte hat.
|
||||||
|
# ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
name: build-binaries
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ "main" ]
|
||||||
|
tags: [ "v*" ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
runs-on: ubuntu-fast
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- goos: linux
|
||||||
|
goarch: amd64
|
||||||
|
ext: ""
|
||||||
|
- goos: linux
|
||||||
|
goarch: arm64
|
||||||
|
ext: ""
|
||||||
|
- goos: linux
|
||||||
|
goarch: arm
|
||||||
|
goarm: "7"
|
||||||
|
ext: ""
|
||||||
|
- goos: windows
|
||||||
|
goarch: amd64
|
||||||
|
ext: ".exe"
|
||||||
|
|
||||||
|
env:
|
||||||
|
GO_VERSION: "1.25"
|
||||||
|
BINARY_NAME: release-agent
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout source
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Set up Go ${{ env.GO_VERSION }}
|
||||||
|
uses: actions/setup-go@v4
|
||||||
|
with:
|
||||||
|
go-version: ${{ env.GO_VERSION }}
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: Build ${{ matrix.goos }}/${{ matrix.goarch }}${{ matrix.goarm && format('/v{0}', matrix.goarm) || '' }}
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
mkdir -p dist/package
|
||||||
|
if [ -n "${{ matrix.goarm }}" ]; then export GOARM=${{ matrix.goarm }}; fi
|
||||||
|
CGO_ENABLED=0 GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }} go build -trimpath -ldflags "-s -w" \
|
||||||
|
-o "dist/package/${BINARY_NAME}${{ matrix.ext }}" .
|
||||||
|
# Assets: statisches Verzeichnis beilegen
|
||||||
|
# cp -r static dist/package/
|
||||||
|
|
||||||
|
- name: Package archive with static assets
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
cd dist
|
||||||
|
if [ "${{ matrix.goos }}" == "windows" ]; then
|
||||||
|
ZIP_NAME="${BINARY_NAME}-windows-amd64.zip"
|
||||||
|
(cd package && zip -r "../$ZIP_NAME" .)
|
||||||
|
else
|
||||||
|
ARCH_SUFFIX="${{ matrix.goarch }}"
|
||||||
|
if [ "${{ matrix.goarch }}" == "arm" ]; then ARCH_SUFFIX="armv${{ matrix.goarm }}"; fi
|
||||||
|
TAR_NAME="${BINARY_NAME}-${{ matrix.goos }}-${ARCH_SUFFIX}.tar.gz"
|
||||||
|
tar -czf "$TAR_NAME" -C package .
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Upload workflow artifact
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: ${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.goarm && format('v{0}', matrix.goarm) || '' }}
|
||||||
|
path: dist/*.tar.gz
|
||||||
|
if-no-files-found: ignore
|
||||||
|
- uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: windows-amd64
|
||||||
|
path: dist/*.zip
|
||||||
|
if-no-files-found: ignore
|
||||||
|
|
||||||
|
# Release Schritt für Tag‑Pushes
|
||||||
|
release:
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-fast
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Download artifacts
|
||||||
|
uses: actions/download-artifact@v3
|
||||||
|
with:
|
||||||
|
path: ./dist
|
||||||
|
|
||||||
|
- name: Create / Update release
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITEA_TOKEN || github.token }}
|
||||||
|
with:
|
||||||
|
name: "Release ${{ github.ref_name }}"
|
||||||
|
tag_name: ${{ github.ref_name }}
|
||||||
|
draft: false
|
||||||
|
prerelease: false
|
||||||
|
files: |
|
||||||
|
dist/**/release-agent-*.tar.gz
|
||||||
|
dist/**/release-agent-*.zip
|
||||||
21
Dockerfile
Normal file
21
Dockerfile
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
FROM golang:1.25.3 AS build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -o /out/release-agent
|
||||||
|
#FROM gcr.io/distroless/static:nonroot
|
||||||
|
FROM alpine:3.22.2
|
||||||
|
WORKDIR /
|
||||||
|
RUN apk add --no-cache tzdata nano
|
||||||
|
RUN mkdir /data
|
||||||
|
VOLUME ["/data"]
|
||||||
|
EXPOSE 8080
|
||||||
|
ENV API_TOKEN="" \
|
||||||
|
HTTP_PUBLIC=":8080" \
|
||||||
|
MANIFEST_PATH="/data" \
|
||||||
|
APP_VENDOR="YourVendor" \
|
||||||
|
APP_PRODUCT="YourProduct"
|
||||||
|
|
||||||
|
COPY --from=build /out/release-agent /release-agent
|
||||||
|
ENTRYPOINT ["/release-agent"]
|
||||||
187
admin.html
Normal file
187
admin.html
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Version Agent Admin</title>
|
||||||
|
<style>
|
||||||
|
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Cantarell;max-width:1000px;margin:40px auto;padding:0 16px}
|
||||||
|
header{display:flex;justify-content:space-between;align-items:center;margin-bottom:24px}
|
||||||
|
section{border:1px solid #ddd;border-radius:12px;padding:16px;margin-bottom:24px;box-shadow:0 1px 3px rgba(0,0,0,.05)}
|
||||||
|
label{display:block;margin:.3rem 0 .1rem;color:#333}
|
||||||
|
input,select,textarea{width:100%;padding:.5rem;border:1px solid #ccc;border-radius:8px}
|
||||||
|
button{padding:.6rem 1rem;border:0;border-radius:10px;cursor:pointer}
|
||||||
|
.btn{background:#111;color:#fff}
|
||||||
|
.grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px}
|
||||||
|
.assets{margin-top:8px}
|
||||||
|
.asset-row{display:grid;grid-template-columns:2fr 2fr 1fr 1fr;gap:8px;margin-bottom:8px}
|
||||||
|
.small{font-size:.9rem;color:#555}
|
||||||
|
code{background:#f6f6f6;padding:.2rem .4rem;border-radius:6px}
|
||||||
|
pre{background:#f6f6f6;padding:8px;border-radius:8px;overflow:auto}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>Version Agent Admin</h1>
|
||||||
|
<div>
|
||||||
|
<label>API Token (for POST)</label>
|
||||||
|
<input id="token" placeholder="Bearer Token" />
|
||||||
|
<div class="small">will be saved in <code>localStorage</code></div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Configuration</h2>
|
||||||
|
<div class="grid">
|
||||||
|
<div>
|
||||||
|
<label>Vendor</label><input id="vendor" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Product</label><input id="product" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Default Branch</label><input id="defBranch" placeholder="eg. 12.x" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Default Channel</label>
|
||||||
|
<select id="defChannel"></select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:12px"><button class="btn" id="saveConfig">Save</button></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Maintenance: Set Latest</h2>
|
||||||
|
<div class="grid">
|
||||||
|
<div><label>Branch</label><input id="branch" placeholder="eg. 12.x"/></div>
|
||||||
|
<div><label>Channel</label><select id="channel"></select></div>
|
||||||
|
<div><label>Arch</label><select id="arch"></select></div>
|
||||||
|
<div><label>Bit</label><select id="bit"></select></div>
|
||||||
|
</div>
|
||||||
|
<div class="grid" style="margin-top:12px">
|
||||||
|
<div><label>OS</label><select id="os"></select></div>
|
||||||
|
<div><label>Version</label><input id="version" placeholder="12.3.1"/></div>
|
||||||
|
<div><label>Build</label><input id="build" placeholder="optional"/></div>
|
||||||
|
<div><label>Released At (RFC3339)</label><input id="releasedAt" placeholder="2025-10-15T12:34:56Z"/></div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:12px">
|
||||||
|
<label>Notes URL</label><input id="notesUrl" placeholder="https://example.com/release-notes"/>
|
||||||
|
</div>
|
||||||
|
<div class="assets">
|
||||||
|
<h3>Assets</h3>
|
||||||
|
<div id="assets"></div>
|
||||||
|
<button id="addAsset">Add Asset</button>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:12px"><button class="btn" id="publish">Publish</button></div>
|
||||||
|
<div style="margin-top:12px">
|
||||||
|
<button id="loadLatest">Load Latest Info</button>
|
||||||
|
</div>
|
||||||
|
<pre id="log"></pre>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Manifest</h2>
|
||||||
|
<div class="small">ETag-aware GET <code>/v1/manifest</code></div>
|
||||||
|
<pre id="manifest"></pre>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const $ = sel => document.querySelector(sel);
|
||||||
|
const log = (msg) => { const el=$('#log'); el.textContent = (new Date()).toISOString()+"\n"+msg; }
|
||||||
|
|
||||||
|
function tokenHeader(){ const t=$('#token').value.trim(); return t? { 'Authorization': 'Bearer '+t } : {}; }
|
||||||
|
|
||||||
|
async function loadValues(){
|
||||||
|
const r = await fetch('/v1/values'); const j = await r.json();
|
||||||
|
const fill = (id, arr) => { const s=$(id); s.innerHTML=''; arr.forEach(v=>{ const o=document.createElement('option'); o.value=v; o.textContent=v; s.appendChild(o); }); };
|
||||||
|
fill('#arch', j.arch); fill('#bit', j.bit); fill('#os', j.os); fill('#channel', j.channels); fill('#defChannel', j.channels);
|
||||||
|
$('#defBranch').value = j.defaults.branch || '';
|
||||||
|
$('#defChannel').value = j.defaults.channel || 'stable';
|
||||||
|
$('#vendor').value = j.meta.vendor || '';
|
||||||
|
$('#product').value = j.meta.product || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadManifest(){ const r=await fetch('/v1/manifest'); const j=await r.json(); $('#manifest').textContent = JSON.stringify(j, null, 2); }
|
||||||
|
|
||||||
|
function addAssetRow(data={}){
|
||||||
|
const wrap=document.createElement('div'); wrap.className='asset-row';
|
||||||
|
wrap.innerHTML = `
|
||||||
|
<input placeholder="URL" value="${data.url||''}"/>
|
||||||
|
<input placeholder="SHA256" value="${data.sha256||''}"/>
|
||||||
|
<input placeholder="Size (bytes)" value="${data.size_bytes||''}"/>
|
||||||
|
<input placeholder="Signature URL" value="${data.signature_url||''}"/>
|
||||||
|
`;
|
||||||
|
$('#assets').appendChild(wrap);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publish(){
|
||||||
|
const assets = Array.from(document.querySelectorAll('.asset-row')).map(row=>{
|
||||||
|
const [url,sha,size,sig] = row.querySelectorAll('input');
|
||||||
|
const a={ url:url.value.trim(), sha256:sha.value.trim() };
|
||||||
|
if(size.value.trim()) a.size_bytes = parseInt(size.value.trim(),10);
|
||||||
|
if(sig.value.trim()) a.signature_url = sig.value.trim();
|
||||||
|
return a;
|
||||||
|
}).filter(a=>a.url && a.sha256);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
branch: $('#branch').value.trim() || $('#defBranch').value.trim(),
|
||||||
|
channel: $('#channel').value,
|
||||||
|
arch: $('#arch').value,
|
||||||
|
bit: $('#bit').value,
|
||||||
|
os: $('#os').value,
|
||||||
|
release: {
|
||||||
|
version: $('#version').value.trim(),
|
||||||
|
build: $('#build').value.trim(),
|
||||||
|
released_at: $('#releasedAt').value.trim(),
|
||||||
|
notes_url: $('#notesUrl').value.trim(),
|
||||||
|
assets
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const r = await fetch('/v1/publish', { method:'POST', headers:{ 'Content-Type':'application/json', ...tokenHeader() }, body: JSON.stringify(payload) });
|
||||||
|
const txt = await r.text(); log(txt); await loadManifest();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLatest(){
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
branch: $('#branch').value.trim() || $('#defBranch').value.trim(),
|
||||||
|
channel: $('#channel').value,
|
||||||
|
arch: $('#arch').value,
|
||||||
|
bit: $('#bit').value,
|
||||||
|
os: $('#os').value
|
||||||
|
});
|
||||||
|
const r = await fetch('/v1/latest?'+params.toString());
|
||||||
|
if(!r.ok){ log('not found'); return; }
|
||||||
|
const j = await r.json();
|
||||||
|
$('#version').value = j.release.version || '';
|
||||||
|
$('#build').value = j.release.build || '';
|
||||||
|
$('#releasedAt').value = (j.release.released_at||'');
|
||||||
|
$('#notesUrl').value = j.release.notes_url || '';
|
||||||
|
$('#assets').innerHTML='';
|
||||||
|
(j.release.assets||[]).forEach(a=>addAssetRow({ url:a.url, sha256:a.sha256, size_bytes:a.size_bytes||'', signature_url:a.signature_url||'' }));
|
||||||
|
if((j.release.assets||[]).length===0) addAssetRow();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveConfig(){
|
||||||
|
const payload = {
|
||||||
|
vendor: $('#vendor').value.trim(),
|
||||||
|
product: $('#product').value.trim(),
|
||||||
|
default_branch: $('#defBranch').value.trim(),
|
||||||
|
default_channel: $('#defChannel').value
|
||||||
|
};
|
||||||
|
const r = await fetch('/v1/config', { method:'POST', headers:{ 'Content-Type':'application/json', ...tokenHeader() }, body: JSON.stringify(payload) });
|
||||||
|
const txt = await r.text(); log(txt); await loadValues(); await loadManifest();
|
||||||
|
}
|
||||||
|
|
||||||
|
// init
|
||||||
|
(function(){
|
||||||
|
$('#token').value = localStorage.getItem('apiToken')||'';
|
||||||
|
$('#token').addEventListener('input', e=> localStorage.setItem('apiToken', e.target.value));
|
||||||
|
$('#addAsset').addEventListener('click', e=>{ e.preventDefault(); addAssetRow(); });
|
||||||
|
$('#publish').addEventListener('click', e=>{ e.preventDefault(); publish(); });
|
||||||
|
$('#loadLatest').addEventListener('click', e=>{ e.preventDefault(); loadLatest(); });
|
||||||
|
$('#saveConfig').addEventListener('click', e=>{ e.preventDefault(); saveConfig(); });
|
||||||
|
addAssetRow(); loadValues(); loadManifest();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body></html>
|
||||||
3
go.mod
Normal file
3
go.mod
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
module git.send.nrw/patchping/release-agent
|
||||||
|
|
||||||
|
go 1.25.3
|
||||||
634
main.go
Normal file
634
main.go
Normal 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 ""
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user