реализовал вэбку обновления ПО

This commit is contained in:
kirillius 2025-10-04 22:46:12 +03:00
parent 95d09e3c79
commit adbd4ea6d4
8 changed files with 221 additions and 46 deletions

View File

@ -33,7 +33,6 @@ public class App implements Context, Closeable {
static { static {
SystemLogger.initializeLogging(Level.INFO, List.of(InMemoryLogHandler.class)); SystemLogger.initializeLogging(Level.INFO, List.of(InMemoryLogHandler.class));
SystemLogger.setExceptionDumping(true);
} }
private final AtomicBoolean shouldRestart = new AtomicBoolean(false); private final AtomicBoolean shouldRestart = new AtomicBoolean(false);
@ -71,8 +70,6 @@ public class App implements Context, Closeable {
private ServiceManager loadServiceManager() { private ServiceManager loadServiceManager() {
var manager = new ServiceManager(this, List.of(AuthManager.class, ComponentHandlerService.class, TokenService.class, AppUpdateService.class, BGPInfoService.class, NetworkingService.class, SubscriptionService.class, ResourceUpdateService.class, WebService.class)); var manager = new ServiceManager(this, List.of(AuthManager.class, ComponentHandlerService.class, TokenService.class, AppUpdateService.class, BGPInfoService.class, NetworkingService.class, SubscriptionService.class, ResourceUpdateService.class, WebService.class));
manager.getService(BGPInfoService.class).setProvider(new HEInfoProvider()); manager.getService(BGPInfoService.class).setProvider(new HEInfoProvider());
manager.getService(ResourceUpdateService.class).start();
manager.getService(ComponentHandlerService.class).syncComponentsWithConfig();
return manager; return manager;
} }
@ -93,9 +90,16 @@ public class App implements Context, Closeable {
public App(LauncherConfig launcherConfig) { public App(LauncherConfig launcherConfig) {
this.launcherConfig = launcherConfig; this.launcherConfig = launcherConfig;
config = loadConfig(); config = loadConfig();
if (config.isDisplayDebuggingInfo()) {
SystemLogger.setExceptionDumping(true);
}
serviceManager = loadServiceManager(); serviceManager = loadServiceManager();
serviceManager.getService(SubscriptionService.class).triggerUpdate(); serviceManager.getService(SubscriptionService.class).triggerUpdate();
checkDefaultPassword(); checkDefaultPassword();
serviceManager.getService(ComponentHandlerService.class).syncComponentsWithConfig();
serviceManager.getService(ResourceUpdateService.class).start();
} }
/** /**

View File

@ -12,6 +12,7 @@ import ru.kirillius.json.rpc.Servlet.JSONRPCServlet;
import ru.kirillius.pf.sdn.External.API.ShellExecutor; import ru.kirillius.pf.sdn.External.API.ShellExecutor;
import ru.kirillius.pf.sdn.core.AbstractComponent; import ru.kirillius.pf.sdn.core.AbstractComponent;
import ru.kirillius.pf.sdn.core.Context; import ru.kirillius.pf.sdn.core.Context;
import ru.kirillius.pf.sdn.core.Networking.NetworkResourceBundle;
import ru.kirillius.pf.sdn.core.Networking.NetworkingService; import ru.kirillius.pf.sdn.core.Networking.NetworkingService;
import ru.kirillius.pf.sdn.core.Util.IPv4Util; import ru.kirillius.pf.sdn.core.Util.IPv4Util;
import ru.kirillius.pf.sdn.web.ProtectedMethod; import ru.kirillius.pf.sdn.web.ProtectedMethod;
@ -25,21 +26,30 @@ import java.io.IOException;
*/ */
public final class OVPN extends AbstractComponent<OVPN.OVPNConfig> { public final class OVPN extends AbstractComponent<OVPN.OVPNConfig> {
private final static String CTX = OVPN.class.getSimpleName(); private final static String CTX = OVPN.class.getSimpleName();
private final EventListener<JSONRPCServlet> subscription; private final EventListener<JSONRPCServlet> rpcEvent;
private final EventListener<NetworkResourceBundle> updateEvent;
/** /**
* Registers the component with the JSON-RPC servlet or defers until it becomes available. * Registers the component with the JSON-RPC servlet or defers until it becomes available.
*/ */
public OVPN(Context context) { public OVPN(Context context) {
super(context); super(context);
var eventsHandler = context.getEventsHandler();
if (config.restartOnUpdate) {
updateEvent = eventsHandler.getNetworkManagerUpdateEvent().add(bundle -> restartSystemService());
} else {
updateEvent = null;
}
var RPC = context.getServiceManager().getService(WebService.class).getJSONRPC(); var RPC = context.getServiceManager().getService(WebService.class).getJSONRPC();
if (RPC != null) { if (RPC != null) {
RPC.addTargetInstance(OVPN.class, this); RPC.addTargetInstance(OVPN.class, this);
subscription = null; rpcEvent = null;
return; return;
} }
subscription = context.getEventsHandler().getRPCInitEvent() rpcEvent = eventsHandler.getRPCInitEvent()
.add(servlet -> servlet.addTargetInstance(OVPN.class, OVPN.this)); .add(servlet -> servlet.addTargetInstance(OVPN.class, OVPN.this)); //TODO поисследовать. Возможно событие уже не нужно
} }
/** /**
@ -78,8 +88,12 @@ public final class OVPN extends AbstractComponent<OVPN.OVPNConfig> {
*/ */
@Override @Override
public void close() { public void close() {
if (subscription != null) { var eventsHandler = context.getEventsHandler();
context.getEventsHandler().getRPCInitEvent().remove(subscription); if (rpcEvent != null) {
eventsHandler.getRPCInitEvent().remove(rpcEvent);
}
if (updateEvent != null) {
eventsHandler.getNetworkManagerUpdateEvent().remove(updateEvent);
} }
} }
@ -97,5 +111,10 @@ public final class OVPN extends AbstractComponent<OVPN.OVPNConfig> {
@Setter @Setter
@JSONProperty @JSONProperty
private volatile String restartCommand = "rc-service openvpn restart"; private volatile String restartCommand = "rc-service openvpn restart";
@Getter
@Setter
@JSONProperty
private volatile boolean restartOnUpdate = true;
} }
} }

View File

@ -5,12 +5,10 @@ import org.json.JSONObject;
import ru.kirillius.json.JSONUtility; import ru.kirillius.json.JSONUtility;
import ru.kirillius.json.rpc.Annotations.JRPCArgument; import ru.kirillius.json.rpc.Annotations.JRPCArgument;
import ru.kirillius.json.rpc.Annotations.JRPCMethod; import ru.kirillius.json.rpc.Annotations.JRPCMethod;
import ru.kirillius.pf.sdn.core.AppUpdateService; import ru.kirillius.pf.sdn.core.*;
import ru.kirillius.pf.sdn.core.Component;
import ru.kirillius.pf.sdn.core.ComponentHandlerService;
import ru.kirillius.pf.sdn.core.Context;
import ru.kirillius.pf.sdn.web.ProtectedMethod; import ru.kirillius.pf.sdn.web.ProtectedMethod;
import java.io.IOException;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/** /**
@ -25,6 +23,7 @@ public class System implements RPC {
public System(Context context) { public System(Context context) {
this.context = context; this.context = context;
} }
/** /**
* Requests an application restart. * Requests an application restart.
*/ */
@ -61,6 +60,16 @@ public class System implements RPC {
return context.getConfig().isModified(); return context.getConfig().isModified();
} }
@ProtectedMethod
@JRPCMethod
public void saveConfig() {
try {
Config.store(context.getConfig(), context.getLauncherConfig().getConfigFile());
} catch (IOException e) {
throw new RuntimeException(e);
}
}
/** /**
* Retrieves the latest version available for update. * Retrieves the latest version available for update.
*/ */
@ -85,11 +94,20 @@ public class System implements RPC {
@ProtectedMethod @ProtectedMethod
@JRPCMethod @JRPCMethod
public JSONObject getVersionInfo() { public JSONObject getVersionInfo() {
var available = context.getServiceManager().getService(AppUpdateService.class).checkVersionForUpdate(); var updateService = context.getServiceManager().getService(AppUpdateService.class);
var json = new JSONObject();
return null; json.put("available", updateService.getLatestVersion());
json.put("current", updateService.getAppVersion());
json.put("downloaded", updateService.getDownloadedVersion());
return json;
} }
@ProtectedMethod
@JRPCMethod
public void doUpdate() {
var updateService = context.getServiceManager().getService(AppUpdateService.class);
updateService.updateApp();
}
/** /**
* Returns the list of enabled component class names. * Returns the list of enabled component class names.
@ -141,7 +159,7 @@ public class System implements RPC {
/** /**
Updates the configuration of the specified component and reloads it. * Updates the configuration of the specified component and reloads it.
*/ */
@SuppressWarnings({"rawtypes", "unchecked"}) @SuppressWarnings({"rawtypes", "unchecked"})
@ProtectedMethod @ProtectedMethod

View File

@ -1,5 +1,6 @@
package ru.kirillius.pf.sdn.core; package ru.kirillius.pf.sdn.core;
import lombok.Getter;
import org.w3c.dom.Element; import org.w3c.dom.Element;
import ru.kirillius.utils.logging.SystemLogger; import ru.kirillius.utils.logging.SystemLogger;
@ -29,7 +30,11 @@ public class AppUpdateService extends AppService {
private final Path appLibraryPath; private final Path appLibraryPath;
private final Class<?> anchorClass; private final Class<?> anchorClass;
private final HttpClient httpClient; private final HttpClient httpClient;
private volatile String cachedLatestVersion; @Getter
private volatile String latestVersion;
@Getter
private volatile String downloadedVersion;
/** /**
* Creates the service bound to the provided context and initialises HTTP access helpers. * Creates the service bound to the provided context and initialises HTTP access helpers.
@ -94,19 +99,20 @@ public class AppUpdateService extends AppService {
* @return newest version string or the previously cached value when fetch fails. * @return newest version string or the previously cached value when fetch fails.
*/ */
public synchronized String checkVersionForUpdate() { public synchronized String checkVersionForUpdate() {
SystemLogger.message("Checking application version", CTX);
var latest = fetchLatestVersion(); var latest = fetchLatestVersion();
if (latest != null) { if (latest != null) {
cachedLatestVersion = latest; latestVersion = latest;
return latest; return latest;
} }
return cachedLatestVersion; return latestVersion;
} }
/** /**
* Downloads the application package corresponding to the latest known version. * Downloads the application package corresponding to the latest known version.
*/ */
public void updateApp() { public void updateApp() {
var version = cachedLatestVersion; var version = latestVersion;
if (version == null || version.isBlank()) { if (version == null || version.isBlank()) {
version = checkVersionForUpdate(); version = checkVersionForUpdate();
} }
@ -128,6 +134,11 @@ public class AppUpdateService extends AppService {
var tempFile = targetDirectory.resolve(fileName + ".download"); var tempFile = targetDirectory.resolve(fileName + ".download");
var targetFile = targetDirectory.resolve(fileName); var targetFile = targetDirectory.resolve(fileName);
if (targetFile.toFile().exists()) {
SystemLogger.error("Latest version is downloaded already", CTX);
return;
}
try { try {
Files.createDirectories(targetDirectory); Files.createDirectories(targetDirectory);
Files.deleteIfExists(tempFile); Files.deleteIfExists(tempFile);
@ -142,7 +153,8 @@ public class AppUpdateService extends AppService {
return; return;
} }
Files.move(tempFile, targetFile, StandardCopyOption.REPLACE_EXISTING); Files.move(tempFile, targetFile, StandardCopyOption.REPLACE_EXISTING);
cachedLatestVersion = version; latestVersion = version;
downloadedVersion = version;
} catch (Exception e) { } catch (Exception e) {
SystemLogger.error("Failed to download update", CTX, e); SystemLogger.error("Failed to download update", CTX, e);
} finally { } finally {

View File

@ -92,6 +92,11 @@ public class Config {
@JSONProperty @JSONProperty
private volatile boolean mergeSubnets = true; private volatile boolean mergeSubnets = true;
@Setter
@Getter
@JSONProperty
private volatile boolean displayDebuggingInfo = true;
@Setter @Setter
@Getter @Getter
@JSONProperty @JSONProperty
@ -113,8 +118,10 @@ public class Config {
public static void store(Config config, File file) throws IOException { public static void store(Config config, File file) throws IOException {
try (var fileInputStream = new FileOutputStream(file)) { try (var fileInputStream = new FileOutputStream(file)) {
try (var writer = new BufferedWriter(new OutputStreamWriter(fileInputStream))) { try (var writer = new BufferedWriter(new OutputStreamWriter(fileInputStream))) {
writer.write(serialize(config).toString()); var json = serialize(config);
writer.write(json.toString());
writer.flush(); writer.flush();
config.initialJSON = json;
} }
} }
} }
@ -152,6 +159,6 @@ public class Config {
* Indicates whether the in-memory configuration diverges from the initially loaded snapshot. * Indicates whether the in-memory configuration diverges from the initially loaded snapshot.
*/ */
public boolean isModified() { public boolean isModified() {
return !initialJSON.equals(serialize(this)); return !initialJSON.toString().equals(serialize(this).toString());
} }
} }

View File

@ -56,12 +56,17 @@ public class ResourceUpdateService extends AppService {
@SneakyThrows @SneakyThrows
@Override @Override
public void run() { public void run() {
var updateService = context.getServiceManager().getService(AppUpdateService.class);
var uptime = 0L; var uptime = 0L;
var config = context.getConfig(); var config = context.getConfig();
updateService.checkVersionForUpdate();
while (!Thread.currentThread().isInterrupted()) { while (!Thread.currentThread().isInterrupted()) {
Thread.sleep(Duration.ofMinutes(1)); Thread.sleep(Duration.ofMinutes(1));
uptime++; uptime++;
if (uptime % 15 == 0) {
updateService.checkVersionForUpdate();
}
if (config.getUpdateSubscriptionsInterval() > 0 && uptime % (config.getUpdateSubscriptionsInterval() * 60L) == 0) { if (config.getUpdateSubscriptionsInterval() > 0 && uptime % (config.getUpdateSubscriptionsInterval() * 60L) == 0) {
SystemLogger.message("Updating subscriptions", CTX); SystemLogger.message("Updating subscriptions", CTX);

View File

@ -17,6 +17,6 @@ public final class CommandLineUtils {
if (first.isEmpty()) { if (first.isEmpty()) {
throw new IllegalArgumentException("Missing required argument: -" + argname); throw new IllegalArgumentException("Missing required argument: -" + argname);
} }
return first.get(); return first.get().substring(argname.length() + 2);
} }
} }

View File

@ -6,18 +6,27 @@ let updateInterval = null;
const $content = () => $('#statistics-content'); const $content = () => $('#statistics-content');
const createStatusCard = (title, status, buttonId, buttonLabel, isUpdating, resourceStatsHtml = '') => { const createStatusCard = (title, status, buttons = [], resourceStatsHtml = '', customStatusContent = null) => {
const statusClass = status === 'Обновляется' || status === 'Обнаружено' ? 'text-yellow-500' : 'text-green-500'; const warningStatuses = ['Обновляется', 'Обнаружено', 'Готово к установке'];
const statusClass = warningStatuses.includes(status) ? 'text-yellow-500' : 'text-green-500';
const statusSection = customStatusContent || `
<p class="status-line">
Статус: <span class="${statusClass}">${status}</span>
</p>
`;
const buttonsHtml = buttons.length
? `<div class="button-group">${buttons.map(({ id, label, disabled, isLoading = false, loadingLabel = 'Обновление...' }) => `
<button id="${id}" class="btn-secondary" ${disabled || isLoading ? 'disabled' : ''}>
${isLoading ? loadingLabel : label}
</button>
`).join('')}</div>`
: '';
return ` return `
<div class="stat-card"> <div class="stat-card">
<h3>${title}</h3> <h3>${title}</h3>
<p class="status-line"> ${statusSection}
Статус: <span class="${statusClass}">${status}</span> ${buttonsHtml}
</p>
<button id="${buttonId}" class="btn-secondary" ${isUpdating ? 'disabled' : ''}>
${isUpdating ? 'Обновление...' : buttonLabel}
</button>
${resourceStatsHtml} ${resourceStatsHtml}
</div> </div>
`; `;
@ -72,7 +81,32 @@ async function getNetworkResources() {
async function renderSystemAndManagerStatus() { async function renderSystemAndManagerStatus() {
const dataHtml = []; const dataHtml = [];
const hasUpdates = await JSONRPC.System.hasUpdates(); const versionInfo = await JSONRPC.System.getVersionInfo();
const availableVersion = (versionInfo && versionInfo.available) || '';
const currentVersion = (versionInfo && versionInfo.current) || '';
const downloadedVersion = (versionInfo && versionInfo.downloaded) || '';
const isConfigChanged = await JSONRPC.System.isConfigChanged();
const hasAvailableUpdate = availableVersion && availableVersion !== currentVersion;
const isDownloaded = hasAvailableUpdate && downloadedVersion === availableVersion;
const updateButtonDisabled = !hasAvailableUpdate || isDownloaded;
const systemStatus = (() => {
if (!hasAvailableUpdate) {
return 'Нет обновлений';
}
if (isDownloaded) {
return 'Готово к установке';
}
return 'Обнаружено';
})();
const versionStatusHtml = `
<div class="status-line version-line">
<span>Текущая версия: <span class="${hasAvailableUpdate ? 'text-yellow-500' : 'text-green-500'}">${currentVersion || '-'}</span></span><br />
<span class="last-version">Последняя версия: <span class="${hasAvailableUpdate ? 'text-yellow-500' : 'text-green-500'}">${availableVersion || '-'}</span></span>
</div>
<p class="status-line ${isConfigChanged ? 'text-yellow-500' : 'text-green-500'}">
Конфигурация: ${isConfigChanged ? 'изменена' : 'актуальна'}
</p>
`;
const isSubUpdating = await JSONRPC.SubscriptionManager.isUpdating(); const isSubUpdating = await JSONRPC.SubscriptionManager.isUpdating();
const isNetUpdating = await JSONRPC.NetworkManager.isUpdating(); const isNetUpdating = await JSONRPC.NetworkManager.isUpdating();
const subCount = await getSubscriptionCount(); const subCount = await getSubscriptionCount();
@ -80,10 +114,27 @@ async function renderSystemAndManagerStatus() {
dataHtml.push(createStatusCard( dataHtml.push(createStatusCard(
'Обновление ПО', 'Обновление ПО',
hasUpdates ? 'Обнаружено' : 'Нет обновлений', systemStatus,
'update-software-btn', [
'Проверить', {
false id: 'update-software-btn',
label: 'Обновить',
disabled: updateButtonDisabled
},
{
id: 'restart-system-btn',
label: 'Перезагрузить',
loadingLabel: 'Перезагрузка...'
},
{
id: 'save-config-btn',
label: 'Сохранить',
disabled: !isConfigChanged,
loadingLabel: 'Сохранение...'
}
],
'',
versionStatusHtml
)); ));
const subStatsHtml = `<div class="resource-group" style="margin-top: 20px;"> const subStatsHtml = `<div class="resource-group" style="margin-top: 20px;">
@ -95,9 +146,14 @@ async function renderSystemAndManagerStatus() {
dataHtml.push(createStatusCard( dataHtml.push(createStatusCard(
'Менеджер Подписок', 'Менеджер Подписок',
isSubUpdating ? 'Обновляется' : 'Активен', isSubUpdating ? 'Обновляется' : 'Активен',
'update-sub-btn', [
'Обновить', {
isSubUpdating, id: 'update-sub-btn',
label: 'Обновить',
isLoading: isSubUpdating,
loadingLabel: 'Обновление...'
}
],
subStatsHtml subStatsHtml
)); ));
@ -105,9 +161,14 @@ async function renderSystemAndManagerStatus() {
dataHtml.push(createStatusCard( dataHtml.push(createStatusCard(
'Менеджер Сетей', 'Менеджер Сетей',
isNetUpdating ? 'Обновляется' : 'Активен', isNetUpdating ? 'Обновляется' : 'Активен',
'update-net-btn', [
'Обновить', {
isNetUpdating, id: 'update-net-btn',
label: 'Обновить',
isLoading: isNetUpdating,
loadingLabel: 'Обновление...'
}
],
netResourcesHtml netResourcesHtml
)); ));
@ -153,8 +214,57 @@ function attachEventHandlers() {
} }
}); });
$('#update-software-btn').off('click').on('click', function () { $('#update-software-btn').off('click').on('click', async function () {
alert('Проверка обновлений ПО запущена...'); const $btn = $(this);
if ($btn.prop('disabled')) {
return;
}
$btn.prop('disabled', true).text('Обновление...');
try {
await JSONRPC.System.doUpdate();
alert('Обновление ПО запущено!');
await renderSystemAndManagerStatus();
} catch (e) {
alert('Ошибка при запуске обновления ПО!');
$btn.prop('disabled', false).text('Обновить');
}
});
$('#restart-system-btn').off('click').on('click', async function () {
const $btn = $(this);
$btn.prop('disabled', true).text('Перезагрузка...');
try {
await JSONRPC.System.restart();
alert('Перезагрузка системы инициирована!');
setTimeout(() => {
window.location.reload();
}, 10000);
} catch (e) {
alert('Ошибка при перезагрузке системы!');
$btn.prop('disabled', false).text('Перезагрузить');
}
});
$('#save-config-btn').off('click').on('click', async function () {
const $btn = $(this);
if ($btn.prop('disabled')) {
return;
}
$btn.prop('disabled', true).text('Сохранение...');
try {
await JSONRPC.System.saveConfig();
alert('Конфигурация сохранена!');
await renderSystemAndManagerStatus();
} catch (e) {
alert('Ошибка при сохранении конфигурации!');
$btn.prop('disabled', false).text('Сохранить');
}
}); });
} }