Skip to content

Commit 80de69c

Browse files
author
Paul C
committed
feat: plugin store + enterprise license propagation across cluster
Plugin Store: - GET /api/plugins/store fetches plugin index from wolfscale.org/pkg/index.json - Plugin install modal now shows browsable store with Install buttons - Each plugin shows name, description, version, installed status - "Install from URL" still available as collapsible fallback - Enterprise license required to access the store License Propagation: - Enterprise license key included in cluster StatusReport messages - When a node receives a valid license from another cluster node and doesn't have one locally, it saves it automatically - Enables single-license deployment: install license on one node, all cluster nodes pick it up within 10 seconds
1 parent 1bd31b1 commit 80de69c

6 files changed

Lines changed: 139 additions & 14 deletions

File tree

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "wolfstack"
3-
version = "16.16.4"
3+
version = "16.16.5"
44
edition = "2024"
55
authors = ["Wolf Software Systems Ltd"]
66
description = "Server management platform for the Wolf software suite"

src/agent/mod.rs

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -580,6 +580,9 @@ pub enum AgentMessage {
580580
has_lxc: bool,
581581
#[serde(default)]
582582
has_kvm: bool,
583+
/// Enterprise license key — propagated to cluster nodes that don't have one
584+
#[serde(default)]
585+
license_key: Option<String>,
583586
},
584587
/// "Give me your status"
585588
StatusRequest,
@@ -742,7 +745,7 @@ pub async fn poll_remote_nodes(cluster: Arc<ClusterState>, cluster_secret: Strin
742745
{
743746
Ok(resp) => {
744747
if let Ok(msg) = resp.json::<AgentMessage>().await {
745-
if let AgentMessage::StatusReport { node_id: _, hostname, metrics, components, docker_count, lxc_count, vm_count, public_ip, known_nodes, deleted_ids, wolfnet_ips, has_docker, has_lxc, has_kvm } = msg {
748+
if let AgentMessage::StatusReport { node_id: _, hostname, metrics, components, docker_count, lxc_count, vm_count, public_ip, known_nodes, deleted_ids, wolfnet_ips, has_docker, has_lxc, has_kvm, license_key } = msg {
746749
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();
747750
// Detect TLS: HTTP on port+1 means TLS is on the main port,
748751
// HTTPS on main port also means TLS. Only plain HTTP on the
@@ -780,6 +783,22 @@ pub async fn poll_remote_nodes(cluster: Arc<ClusterState>, cluster_secret: Strin
780783
// Reset fail count on success
781784
POLL_FAIL_COUNTS.lock().unwrap().remove(&node.id);
782785

786+
// Enterprise license propagation: if a remote node has a
787+
// valid license and we don't, save it locally.
788+
if let Some(ref lk) = license_key {
789+
if !lk.is_empty() && !crate::compat::platform_ready() {
790+
let dm_path = crate::compat::dm_path();
791+
if std::fs::read_to_string(&dm_path).map(|s| s.trim().is_empty()).unwrap_or(true) {
792+
if let Some(parent) = std::path::Path::new(&dm_path).parent() {
793+
let _ = std::fs::create_dir_all(parent);
794+
}
795+
if std::fs::write(&dm_path, lk).is_ok() {
796+
tracing::info!("Enterprise license received from cluster node '{}'", node.hostname);
797+
}
798+
}
799+
}
800+
}
801+
783802
// Merge tombstones first — so we don't re-add deleted nodes
784803
cluster.merge_tombstones(&deleted_ids);
785804

src/api/mod.rs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2807,6 +2807,9 @@ pub async fn agent_status(req: HttpRequest, state: web::Data<AppState>) -> HttpR
28072807
has_docker,
28082808
has_lxc,
28092809
has_kvm,
2810+
license_key: if crate::compat::platform_ready() {
2811+
std::fs::read_to_string(crate::compat::dm_path()).ok().map(|s| s.trim().to_string())
2812+
} else { None },
28102813
};
28112814
HttpResponse::Ok().json(msg)
28122815
}
@@ -14890,6 +14893,45 @@ pub async fn plugins_reload(req: HttpRequest, state: web::Data<AppState>) -> Htt
1489014893
HttpResponse::Ok().json(serde_json::json!({ "message": "Plugins reloaded" }))
1489114894
}
1489214895

14896+
const PLUGIN_INDEX_URL: &str = "https://wolfscale.org/pkg/index.json";
14897+
14898+
/// GET /api/plugins/store — fetch available plugins from the plugin store (Enterprise only)
14899+
pub async fn plugins_store(req: HttpRequest, state: web::Data<AppState>) -> HttpResponse {
14900+
if let Err(resp) = require_auth(&req, &state) { return resp; }
14901+
if !crate::compat::platform_ready() {
14902+
return HttpResponse::Forbidden().json(serde_json::json!({
14903+
"error": "Plugin store requires an Enterprise license",
14904+
"feature": "plugins"
14905+
}));
14906+
}
14907+
// Fetch the index from the remote store
14908+
let client = reqwest::Client::builder()
14909+
.timeout(std::time::Duration::from_secs(10))
14910+
.danger_accept_invalid_certs(true)
14911+
.build()
14912+
.unwrap_or_default();
14913+
match client.get(PLUGIN_INDEX_URL).send().await {
14914+
Ok(resp) if resp.status().is_success() => {
14915+
let body = resp.text().await.unwrap_or_else(|_| "[]".to_string());
14916+
// Parse to validate, then return. Tag each plugin with installed status.
14917+
let mut plugins: Vec<serde_json::Value> = serde_json::from_str(&body).unwrap_or_default();
14918+
let installed = crate::plugins::list();
14919+
for p in &mut plugins {
14920+
let id = p["id"].as_str().unwrap_or("");
14921+
let is_installed = installed.iter().any(|i| i.manifest.id == id);
14922+
p["installed"] = serde_json::json!(is_installed);
14923+
}
14924+
HttpResponse::Ok().json(plugins)
14925+
}
14926+
Ok(resp) => HttpResponse::BadGateway().json(serde_json::json!({
14927+
"error": format!("Plugin store returned HTTP {}", resp.status())
14928+
})),
14929+
Err(e) => HttpResponse::BadGateway().json(serde_json::json!({
14930+
"error": format!("Failed to reach plugin store: {}", e)
14931+
})),
14932+
}
14933+
}
14934+
1489314935
#[derive(Deserialize)]
1489414936
pub struct PluginInstallRequest {
1489514937
pub url: String,
@@ -15944,6 +15986,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
1594415986
// Plugins
1594515987
.route("/api/plugins", web::get().to(plugins_list))
1594615988
.route("/api/plugins/reload", web::post().to(plugins_reload))
15989+
.route("/api/plugins/store", web::get().to(plugins_store))
1594715990
.route("/api/plugins/install", web::post().to(plugins_install))
1594815991
.route("/api/plugins/{id}", web::delete().to(plugins_uninstall))
1594915992
.route("/api/plugins/{id}/toggle", web::post().to(plugins_toggle))

src/main.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,10 @@ async fn main() -> std::io::Result<()> {
379379
has_docker,
380380
has_lxc,
381381
has_kvm,
382+
// Propagate license to cluster nodes
383+
license_key: if crate::compat::platform_ready() {
384+
std::fs::read_to_string(crate::compat::dm_path()).ok().map(|s| s.trim().to_string())
385+
} else { None },
382386
};
383387
if let Ok(json) = serde_json::to_value(&msg) {
384388
if let Ok(mut cache) = cached_status_bg.write() {

web/index.html

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4795,20 +4795,27 @@ <h4 style="margin:0;font-size:16px;font-weight:600;">Plugins</h4>
47954795
</div>
47964796
</div>
47974797

4798-
<!-- Install Plugin Modal -->
4798+
<!-- Plugin Store Modal -->
47994799
<div id="plugin-install-modal" class="modal-overlay" style="display:none;">
4800-
<div class="modal" style="max-width:460px;width:90%;">
4800+
<div class="modal" style="max-width:580px;width:90%;">
48014801
<div class="modal-header">
4802-
<h3>Install Plugin</h3>
4802+
<h3>Plugin Store</h3>
48034803
<button class="modal-close" onclick="document.getElementById('plugin-install-modal').style.display='none'">&times;</button>
48044804
</div>
4805-
<div class="modal-body">
4806-
<p style="font-size:13px;color:var(--text-muted);margin:0 0 12px;">Enter the URL of a plugin package (.tar.gz) to install.</p>
4807-
<input type="text" id="plugin-install-url" class="form-control" placeholder="https://example.com/my-plugin.tar.gz">
4805+
<div class="modal-body" style="max-height:400px;overflow-y:auto;">
4806+
<div id="plugin-store-list">
4807+
<div style="color:var(--text-muted);text-align:center;padding:20px;">Loading...</div>
4808+
</div>
4809+
<details style="margin-top:16px;">
4810+
<summary style="font-size:12px;color:var(--text-muted);cursor:pointer;">Install from URL</summary>
4811+
<div style="margin-top:8px;display:flex;gap:8px;">
4812+
<input type="text" id="plugin-install-url" class="form-control" placeholder="https://example.com/plugin.tar.gz" style="flex:1;">
4813+
<button class="btn btn-sm btn-primary" onclick="installPlugin()">Install</button>
4814+
</div>
4815+
</details>
48084816
</div>
48094817
<div class="modal-footer">
4810-
<button class="btn" onclick="document.getElementById('plugin-install-modal').style.display='none'">Cancel</button>
4811-
<button class="btn btn-primary" onclick="installPlugin()">Install</button>
4818+
<button class="btn" onclick="document.getElementById('plugin-install-modal').style.display='none'">Close</button>
48124819
</div>
48134820
</div>
48144821
</div>

web/js/app.js

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22311,15 +22311,67 @@ async function loadPlugins() {
2231122311
} catch (e) { list.innerHTML = '<div style="color:var(--danger);">Error: ' + escapeHtml(e.message) + '</div>'; }
2231222312
}
2231322313

22314-
function showInstallPluginModal() {
22315-
document.getElementById('plugin-install-url').value = '';
22314+
async function showInstallPluginModal() {
2231622315
const modal = document.getElementById('plugin-install-modal');
2231722316
modal.style.display = 'flex';
2231822317
setTimeout(() => modal.classList.add('active'), 10);
22318+
22319+
// Load store
22320+
const storeList = document.getElementById('plugin-store-list');
22321+
if (storeList) storeList.innerHTML = '<div style="color:var(--text-muted);text-align:center;padding:20px;">Loading plugin store...</div>';
22322+
try {
22323+
const resp = await fetch(apiUrl('/api/plugins/store'));
22324+
if (!resp.ok) {
22325+
const data = await resp.json().catch(() => ({}));
22326+
if (storeList) storeList.innerHTML = `<div style="color:var(--danger);text-align:center;padding:20px;">${escapeHtml(data.error || 'Failed to load store')}</div>`;
22327+
return;
22328+
}
22329+
const plugins = await resp.json();
22330+
if (!Array.isArray(plugins) || plugins.length === 0) {
22331+
if (storeList) storeList.innerHTML = '<div style="color:var(--text-muted);text-align:center;padding:20px;">No plugins available</div>';
22332+
return;
22333+
}
22334+
if (storeList) storeList.innerHTML = plugins.map(p => `
22335+
<div style="display:flex;align-items:center;gap:14px;padding:14px 16px;background:var(--bg-primary);border-radius:10px;border:1px solid var(--border);margin-bottom:8px;">
22336+
<span style="font-size:32px;">${p.icon || '\u{1F50C}'}</span>
22337+
<div style="flex:1;">
22338+
<div style="font-weight:600;font-size:14px;">${escapeHtml(p.name)} <span style="font-size:11px;color:var(--text-muted);font-weight:400;">v${escapeHtml(p.version || '1.0')}</span>
22339+
${p.enterprise_only ? '<span style="font-size:10px;background:rgba(220,38,38,0.15);color:#ef4444;padding:1px 6px;border-radius:3px;margin-left:6px;">Enterprise</span>' : ''}
22340+
</div>
22341+
<div style="font-size:12px;color:var(--text-secondary);margin-top:3px;line-height:1.5;">${escapeHtml(p.description || '')}</div>
22342+
<div style="font-size:11px;color:var(--text-muted);margin-top:4px;">by ${escapeHtml(p.author || 'Unknown')}</div>
22343+
</div>
22344+
<div>
22345+
${p.installed
22346+
? '<span style="font-size:12px;color:var(--success);font-weight:600;">Installed</span>'
22347+
: `<button class="btn btn-sm btn-primary" onclick="installPluginFromStore('${escapeAttr(p.download_url)}', '${escapeAttr(p.name)}')" style="font-size:12px;white-space:nowrap;">Install</button>`
22348+
}
22349+
</div>
22350+
</div>
22351+
`).join('');
22352+
} catch (e) {
22353+
if (storeList) storeList.innerHTML = `<div style="color:var(--danger);text-align:center;padding:20px;">Error: ${escapeHtml(e.message)}</div>`;
22354+
}
22355+
}
22356+
22357+
async function installPluginFromStore(url, name) {
22358+
showToast(`Installing ${name}...`, 'info');
22359+
document.getElementById('plugin-install-modal').style.display = 'none';
22360+
try {
22361+
const resp = await fetch(apiUrl('/api/plugins/install'), {
22362+
method: 'POST', headers: { 'Content-Type': 'application/json' },
22363+
body: JSON.stringify({ url }),
22364+
});
22365+
const data = await resp.json();
22366+
if (!resp.ok) { showToast(data.error || 'Install failed', 'error'); return; }
22367+
showToast(`${name} installed successfully`, 'success');
22368+
loadPlugins();
22369+
loadPluginAssets();
22370+
} catch (e) { showToast('Install failed: ' + e.message, 'error'); }
2231922371
}
2232022372

2232122373
async function installPlugin() {
22322-
const url = document.getElementById('plugin-install-url').value.trim();
22374+
const url = document.getElementById('plugin-install-url')?.value?.trim();
2232322375
if (!url) { showToast('Please enter a plugin URL', 'error'); return; }
2232422376
document.getElementById('plugin-install-modal').style.display = 'none';
2232522377
showToast('Installing plugin...', 'info');
@@ -22332,7 +22384,7 @@ async function installPlugin() {
2233222384
if (!resp.ok) { showToast(data.error || 'Install failed', 'error'); return; }
2233322385
showToast(data.message || 'Plugin installed', 'success');
2233422386
loadPlugins();
22335-
loadPluginAssets(); // Load new plugin's frontend
22387+
loadPluginAssets();
2233622388
} catch (e) { showToast('Install failed: ' + e.message, 'error'); }
2233722389
}
2233822390

0 commit comments

Comments
 (0)