Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3de223040d | |||
| 4a9a4ad65d | |||
| 09f57ef5f6 |
208
admin.html
208
admin.html
@@ -1,107 +1,125 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="de">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>Version Agent Admin</title>
|
<title>Version Agent Admin</title>
|
||||||
<style>
|
<style>
|
||||||
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Cantarell;max-width:1000px;margin:40px auto;padding:0 16px}
|
:root { --gap: 12px; }
|
||||||
header{display:flex;justify-content:space-between;align-items:center;margin-bottom:24px}
|
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Cantarell;max-width:1100px;margin:40px auto;padding:0 16px}
|
||||||
|
header{display:flex;gap:24px;flex-wrap:wrap;align-items:flex-end;justify-content:space-between;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)}
|
section{border:1px solid #ddd;border-radius:12px;padding:16px;margin-bottom:24px;box-shadow:0 1px 3px rgba(0,0,0,.05)}
|
||||||
|
h1{font-size:1.4rem;margin:0 0 8px}
|
||||||
|
h2{font-size:1.1rem;margin:.2rem 0 .8rem}
|
||||||
label{display:block;margin:.3rem 0 .1rem;color:#333}
|
label{display:block;margin:.3rem 0 .1rem;color:#333}
|
||||||
input,select,textarea{width:100%;padding:.5rem;border:1px solid #ccc;border-radius:8px}
|
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}
|
button{padding:.6rem 1rem;border:0;border-radius:10px;cursor:pointer}
|
||||||
.btn{background:#111;color:#fff}
|
.btn{background:#111;color:#fff}
|
||||||
.grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px}
|
.muted{color:#666}
|
||||||
|
.row{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:var(--gap)}
|
||||||
|
.row-3{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--gap)}
|
||||||
|
.row-2{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:var(--gap)}
|
||||||
.assets{margin-top:8px}
|
.assets{margin-top:8px}
|
||||||
.asset-row{display:grid;grid-template-columns:2fr 2fr 1fr 1fr;gap:8px;margin-bottom:8px}
|
.asset-row{display:grid;grid-template-columns:2fr 2fr 1fr 2fr;gap:8px;margin-bottom:8px}
|
||||||
.small{font-size:.9rem;color:#555}
|
pre{background:#f6f6f6;padding:8px;border-radius:8px;overflow:auto;max-height:360px}
|
||||||
code{background:#f6f6f6;padding:.2rem .4rem;border-radius:6px}
|
.right{display:flex;gap:8px;align-items:end}
|
||||||
pre{background:#f6f6f6;padding:8px;border-radius:8px;overflow:auto}
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
<h1>Version Agent Admin</h1>
|
|
||||||
<div>
|
<div>
|
||||||
<label>API Token (for POST)</label>
|
<h1>Version Agent Admin</h1>
|
||||||
|
<div class="muted">Pflege mehrerer Produkte pro Vendor</div>
|
||||||
|
</div>
|
||||||
|
<div class="right">
|
||||||
|
<div>
|
||||||
|
<label>API Token (für POST)</label>
|
||||||
<input id="token" placeholder="Bearer Token" />
|
<input id="token" placeholder="Bearer Token" />
|
||||||
<div class="small">will be saved in <code>localStorage</code></div>
|
<div class="muted">lokal gespeichert</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Produkt</label>
|
||||||
|
<div class="row-2">
|
||||||
|
<select id="product"></select>
|
||||||
|
<button id="makeDefault" title="Als Default-Produkt setzen">Als Default</button>
|
||||||
|
</div>
|
||||||
|
<div class="muted">wird in <code>localStorage</code> gemerkt</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2>Configuration</h2>
|
<h2>Produkt-Konfiguration</h2>
|
||||||
<div class="grid">
|
<div class="row">
|
||||||
<div>
|
<div>
|
||||||
<label>Vendor</label><input id="vendor" />
|
<label>Default Branch</label>
|
||||||
</div>
|
<input id="defBranch" placeholder="z.B. 12.x" />
|
||||||
<div>
|
|
||||||
<label>Product</label><input id="product" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label>Default Branch</label><input id="defBranch" placeholder="eg. 12.x" />
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label>Default Channel</label>
|
<label>Default Channel</label>
|
||||||
<select id="defChannel"></select>
|
<select id="defChannel"></select>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Vendor (optional global)</label>
|
||||||
|
<input id="vendor" placeholder="Vendor-Name" />
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:8px;align-items:flex-end">
|
||||||
|
<button class="btn" id="saveProductCfg">Produkt-Defaults speichern</button>
|
||||||
|
<button id="saveVendorCfg">Vendor speichern</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="margin-top:12px"><button class="btn" id="saveConfig">Save</button></div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2>Maintenance: Set Latest</h2>
|
<h2>Pflege: Latest setzen</h2>
|
||||||
<div class="grid">
|
<div class="row">
|
||||||
<div><label>Branch</label><input id="branch" placeholder="eg. 12.x"/></div>
|
<div><label>Branch</label><input id="branch" placeholder="z.B. 12.x"/></div>
|
||||||
<div><label>Channel</label><select id="channel"></select></div>
|
<div><label>Channel</label><select id="channel"></select></div>
|
||||||
<div><label>Arch</label><select id="arch"></select></div>
|
<div><label>Arch</label><select id="arch"></select></div>
|
||||||
<div><label>Bit</label><select id="bit"></select></div>
|
<div><label>Bit</label><select id="bit"></select></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid" style="margin-top:12px">
|
<div class="row" style="margin-top:var(--gap)">
|
||||||
<div><label>OS</label><select id="os"></select></div>
|
<div><label>OS</label><select id="os"></select></div>
|
||||||
<div><label>Version</label><input id="version" placeholder="12.3.1"/></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>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><label>Released At (RFC3339)</label><input id="releasedAt" placeholder="2025-10-15T12:34:56Z"/></div>
|
||||||
</div>
|
</div>
|
||||||
<div style="margin-top:12px">
|
<div style="margin-top:var(--gap)">
|
||||||
<label>Notes URL</label><input id="notesUrl" placeholder="https://example.com/release-notes"/>
|
<label>Notes URL</label><input id="notesUrl" placeholder="https://example.com/release-notes"/>
|
||||||
</div>
|
</div>
|
||||||
<div class="assets">
|
<div class="assets">
|
||||||
<h3>Assets</h3>
|
<h3>Assets</h3>
|
||||||
<div id="assets"></div>
|
<div id="assets"></div>
|
||||||
<button id="addAsset">Add Asset</button>
|
<button id="addAsset">Asset hinzufügen</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>
|
</div>
|
||||||
|
<div style="margin-top:var(--gap)"><button class="btn" id="publish">Publish</button> <button id="loadLatest">Aktuelles Latest laden</button></div>
|
||||||
<pre id="log"></pre>
|
<pre id="log"></pre>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2>Manifest</h2>
|
<h2>Manifest (Produkt-spezifisch)</h2>
|
||||||
<div class="small">ETag-aware GET <code>/v1/manifest</code></div>
|
<div class="muted">GET <code>/v1/manifest?product=...</code></div>
|
||||||
<pre id="manifest"></pre>
|
<pre id="manifest"></pre>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Neues Produkt anlegen</h2>
|
||||||
|
<div class="row-3">
|
||||||
|
<div><label>Produkt-Name</label><input id="newProdName" placeholder="z.B. wol-server"/></div>
|
||||||
|
<div><label>Default Branch</label><input id="newProdBranch" placeholder="z.B. 1.x"/></div>
|
||||||
|
<div><label>Default Channel</label><select id="newProdChannel"></select></div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:var(--gap)"><button id="createProduct">Produkt anlegen/aktualisieren</button></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const $ = sel => document.querySelector(sel);
|
const $ = s => document.querySelector(s);
|
||||||
|
const tokenHeader = () => { const t=$('#token').value.trim(); return t? { 'Authorization': 'Bearer '+t } : {}; }
|
||||||
|
const api = (path, opt={}) => fetch(path, opt);
|
||||||
|
const product = () => $('#product').value.trim();
|
||||||
const log = (msg) => { const el=$('#log'); el.textContent = (new Date()).toISOString()+"\n"+msg; }
|
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 } : {}; }
|
function fillSelect(sel, arr, val){ const s=$(sel); s.innerHTML=''; arr.forEach(v=>{ const o=document.createElement('option'); o.value=v; o.textContent=v; s.appendChild(o); }); if(val && arr.includes(val)) s.value=val; }
|
||||||
|
|
||||||
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={}){
|
function addAssetRow(data={}){
|
||||||
const wrap=document.createElement('div'); wrap.className='asset-row';
|
const wrap=document.createElement('div'); wrap.className='asset-row';
|
||||||
@@ -114,7 +132,75 @@ function addAssetRow(data={}){
|
|||||||
$('#assets').appendChild(wrap);
|
$('#assets').appendChild(wrap);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadProducts(){
|
||||||
|
const r = await api('/v1/products');
|
||||||
|
const j = await r.json();
|
||||||
|
const list = j.products || [];
|
||||||
|
const def = j.default || '';
|
||||||
|
const last = localStorage.getItem('product') || '';
|
||||||
|
const chosen = list.includes(last) ? last : (def || list[0] || '');
|
||||||
|
fillSelect('#product', list, chosen);
|
||||||
|
localStorage.setItem('product', chosen||'');
|
||||||
|
await Promise.all([loadValues(), loadManifest()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadValues(){
|
||||||
|
const r = await api('/v1/values?product='+encodeURIComponent(product()));
|
||||||
|
const j = await r.json();
|
||||||
|
fillSelect('#arch', j.arch||[]);
|
||||||
|
fillSelect('#bit', j.bit||[]);
|
||||||
|
fillSelect('#os', j.os||[]);
|
||||||
|
fillSelect('#channel', j.channels||[], (j.defaults && j.defaults.channel) || 'stable');
|
||||||
|
fillSelect('#defChannel', j.channels||[], (j.defaults && j.defaults.channel) || 'stable');
|
||||||
|
fillSelect('#newProdChannel', j.channels||[], 'stable');
|
||||||
|
|
||||||
|
$('#defBranch').value = (j.defaults && j.defaults.branch) || '';
|
||||||
|
$('#vendor').value = (j.meta && j.meta.vendor) || '';
|
||||||
|
|
||||||
|
// Vorauswahl für Publish-Form
|
||||||
|
$('#branch').value = $('#defBranch').value;
|
||||||
|
$('#assets').innerHTML=''; addAssetRow();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadManifest(){
|
||||||
|
const p = product();
|
||||||
|
const r = await api('/v1/manifest'+(p? ('?product='+encodeURIComponent(p)) : ''));
|
||||||
|
const j = await r.json();
|
||||||
|
$('#manifest').textContent = JSON.stringify(j, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveProductCfg(){
|
||||||
|
const p = product(); if(!p){ alert('Kein Produkt gewählt'); return }
|
||||||
|
const payload = { DefaultBranch: $('#defBranch').value.trim(), DefaultChannel: $('#defChannel').value };
|
||||||
|
const r = await api('/v1/config?product='+encodeURIComponent(p), { method:'POST', headers:{ 'Content-Type':'application/json', ...tokenHeader() }, body: JSON.stringify(payload) });
|
||||||
|
const txt = await r.text(); log(txt); await Promise.all([loadValues(), loadManifest()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveVendorCfg(){
|
||||||
|
const payload = { Vendor: $('#vendor').value.trim() };
|
||||||
|
const r = await api('/v1/config', { method:'POST', headers:{ 'Content-Type':'application/json', ...tokenHeader() }, body: JSON.stringify(payload) });
|
||||||
|
const txt = await r.text(); log(txt);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function makeDefault(){
|
||||||
|
const p = product(); if(!p){ alert('Kein Produkt gewählt'); return }
|
||||||
|
const payload = { DefaultProduct: p };
|
||||||
|
const r = await api('/v1/config', { method:'POST', headers:{ 'Content-Type':'application/json', ...tokenHeader() }, body: JSON.stringify(payload) });
|
||||||
|
const txt = await r.text(); log(txt); await loadProducts();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createProduct(){
|
||||||
|
const name = $('#newProdName').value.trim(); if(!name){ alert('Produkt-Name fehlt'); return }
|
||||||
|
const payload = { product: name, default_branch: $('#newProdBranch').value.trim(), default_channel: $('#newProdChannel').value };
|
||||||
|
const r = await api('/v1/products', { method:'POST', headers:{ 'Content-Type':'application/json', ...tokenHeader() }, body: JSON.stringify(payload) });
|
||||||
|
const txt = await r.text(); log(txt);
|
||||||
|
await loadProducts();
|
||||||
|
$('#product').value = name; localStorage.setItem('product', name);
|
||||||
|
await Promise.all([loadValues(), loadManifest()]);
|
||||||
|
}
|
||||||
|
|
||||||
async function publish(){
|
async function publish(){
|
||||||
|
const p = product(); if(!p){ alert('Kein Produkt gewählt'); return }
|
||||||
const assets = Array.from(document.querySelectorAll('.asset-row')).map(row=>{
|
const assets = Array.from(document.querySelectorAll('.asset-row')).map(row=>{
|
||||||
const [url,sha,size,sig] = row.querySelectorAll('input');
|
const [url,sha,size,sig] = row.querySelectorAll('input');
|
||||||
const a={ url:url.value.trim(), sha256:sha.value.trim() };
|
const a={ url:url.value.trim(), sha256:sha.value.trim() };
|
||||||
@@ -138,19 +224,21 @@ async function publish(){
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const r = await fetch('/v1/publish', { method:'POST', headers:{ 'Content-Type':'application/json', ...tokenHeader() }, body: JSON.stringify(payload) });
|
const r = await api('/v1/publish?product='+encodeURIComponent(p), { method:'POST', headers:{ 'Content-Type':'application/json', ...tokenHeader() }, body: JSON.stringify(payload) });
|
||||||
const txt = await r.text(); log(txt); await loadManifest();
|
const txt = await r.text(); log(txt); await loadManifest();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadLatest(){
|
async function loadLatest(){
|
||||||
|
const p = product(); if(!p){ alert('Kein Produkt gewählt'); return }
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
|
product: p,
|
||||||
branch: $('#branch').value.trim() || $('#defBranch').value.trim(),
|
branch: $('#branch').value.trim() || $('#defBranch').value.trim(),
|
||||||
channel: $('#channel').value,
|
channel: $('#channel').value,
|
||||||
arch: $('#arch').value,
|
arch: $('#arch').value,
|
||||||
bit: $('#bit').value,
|
bit: $('#bit').value,
|
||||||
os: $('#os').value
|
os: $('#os').value
|
||||||
});
|
});
|
||||||
const r = await fetch('/v1/latest?'+params.toString());
|
const r = await api('/v1/latest?'+params.toString());
|
||||||
if(!r.ok){ log('not found'); return; }
|
if(!r.ok){ log('not found'); return; }
|
||||||
const j = await r.json();
|
const j = await r.json();
|
||||||
$('#version').value = j.release.version || '';
|
$('#version').value = j.release.version || '';
|
||||||
@@ -162,26 +250,20 @@ async function loadLatest(){
|
|||||||
if((j.release.assets||[]).length===0) addAssetRow();
|
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
|
// init
|
||||||
(function(){
|
(function(){
|
||||||
$('#token').value = localStorage.getItem('apiToken')||'';
|
$('#token').value = localStorage.getItem('apiToken')||'';
|
||||||
$('#token').addEventListener('input', e=> localStorage.setItem('apiToken', e.target.value));
|
$('#token').addEventListener('input', e=> localStorage.setItem('apiToken', e.target.value));
|
||||||
|
$('#product').addEventListener('change', async e=>{ localStorage.setItem('product', e.target.value); await Promise.all([loadValues(), loadManifest()]); });
|
||||||
$('#addAsset').addEventListener('click', e=>{ e.preventDefault(); addAssetRow(); });
|
$('#addAsset').addEventListener('click', e=>{ e.preventDefault(); addAssetRow(); });
|
||||||
$('#publish').addEventListener('click', e=>{ e.preventDefault(); publish(); });
|
$('#publish').addEventListener('click', e=>{ e.preventDefault(); publish(); });
|
||||||
$('#loadLatest').addEventListener('click', e=>{ e.preventDefault(); loadLatest(); });
|
$('#loadLatest').addEventListener('click', e=>{ e.preventDefault(); loadLatest(); });
|
||||||
$('#saveConfig').addEventListener('click', e=>{ e.preventDefault(); saveConfig(); });
|
$('#saveProductCfg').addEventListener('click', e=>{ e.preventDefault(); saveProductCfg(); });
|
||||||
addAssetRow(); loadValues(); loadManifest();
|
$('#saveVendorCfg').addEventListener('click', e=>{ e.preventDefault(); saveVendorCfg(); });
|
||||||
|
$('#makeDefault').addEventListener('click', e=>{ e.preventDefault(); makeDefault(); });
|
||||||
|
$('#createProduct').addEventListener('click', e=>{ e.preventDefault(); createProduct(); });
|
||||||
|
addAssetRow(); loadProducts();
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body></html>
|
</body>
|
||||||
|
</html>
|
||||||
|
|||||||
503
main.go
503
main.go
@@ -43,20 +43,28 @@ type Release struct {
|
|||||||
ChannelHint string `json:"channel,omitempty"` // echoed by server
|
ChannelHint string `json:"channel,omitempty"` // echoed by server
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manifest nests by Branch→Channel→Arch→Bit→OS as requested.
|
// ProductManifest nests by Branch→Channel→Arch→Bit→OS.
|
||||||
// The innermost value is the latest Release for that tuple.
|
|
||||||
// Example path: Releases["12.x"]["stable"]["amd64"]["64"]["windows"]
|
// Example path: Releases["12.x"]["stable"]["amd64"]["64"]["windows"]
|
||||||
type Manifest struct {
|
type ProductManifest struct {
|
||||||
Vendor string `json:"vendor"`
|
|
||||||
Product string `json:"product"`
|
Product string `json:"product"`
|
||||||
DefaultBranch string `json:"default_branch,omitempty"`
|
DefaultBranch string `json:"default_branch,omitempty"`
|
||||||
DefaultChannel string `json:"default_channel,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"`
|
Releases map[string]map[string]map[string]map[string]map[string]Release `json:"releases"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// publishRequest is the payload for POST /v1/publish
|
// MultiManifest groups multiple products for the same vendor.
|
||||||
|
// Legacy endpoints continue to operate on DefaultProduct.
|
||||||
|
type MultiManifest struct {
|
||||||
|
Vendor string `json:"vendor"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
DefaultProduct string `json:"default_product,omitempty"`
|
||||||
|
Products map[string]*ProductManifest `json:"products"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// publishRequest is the payload for POST /v1/publish (and /v1/p/{product}/publish)
|
||||||
|
// "product" is optional when you hit the product-scoped endpoints; required for global ones.
|
||||||
type publishRequest struct {
|
type publishRequest struct {
|
||||||
|
Product string `json:"product,omitempty"`
|
||||||
Branch string `json:"branch"`
|
Branch string `json:"branch"`
|
||||||
Channel string `json:"channel"` // stable, beta, rc, nightly
|
Channel string `json:"channel"` // stable, beta, rc, nightly
|
||||||
Arch string `json:"arch"`
|
Arch string `json:"arch"`
|
||||||
@@ -65,9 +73,9 @@ type publishRequest struct {
|
|||||||
Release Release `json:"release"`
|
Release Release `json:"release"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// latestResponse is returned by GET /v1/latest
|
// latestResponse mirrors the request tuple alongside the release for clarity.
|
||||||
// Mirrors the request tuple alongside the release for clarity.
|
|
||||||
type latestResponse struct {
|
type latestResponse struct {
|
||||||
|
Product string `json:"product"`
|
||||||
Branch string `json:"branch"`
|
Branch string `json:"branch"`
|
||||||
Channel string `json:"channel"`
|
Channel string `json:"channel"`
|
||||||
Arch string `json:"arch"`
|
Arch string `json:"arch"`
|
||||||
@@ -80,24 +88,29 @@ type latestResponse struct {
|
|||||||
|
|
||||||
type store struct {
|
type store struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
manifest Manifest
|
manifest MultiManifest
|
||||||
path string
|
path string
|
||||||
}
|
}
|
||||||
|
|
||||||
func newStore(path, vendor, product string) *store {
|
func newStore(path, vendor, defaultProduct string) *store {
|
||||||
m := Manifest{
|
m := MultiManifest{
|
||||||
Vendor: vendor,
|
Vendor: vendor,
|
||||||
Product: product,
|
|
||||||
DefaultBranch: "",
|
|
||||||
DefaultChannel: "stable",
|
|
||||||
UpdatedAt: time.Now().UTC(),
|
UpdatedAt: time.Now().UTC(),
|
||||||
|
Products: make(map[string]*ProductManifest),
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(defaultProduct) != "" {
|
||||||
|
m.DefaultProduct = defaultProduct
|
||||||
|
m.Products[defaultProduct] = &ProductManifest{
|
||||||
|
Product: defaultProduct,
|
||||||
|
DefaultChannel: "stable",
|
||||||
Releases: make(map[string]map[string]map[string]map[string]map[string]Release),
|
Releases: make(map[string]map[string]map[string]map[string]map[string]Release),
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return &store{manifest: m, path: path}
|
return &store{manifest: m, path: path}
|
||||||
}
|
}
|
||||||
|
|
||||||
// oldManifest is used to migrate v1 manifests (without channels) → channels("stable").
|
// oldV1Manifest (no channels) → used for deep migration to multi.
|
||||||
type oldManifest struct {
|
type oldV1Manifest struct {
|
||||||
Vendor string `json:"vendor"`
|
Vendor string `json:"vendor"`
|
||||||
Product string `json:"product"`
|
Product string `json:"product"`
|
||||||
DefaultBranch string `json:"default_branch,omitempty"`
|
DefaultBranch string `json:"default_branch,omitempty"`
|
||||||
@@ -105,6 +118,16 @@ type oldManifest struct {
|
|||||||
Releases map[string]map[string]map[string]map[string]Release `json:"releases"`
|
Releases map[string]map[string]map[string]map[string]Release `json:"releases"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// oldV2Single is the previous single-product structure with channels.
|
||||||
|
type oldV2Single 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"`
|
||||||
|
}
|
||||||
|
|
||||||
func (s *store) loadIfExists() error {
|
func (s *store) loadIfExists() error {
|
||||||
b, err := os.ReadFile(s.path)
|
b, err := os.ReadFile(s.path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -113,32 +136,49 @@ func (s *store) loadIfExists() error {
|
|||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var m Manifest
|
// Try multi manifest directly
|
||||||
if err := json.Unmarshal(b, &m); err == nil && m.Releases != nil {
|
var multi MultiManifest
|
||||||
// Looks like v2 → accept.
|
if err := json.Unmarshal(b, &multi); err == nil && multi.Products != nil {
|
||||||
|
if multi.Products == nil {
|
||||||
|
multi.Products = make(map[string]*ProductManifest)
|
||||||
|
}
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
s.manifest = m
|
s.manifest = multi
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
// Try v1 → migrate into channel "stable".
|
// Try old V2 single-product
|
||||||
var ov1 oldManifest
|
var v2 oldV2Single
|
||||||
if err := json.Unmarshal(b, &ov1); err != nil {
|
if err := json.Unmarshal(b, &v2); err == nil && v2.Releases != nil {
|
||||||
|
mm := MultiManifest{
|
||||||
|
Vendor: v2.Vendor,
|
||||||
|
UpdatedAt: time.Now().UTC(),
|
||||||
|
DefaultProduct: v2.Product,
|
||||||
|
Products: make(map[string]*ProductManifest),
|
||||||
|
}
|
||||||
|
pm := &ProductManifest{
|
||||||
|
Product: v2.Product,
|
||||||
|
DefaultBranch: v2.DefaultBranch,
|
||||||
|
DefaultChannel: firstNonEmpty(v2.DefaultChannel, "stable"),
|
||||||
|
Releases: v2.Releases,
|
||||||
|
}
|
||||||
|
mm.Products[v2.Product] = pm
|
||||||
|
s.mu.Lock()
|
||||||
|
s.manifest = mm
|
||||||
|
s.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Try old V1 (no channels) → migrate to stable channel, then into multi
|
||||||
|
var v1 oldV1Manifest
|
||||||
|
if err := json.Unmarshal(b, &v1); err != nil {
|
||||||
return fmt.Errorf("invalid manifest json: %w", err)
|
return fmt.Errorf("invalid manifest json: %w", err)
|
||||||
}
|
}
|
||||||
mig := Manifest{
|
releases := make(map[string]map[string]map[string]map[string]map[string]Release)
|
||||||
Vendor: ov1.Vendor,
|
for br, archs := range v1.Releases {
|
||||||
Product: ov1.Product,
|
if _, ok := releases[br]; !ok {
|
||||||
DefaultBranch: ov1.DefaultBranch,
|
releases[br] = make(map[string]map[string]map[string]map[string]Release)
|
||||||
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 {
|
ch := releases[br]
|
||||||
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 {
|
if _, ok := ch["stable"]; !ok {
|
||||||
ch["stable"] = make(map[string]map[string]map[string]Release)
|
ch["stable"] = make(map[string]map[string]map[string]Release)
|
||||||
}
|
}
|
||||||
@@ -151,13 +191,29 @@ func (s *store) loadIfExists() error {
|
|||||||
ch["stable"][arch][bit] = make(map[string]Release)
|
ch["stable"][arch][bit] = make(map[string]Release)
|
||||||
}
|
}
|
||||||
for osname, rel := range osmap {
|
for osname, rel := range osmap {
|
||||||
|
rel.ChannelHint = "stable"
|
||||||
ch["stable"][arch][bit][osname] = rel
|
ch["stable"][arch][bit][osname] = rel
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
mm := MultiManifest{
|
||||||
|
Vendor: v1.Vendor,
|
||||||
|
UpdatedAt: time.Now().UTC(),
|
||||||
|
DefaultProduct: firstNonEmpty(v1.Product, s.manifest.DefaultProduct),
|
||||||
|
Products: make(map[string]*ProductManifest),
|
||||||
|
}
|
||||||
|
if mm.DefaultProduct == "" {
|
||||||
|
mm.DefaultProduct = "Product"
|
||||||
|
}
|
||||||
|
mm.Products[mm.DefaultProduct] = &ProductManifest{
|
||||||
|
Product: mm.DefaultProduct,
|
||||||
|
DefaultBranch: v1.DefaultBranch,
|
||||||
|
DefaultChannel: "stable",
|
||||||
|
Releases: releases,
|
||||||
|
}
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
s.manifest = mig
|
s.manifest = mm
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -169,11 +225,9 @@ func (s *store) persistLocked() error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// Ensure dir
|
|
||||||
if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// Write atomically
|
|
||||||
tmp := s.path + ".tmp"
|
tmp := s.path + ".tmp"
|
||||||
if err := os.WriteFile(tmp, b, 0o644); err != nil {
|
if err := os.WriteFile(tmp, b, 0o644); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -181,6 +235,18 @@ func (s *store) persistLocked() error {
|
|||||||
return os.Rename(tmp, s.path)
|
return os.Rename(tmp, s.path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *store) ensureProduct(name string) *ProductManifest {
|
||||||
|
p, ok := s.manifest.Products[name]
|
||||||
|
if !ok {
|
||||||
|
p = &ProductManifest{Product: name, DefaultChannel: "stable", Releases: make(map[string]map[string]map[string]map[string]map[string]Release)}
|
||||||
|
s.manifest.Products[name] = p
|
||||||
|
if s.manifest.DefaultProduct == "" {
|
||||||
|
s.manifest.DefaultProduct = name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
func (s *store) setLatest(pr publishRequest) error {
|
func (s *store) setLatest(pr publishRequest) error {
|
||||||
if err := validateTuple(pr.Branch, pr.Channel, pr.Arch, pr.Bit, pr.OS); err != nil {
|
if err := validateTuple(pr.Branch, pr.Channel, pr.Arch, pr.Bit, pr.OS); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -188,13 +254,22 @@ func (s *store) setLatest(pr publishRequest) error {
|
|||||||
if err := validateRelease(pr.Release); err != nil {
|
if err := validateRelease(pr.Release); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
product := strings.TrimSpace(pr.Product)
|
||||||
|
if product == "" {
|
||||||
|
product = s.manifest.DefaultProduct
|
||||||
|
}
|
||||||
|
if product == "" {
|
||||||
|
return errors.New("product required (none configured as default)")
|
||||||
|
}
|
||||||
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
pm := s.ensureProduct(product)
|
||||||
// Create levels if missing
|
// Create levels if missing
|
||||||
lvl1, ok := s.manifest.Releases[pr.Branch]
|
lvl1, ok := pm.Releases[pr.Branch]
|
||||||
if !ok {
|
if !ok {
|
||||||
lvl1 = make(map[string]map[string]map[string]map[string]Release)
|
lvl1 = make(map[string]map[string]map[string]map[string]Release)
|
||||||
s.manifest.Releases[pr.Branch] = lvl1
|
pm.Releases[pr.Branch] = lvl1
|
||||||
}
|
}
|
||||||
lvlCh, ok := lvl1[pr.Channel]
|
lvlCh, ok := lvl1[pr.Channel]
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -217,13 +292,20 @@ func (s *store) setLatest(pr publishRequest) error {
|
|||||||
return s.persistLocked()
|
return s.persistLocked()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) getLatest(branch, channel, arch, bit, osname string) (Release, bool) {
|
func (s *store) getLatest(product, branch, channel, arch, bit, osname string) (Release, bool) {
|
||||||
if err := validateTuple(branch, channel, arch, bit, osname); err != nil {
|
if err := validateTuple(branch, channel, arch, bit, osname); err != nil {
|
||||||
return Release{}, false
|
return Release{}, false
|
||||||
}
|
}
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
lvl1, ok := s.manifest.Releases[branch]
|
if product == "" {
|
||||||
|
product = s.manifest.DefaultProduct
|
||||||
|
}
|
||||||
|
pm, ok := s.manifest.Products[product]
|
||||||
|
if !ok {
|
||||||
|
return Release{}, false
|
||||||
|
}
|
||||||
|
lvl1, ok := pm.Releases[branch]
|
||||||
if !ok {
|
if !ok {
|
||||||
return Release{}, false
|
return Release{}, false
|
||||||
}
|
}
|
||||||
@@ -243,17 +325,63 @@ func (s *store) getLatest(branch, channel, arch, bit, osname string) (Release, b
|
|||||||
return rel, ok
|
return rel, ok
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) branches() []string {
|
func (s *store) branches(product string) []string {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
out := make([]string, 0, len(s.manifest.Releases))
|
if product == "" {
|
||||||
for k := range s.manifest.Releases {
|
product = s.manifest.DefaultProduct
|
||||||
|
}
|
||||||
|
pm, ok := s.manifest.Products[product]
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(pm.Releases))
|
||||||
|
for k := range pm.Releases {
|
||||||
out = append(out, k)
|
out = append(out, k)
|
||||||
}
|
}
|
||||||
sort.Strings(out)
|
sort.Strings(out)
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *store) listProducts() (names []string, def string) {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
def = s.manifest.DefaultProduct
|
||||||
|
for k := range s.manifest.Products {
|
||||||
|
names = append(names, k)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) createOrUpdateProduct(name, defBranch, defChannel string) error {
|
||||||
|
if strings.TrimSpace(name) == "" {
|
||||||
|
return errors.New("product name required")
|
||||||
|
}
|
||||||
|
if defChannel != "" {
|
||||||
|
if _, ok := allowedChannels[defChannel]; !ok {
|
||||||
|
return fmt.Errorf("invalid default_channel: %s", defChannel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
pm, ok := s.manifest.Products[name]
|
||||||
|
if !ok {
|
||||||
|
pm = &ProductManifest{Product: name, DefaultChannel: firstNonEmpty(defChannel, "stable"), Releases: make(map[string]map[string]map[string]map[string]map[string]Release)}
|
||||||
|
s.manifest.Products[name] = pm
|
||||||
|
if s.manifest.DefaultProduct == "" {
|
||||||
|
s.manifest.DefaultProduct = name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if defBranch != "" {
|
||||||
|
pm.DefaultBranch = defBranch
|
||||||
|
}
|
||||||
|
if defChannel != "" {
|
||||||
|
pm.DefaultChannel = defChannel
|
||||||
|
}
|
||||||
|
return s.persistLocked()
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Validation -------------------------------------------------------------
|
// ---- Validation -------------------------------------------------------------
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -279,6 +407,17 @@ func validateTuple(branch, channel, arch, bit, osname string) error {
|
|||||||
if _, ok := allowedOS[osname]; !ok {
|
if _, ok := allowedOS[osname]; !ok {
|
||||||
return fmt.Errorf("invalid os: %s", osname)
|
return fmt.Errorf("invalid os: %s", osname)
|
||||||
}
|
}
|
||||||
|
// Optional cross-check
|
||||||
|
switch arch {
|
||||||
|
case "386", "armv7":
|
||||||
|
if bit != "32" {
|
||||||
|
return fmt.Errorf("invalid bit %s for arch %s; expected 32", bit, arch)
|
||||||
|
}
|
||||||
|
case "amd64", "arm64", "ppc64le":
|
||||||
|
if bit != "64" {
|
||||||
|
return fmt.Errorf("invalid bit %s for arch %s; expected 64", bit, arch)
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,7 +455,7 @@ func writeJSON(w http.ResponseWriter, r *http.Request, status int, v any) {
|
|||||||
etag := sha256.Sum256(b)
|
etag := sha256.Sum256(b)
|
||||||
etagStr := "\"" + hex.EncodeToString(etag[:]) + "\""
|
etagStr := "\"" + hex.EncodeToString(etag[:]) + "\""
|
||||||
w.Header().Set("ETag", etagStr)
|
w.Header().Set("ETag", etagStr)
|
||||||
if inm := r.Header.Get("If-None-Match"); inm != "" && strings.Contains(inm, strings.Trim(etagStr, "\"")) {
|
if etagMatches(r, etagStr) {
|
||||||
w.WriteHeader(http.StatusNotModified)
|
w.WriteHeader(http.StatusNotModified)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -324,16 +463,32 @@ func writeJSON(w http.ResponseWriter, r *http.Request, status int, v any) {
|
|||||||
_, _ = w.Write(b)
|
_, _ = w.Write(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func etagMatches(r *http.Request, etagQuoted string) bool {
|
||||||
|
in := r.Header.Get("If-None-Match")
|
||||||
|
if in == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
want := strings.Trim(etagQuoted, "\"")
|
||||||
|
for _, part := range strings.Split(in, ",") {
|
||||||
|
p := strings.TrimSpace(part)
|
||||||
|
p = strings.TrimPrefix(p, "W/")
|
||||||
|
p = strings.Trim(p, "\"")
|
||||||
|
if p == want {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func parseJSON(r *http.Request, dst any) error {
|
func parseJSON(r *http.Request, dst any) error {
|
||||||
defer r.Body.Close()
|
defer r.Body.Close()
|
||||||
lr := io.LimitReader(r.Body, 1<<20) // 1 MiB payload cap
|
lr := io.LimitReader(r.Body, 1<<20)
|
||||||
dec := json.NewDecoder(lr)
|
dec := json.NewDecoder(lr)
|
||||||
dec.DisallowUnknownFields()
|
dec.DisallowUnknownFields()
|
||||||
return dec.Decode(dst)
|
return dec.Decode(dst)
|
||||||
}
|
}
|
||||||
|
|
||||||
func cors(w http.ResponseWriter, r *http.Request) bool {
|
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("Access-Control-Allow-Origin", "*")
|
||||||
w.Header().Set("Vary", "Origin")
|
w.Header().Set("Vary", "Origin")
|
||||||
if r.Method == http.MethodOptions {
|
if r.Method == http.MethodOptions {
|
||||||
@@ -349,7 +504,7 @@ func cors(w http.ResponseWriter, r *http.Request) bool {
|
|||||||
|
|
||||||
type server struct {
|
type server struct {
|
||||||
st *store
|
st *store
|
||||||
apiToken string // optional; if set, required for POST /v1/publish & /v1/config
|
apiToken string // optional; if set, required for POST endpoints
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
func (s *server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -359,6 +514,7 @@ func (s *server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Global (legacy) endpoints operate on DefaultProduct to avoid breaking clients.
|
||||||
func (s *server) handleManifest(w http.ResponseWriter, r *http.Request) {
|
func (s *server) handleManifest(w http.ResponseWriter, r *http.Request) {
|
||||||
if cors(w, r) {
|
if cors(w, r) {
|
||||||
return
|
return
|
||||||
@@ -367,12 +523,61 @@ func (s *server) handleManifest(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
product := r.URL.Query().Get("product")
|
||||||
s.st.mu.RLock()
|
s.st.mu.RLock()
|
||||||
m := s.st.manifest
|
pm := s.getProductUnsafe(product)
|
||||||
|
// If no product resolved, return the full multi-manifest at _all route semantics here
|
||||||
|
if pm == nil {
|
||||||
|
mm := s.st.manifest
|
||||||
|
s.st.mu.RUnlock()
|
||||||
|
writeJSON(w, r, http.StatusOK, mm)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m := *pm // copy (shallow)
|
||||||
s.st.mu.RUnlock()
|
s.st.mu.RUnlock()
|
||||||
writeJSON(w, r, http.StatusOK, m)
|
writeJSON(w, r, http.StatusOK, m)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *server) handleValues(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
|
||||||
|
}
|
||||||
|
product := r.URL.Query().Get("product")
|
||||||
|
s.st.mu.RLock()
|
||||||
|
pm := s.getProductUnsafe(product)
|
||||||
|
vendor := s.st.manifest.Vendor
|
||||||
|
defProduct := s.st.manifest.DefaultProduct
|
||||||
|
s.st.mu.RUnlock()
|
||||||
|
archs := keysOf(allowedArch)
|
||||||
|
bits := keysOf(allowedBit)
|
||||||
|
oss := keysOf(allowedOS)
|
||||||
|
chs := keysOf(allowedChannels)
|
||||||
|
resp := map[string]any{"arch": archs, "bit": bits, "os": oss, "channels": chs, "meta": map[string]string{"vendor": vendor}}
|
||||||
|
if pm != nil {
|
||||||
|
resp["defaults"] = map[string]string{"branch": pm.DefaultBranch, "channel": pm.DefaultChannel}
|
||||||
|
resp["product"] = pm.Product
|
||||||
|
} else {
|
||||||
|
resp["defaults"] = map[string]string{"branch": "", "channel": "stable"}
|
||||||
|
resp["product"] = defProduct
|
||||||
|
}
|
||||||
|
writeJSON(w, r, http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) getProductUnsafe(name string) *ProductManifest {
|
||||||
|
if strings.TrimSpace(name) == "" {
|
||||||
|
name = s.st.manifest.DefaultProduct
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
pm := s.st.manifest.Products[name]
|
||||||
|
return pm
|
||||||
|
}
|
||||||
|
|
||||||
func (s *server) handleBranches(w http.ResponseWriter, r *http.Request) {
|
func (s *server) handleBranches(w http.ResponseWriter, r *http.Request) {
|
||||||
if cors(w, r) {
|
if cors(w, r) {
|
||||||
return
|
return
|
||||||
@@ -381,7 +586,8 @@ func (s *server) handleBranches(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, r, http.StatusOK, map[string]any{"branches": s.st.branches()})
|
product := r.URL.Query().Get("product")
|
||||||
|
writeJSON(w, r, http.StatusOK, map[string]any{"branches": s.st.branches(product)})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *server) handleChannels(w http.ResponseWriter, r *http.Request) {
|
func (s *server) handleChannels(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -392,39 +598,17 @@ func (s *server) handleChannels(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
keys := make([]string, 0, len(allowedChannels))
|
keys := keysOfMap(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()
|
s.st.mu.RLock()
|
||||||
defBr, defCh := s.st.manifest.DefaultBranch, s.st.manifest.DefaultChannel
|
defCh := "stable"
|
||||||
vendor, product := s.st.manifest.Vendor, s.st.manifest.Product
|
if pm := s.getProductUnsafe(""); pm != nil {
|
||||||
|
defCh = pm.DefaultChannel
|
||||||
|
}
|
||||||
s.st.mu.RUnlock()
|
s.st.mu.RUnlock()
|
||||||
writeJSON(w, r, http.StatusOK, map[string]any{
|
writeJSON(w, r, http.StatusOK, map[string]any{"channels": keys, "default": defCh})
|
||||||
"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 {
|
func keysOfMap(m map[string]struct{}) []string {
|
||||||
out := make([]string, 0, len(m))
|
out := make([]string, 0, len(m))
|
||||||
for k := range m {
|
for k := range m {
|
||||||
out = append(out, k)
|
out = append(out, k)
|
||||||
@@ -442,25 +626,40 @@ func (s *server) handleLatest(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
q := r.URL.Query()
|
q := r.URL.Query()
|
||||||
branch := firstNonEmpty(q.Get("branch"), s.st.manifest.DefaultBranch)
|
product := q.Get("product")
|
||||||
channel := firstNonEmpty(q.Get("channel"), s.st.manifest.DefaultChannel)
|
branch := q.Get("branch")
|
||||||
|
channel := q.Get("channel")
|
||||||
arch := q.Get("arch")
|
arch := q.Get("arch")
|
||||||
bit := q.Get("bit")
|
bit := q.Get("bit")
|
||||||
osname := q.Get("os")
|
osname := q.Get("os")
|
||||||
if branch == "" || channel == "" || arch == "" || bit == "" || osname == "" {
|
|
||||||
http.Error(w, "missing query params: branch, channel, arch, bit, os", http.StatusBadRequest)
|
// Fill defaults from product
|
||||||
|
s.st.mu.RLock()
|
||||||
|
pm := s.getProductUnsafe(product)
|
||||||
|
s.st.mu.RUnlock()
|
||||||
|
if pm != nil {
|
||||||
|
if branch == "" {
|
||||||
|
branch = pm.DefaultBranch
|
||||||
|
}
|
||||||
|
if channel == "" {
|
||||||
|
channel = pm.DefaultChannel
|
||||||
|
}
|
||||||
|
product = pm.Product
|
||||||
|
}
|
||||||
|
if product == "" || branch == "" || channel == "" || arch == "" || bit == "" || osname == "" {
|
||||||
|
http.Error(w, "missing query params: product, branch, channel, arch, bit, os (or configure defaults)", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rel, ok := s.st.getLatest(branch, channel, arch, bit, osname)
|
rel, ok := s.st.getLatest(product, branch, channel, arch, bit, osname)
|
||||||
if !ok {
|
if !ok {
|
||||||
http.Error(w, "not found", http.StatusNotFound)
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, r, http.StatusOK, latestResponse{Branch: branch, Channel: channel, Arch: arch, Bit: bit, OS: osname, Release: rel})
|
writeJSON(w, r, http.StatusOK, latestResponse{Product: product, Branch: branch, Channel: channel, Arch: arch, Bit: bit, OS: osname, Release: rel})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *server) handleLatestPath(w http.ResponseWriter, r *http.Request) {
|
func (s *server) handleLatestPath(w http.ResponseWriter, r *http.Request) {
|
||||||
// /v1/latest/{branch}/{channel}/{arch}/{bit}/{os}
|
// /v1/latest/{product}/{branch}/{channel}/{arch}/{bit}/{os}
|
||||||
if cors(w, r) {
|
if cors(w, r) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -469,17 +668,17 @@ func (s *server) handleLatestPath(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/v1/latest/"), "/")
|
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/v1/latest/"), "/")
|
||||||
if len(parts) != 5 {
|
if len(parts) != 6 {
|
||||||
http.Error(w, "expected /v1/latest/{branch}/{channel}/{arch}/{bit}/{os}", http.StatusBadRequest)
|
http.Error(w, "expected /v1/latest/{product}/{branch}/{channel}/{arch}/{bit}/{os}", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
branch, channel, arch, bit, osname := parts[0], parts[1], parts[2], parts[3], parts[4]
|
product, branch, channel, arch, bit, osname := parts[0], parts[1], parts[2], parts[3], parts[4], parts[5]
|
||||||
rel, ok := s.st.getLatest(branch, channel, arch, bit, osname)
|
rel, ok := s.st.getLatest(product, branch, channel, arch, bit, osname)
|
||||||
if !ok {
|
if !ok {
|
||||||
http.Error(w, "not found", http.StatusNotFound)
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, r, http.StatusOK, latestResponse{Branch: branch, Channel: channel, Arch: arch, Bit: bit, OS: osname, Release: rel})
|
writeJSON(w, r, http.StatusOK, latestResponse{Product: product, Branch: branch, Channel: channel, Arch: arch, Bit: bit, OS: osname, Release: rel})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *server) handlePublish(w http.ResponseWriter, r *http.Request) {
|
func (s *server) handlePublish(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -491,7 +690,6 @@ func (s *server) handlePublish(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Auth (if token configured)
|
|
||||||
if s.apiToken != "" {
|
if s.apiToken != "" {
|
||||||
auth := r.Header.Get("Authorization")
|
auth := r.Header.Get("Authorization")
|
||||||
if !strings.HasPrefix(auth, "Bearer ") || strings.TrimPrefix(auth, "Bearer ") != s.apiToken {
|
if !strings.HasPrefix(auth, "Bearer ") || strings.TrimPrefix(auth, "Bearer ") != s.apiToken {
|
||||||
@@ -504,6 +702,10 @@ func (s *server) handlePublish(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, fmt.Sprintf("invalid json: %v", err), http.StatusBadRequest)
|
http.Error(w, fmt.Sprintf("invalid json: %v", err), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// product may also be supplied via query
|
||||||
|
if pr.Product == "" {
|
||||||
|
pr.Product = r.URL.Query().Get("product")
|
||||||
|
}
|
||||||
if err := s.st.setLatest(pr); err != nil {
|
if err := s.st.setLatest(pr); err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
@@ -511,7 +713,8 @@ func (s *server) handlePublish(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, r, http.StatusOK, map[string]string{"status": "ok"})
|
writeJSON(w, r, http.StatusOK, map[string]string{"status": "ok"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleConfig allows updating vendor/product/defaults (token required if set)
|
// handleConfig allows updating vendor/product defaults. If ?product= is present, it
|
||||||
|
// updates that product; otherwise, updates vendor-level defaults (DefaultProduct).
|
||||||
func (s *server) handleConfig(w http.ResponseWriter, r *http.Request) {
|
func (s *server) handleConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method == http.MethodOptions {
|
if r.Method == http.MethodOptions {
|
||||||
cors(w, r)
|
cors(w, r)
|
||||||
@@ -528,9 +731,10 @@ func (s *server) handleConfig(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
product := r.URL.Query().Get("product")
|
||||||
|
if product != "" {
|
||||||
var req struct {
|
var req struct {
|
||||||
Vendor string `json:"vendor"`
|
|
||||||
Product string `json:"product"`
|
|
||||||
DefaultBranch string `json:"default_branch"`
|
DefaultBranch string `json:"default_branch"`
|
||||||
DefaultChannel string `json:"default_channel"`
|
DefaultChannel string `json:"default_channel"`
|
||||||
}
|
}
|
||||||
@@ -545,17 +749,12 @@ func (s *server) handleConfig(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
s.st.mu.Lock()
|
s.st.mu.Lock()
|
||||||
if req.Vendor != "" {
|
pm := s.st.ensureProduct(product)
|
||||||
s.st.manifest.Vendor = req.Vendor
|
|
||||||
}
|
|
||||||
if req.Product != "" {
|
|
||||||
s.st.manifest.Product = req.Product
|
|
||||||
}
|
|
||||||
if req.DefaultBranch != "" {
|
if req.DefaultBranch != "" {
|
||||||
s.st.manifest.DefaultBranch = req.DefaultBranch
|
pm.DefaultBranch = req.DefaultBranch
|
||||||
}
|
}
|
||||||
if req.DefaultChannel != "" {
|
if req.DefaultChannel != "" {
|
||||||
s.st.manifest.DefaultChannel = req.DefaultChannel
|
pm.DefaultChannel = req.DefaultChannel
|
||||||
}
|
}
|
||||||
if err := s.st.persistLocked(); err != nil {
|
if err := s.st.persistLocked(); err != nil {
|
||||||
s.st.mu.Unlock()
|
s.st.mu.Unlock()
|
||||||
@@ -564,6 +763,66 @@ func (s *server) handleConfig(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
s.st.mu.Unlock()
|
s.st.mu.Unlock()
|
||||||
writeJSON(w, r, http.StatusOK, map[string]string{"status": "ok"})
|
writeJSON(w, r, http.StatusOK, map[string]string{"status": "ok"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Vendor/global
|
||||||
|
var req struct{ Vendor, DefaultProduct string }
|
||||||
|
if err := parseJSON(r, &req); err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("invalid json: %v", err), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.st.mu.Lock()
|
||||||
|
if strings.TrimSpace(req.Vendor) != "" {
|
||||||
|
s.st.manifest.Vendor = req.Vendor
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.DefaultProduct) != "" {
|
||||||
|
if _, ok := s.st.manifest.Products[req.DefaultProduct]; ok {
|
||||||
|
s.st.manifest.DefaultProduct = req.DefaultProduct
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Products management
|
||||||
|
func (s *server) handleProducts(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if cors(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
names, def := s.st.listProducts()
|
||||||
|
writeJSON(w, r, http.StatusOK, map[string]any{"products": names, "default": def})
|
||||||
|
case http.MethodPost:
|
||||||
|
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 {
|
||||||
|
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 err := s.st.createOrUpdateProduct(req.Product, req.DefaultBranch, req.DefaultChannel); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, r, http.StatusOK, map[string]string{"status": "ok"})
|
||||||
|
default:
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Admin UI ---------------------------------------------------------------
|
// ---- Admin UI ---------------------------------------------------------------
|
||||||
@@ -578,6 +837,13 @@ func (s *server) handleAdmin(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "admin.html not embedded; ensure //go:embed admin.html and file exists: "+err.Error(), http.StatusInternalServerError)
|
http.Error(w, "admin.html not embedded; ensure //go:embed admin.html and file exists: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
sum := sha256.Sum256(b)
|
||||||
|
etag := "\"" + hex.EncodeToString(sum[:]) + "\""
|
||||||
|
w.Header().Set("ETag", etag)
|
||||||
|
if etagMatches(r, etag) {
|
||||||
|
w.WriteHeader(http.StatusNotModified)
|
||||||
|
return
|
||||||
|
}
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
_, _ = w.Write(b)
|
_, _ = w.Write(b)
|
||||||
}
|
}
|
||||||
@@ -588,30 +854,34 @@ func main() {
|
|||||||
addr := envOr("HTTP_PUBLIC", ":8080")
|
addr := envOr("HTTP_PUBLIC", ":8080")
|
||||||
manifestPath := envOr("MANIFEST_PATH", "/data/manifest.json")
|
manifestPath := envOr("MANIFEST_PATH", "/data/manifest.json")
|
||||||
vendor := envOr("APP_VENDOR", "YourVendor")
|
vendor := envOr("APP_VENDOR", "YourVendor")
|
||||||
product := envOr("APP_PRODUCT", "YourProduct")
|
defaultProduct := envOr("APP_PRODUCT", "YourProduct")
|
||||||
token := envOr("API_TOKEN", "") // optional; if set, required for POST
|
token := envOr("API_TOKEN", "") // optional; if set, required for POST
|
||||||
|
|
||||||
st := newStore(manifestPath, vendor, product)
|
st := newStore(manifestPath, vendor, defaultProduct)
|
||||||
if err := st.loadIfExists(); err != nil {
|
if err := st.loadIfExists(); err != nil {
|
||||||
log.Fatalf("load manifest: %v", err)
|
log.Fatalf("load manifest: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
srv := &server{st: st, apiToken: token}
|
srv := &server{st: st, apiToken: token}
|
||||||
|
|
||||||
|
//Default Route
|
||||||
|
http.Handle("/", http.RedirectHandler("/v1/manifest", http.StatusSeeOther))
|
||||||
|
|
||||||
http.HandleFunc("/healthz", srv.handleHealth)
|
http.HandleFunc("/healthz", srv.handleHealth)
|
||||||
http.HandleFunc("/admin", srv.handleAdmin)
|
http.HandleFunc("/admin", srv.handleAdmin)
|
||||||
|
|
||||||
// Data API
|
// Product management & global (legacy-compatible) API
|
||||||
http.HandleFunc("/v1/manifest", srv.handleManifest)
|
http.HandleFunc("/v1/products", srv.handleProducts)
|
||||||
|
http.HandleFunc("/v1/manifest", srv.handleManifest) // returns default product manifest or multi when no default
|
||||||
http.HandleFunc("/v1/values", srv.handleValues)
|
http.HandleFunc("/v1/values", srv.handleValues)
|
||||||
http.HandleFunc("/v1/branches", srv.handleBranches)
|
http.HandleFunc("/v1/branches", srv.handleBranches)
|
||||||
http.HandleFunc("/v1/channels", srv.handleChannels)
|
http.HandleFunc("/v1/channels", srv.handleChannels)
|
||||||
http.HandleFunc("/v1/latest", srv.handleLatest)
|
http.HandleFunc("/v1/latest", srv.handleLatest)
|
||||||
http.HandleFunc("/v1/latest/", srv.handleLatestPath)
|
// Path variant now includes product as first segment
|
||||||
http.HandleFunc("/v1/publish", srv.handlePublish)
|
http.HandleFunc("/v1/latest/", srv.handleLatestPath) // /v1/latest/{product}/{branch}/{channel}/{arch}/{bit}/{os}
|
||||||
http.HandleFunc("/v1/config", srv.handleConfig)
|
http.HandleFunc("/v1/publish", srv.handlePublish) // accepts product in JSON or ?product=
|
||||||
|
http.HandleFunc("/v1/config", srv.handleConfig) // vendor or ?product=
|
||||||
|
|
||||||
fmt.Println(addr, manifestPath, vendor, product, token)
|
|
||||||
log.Printf("agent listening on %s (admin UI at /admin)", addr)
|
log.Printf("agent listening on %s (admin UI at /admin)", addr)
|
||||||
log.Fatal(http.ListenAndServe(addr, nil))
|
log.Fatal(http.ListenAndServe(addr, nil))
|
||||||
}
|
}
|
||||||
@@ -633,3 +903,12 @@ func firstNonEmpty(vals ...string) string {
|
|||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user