commit 7d49f515c36a05189ccbe4a2fd19c8c3a40c5ae5 parent bd1e814cd34a17b2c0b4342fff077d45ed76aad9 Author: Florian Dold <dold@taler.net> Date: Tue, 8 Sep 2026 00:46:10 +0200 services: apply DD102 restart and configuration exits Diffstat:
18 files changed, 184 insertions(+), 68 deletions(-)
diff --git a/debian/libeufin-bank.libeufin-bank-gc.service b/debian/libeufin-bank.libeufin-bank-gc.service @@ -1,6 +1,6 @@ [Unit] Description=LibEuFin Bank Garbage Collection Service -After=postgres.service +After=postgresql.service PartOf=libeufin-bank.target [Service] @@ -14,4 +14,4 @@ PrivateTmp=yes PrivateDevices=yes ProtectSystem=full -Slice=libeufin-bank.slice -\ No newline at end of file +Slice=libeufin-bank.slice diff --git a/debian/libeufin-bank.libeufin-bank.service b/debian/libeufin-bank.libeufin-bank.service @@ -1,21 +1,20 @@ [Unit] +StartLimitIntervalSec=0 Description=LibEuFin Bank Server Service -After=postgres.service network.target +After=postgresql.service network.target PartOf=libeufin-bank.target [Service] +# DD102: retry transient failures without a start-rate limit. +Restart=always +RestartSec=10s User=libeufin-bank Type=exec ExecStart=/usr/bin/libeufin-bank serve -c /etc/libeufin/libeufin-bank.conf -Restart=always RestartMode=direct -RestartSec=10ms -RestartSteps=5 -RestartPreventExitStatus=9 +RestartPreventExitStatus=6 9 -StartLimitBurst=5 -StartLimitInterval=5s RuntimeMaxSec=4d diff --git a/debian/libeufin-bank.target b/debian/libeufin-bank.target @@ -1,6 +1,7 @@ [Unit] +Wants=postgresql.service Description=LibEuFin Bank -After=postgres.service network.target +After=postgresql.service network.target Wants=libeufin-bank.service diff --git a/debian/libeufin-ebisync.libeufin-ebisync-fetch.service b/debian/libeufin-ebisync.libeufin-ebisync-fetch.service @@ -1,22 +1,21 @@ [Unit] +StartLimitIntervalSec=0 Description=LibEuFin EbiSync fetch service. -After=postgres.service network.target +After=postgresql.service network.target PartOf=libeufin-ebisync.target [Service] +# DD102: retry transient failures without a start-rate limit. +Restart=always +RestartSec=10s User=libeufin-ebisync Type=exec ExecStart=/usr/bin/libeufin-ebisync fetch -c /etc/libeufin-ebisync/libeufin-ebisync.conf ExecCondition=/usr/bin/libeufin-ebisync fetch -c /etc/libeufin-ebisync/libeufin-ebisync.conf --check -Restart=always RestartMode=direct -RestartSec=10ms -RestartSteps=5 -RestartPreventExitStatus=9 +RestartPreventExitStatus=6 9 -StartLimitBurst=5 -StartLimitInterval=5s RuntimeMaxSec=4d diff --git a/debian/libeufin-ebisync.libeufin-ebisync-httpd.service b/debian/libeufin-ebisync.libeufin-ebisync-httpd.service @@ -1,22 +1,21 @@ [Unit] +StartLimitIntervalSec=0 Description=LibEuFin EbiSync Server Service -After=postgres.service network.target +After=postgresql.service network.target PartOf=libeufin-ebisync.target [Service] +# DD102: retry transient failures without a start-rate limit. +Restart=always +RestartSec=10s User=libeufin-ebisync Type=exec ExecStart=/usr/bin/libeufin-ebisync serve -c /etc/libeufin-ebisync/libeufin-ebisync.conf ExecCondition=/usr/bin/libeufin-ebisync serve -c /etc/libeufin-ebisync/libeufin-ebisync.conf --check -Restart=always RestartMode=direct -RestartSec=10ms -RestartSteps=5 -RestartPreventExitStatus=9 +RestartPreventExitStatus=6 9 -StartLimitBurst=5 -StartLimitInterval=5s RuntimeMaxSec=4d diff --git a/debian/libeufin-ebisync.target b/debian/libeufin-ebisync.target @@ -1,6 +1,7 @@ [Unit] +Wants=postgresql.service Description=LibEuFin EbiSync -After=postgres.service network.target +After=postgresql.service network.target Wants=libeufin-ebisync-fetch.service Wants=libeufin-ebisync-httpd.service diff --git a/debian/libeufin-nexus.libeufin-nexus-ebics-fetch.service b/debian/libeufin-nexus.libeufin-nexus-ebics-fetch.service @@ -1,21 +1,20 @@ [Unit] +StartLimitIntervalSec=0 Description=LibEuFin Nexus EBICS fetch service. -After=postgres.service network.target +After=postgresql.service network.target PartOf=libeufin-nexus.target [Service] +# DD102: retry transient failures without a start-rate limit. +Restart=always +RestartSec=10s User=libeufin-nexus Type=exec ExecStart=/usr/bin/libeufin-nexus ebics-fetch -c /etc/libeufin/libeufin-nexus.conf -Restart=always RestartMode=direct -RestartSec=10ms -RestartSteps=5 -RestartPreventExitStatus=9 +RestartPreventExitStatus=6 9 -StartLimitBurst=5 -StartLimitInterval=5s RuntimeMaxSec=4d diff --git a/debian/libeufin-nexus.libeufin-nexus-ebics-submit.service b/debian/libeufin-nexus.libeufin-nexus-ebics-submit.service @@ -1,21 +1,20 @@ [Unit] +StartLimitIntervalSec=0 Description=LibEuFin Nexus EBICS submit service. -After=postgres.service network.target +After=postgresql.service network.target PartOf=libeufin-nexus.target [Service] +# DD102: retry transient failures without a start-rate limit. +Restart=always +RestartSec=10s User=libeufin-nexus Type=exec ExecStart=/usr/bin/libeufin-nexus ebics-submit -c /etc/libeufin/libeufin-nexus.conf -Restart=always RestartMode=direct -RestartSec=10ms -RestartSteps=5 -RestartPreventExitStatus=9 +RestartPreventExitStatus=6 9 -StartLimitBurst=5 -StartLimitInterval=5s RuntimeMaxSec=4d diff --git a/debian/libeufin-nexus.libeufin-nexus-httpd.service b/debian/libeufin-nexus.libeufin-nexus-httpd.service @@ -1,22 +1,21 @@ [Unit] +StartLimitIntervalSec=0 Description=LibEuFin Nexus Server Service -After=postgres.service network.target +After=postgresql.service network.target PartOf=libeufin-nexus.target [Service] +# DD102: retry transient failures without a start-rate limit. +Restart=always +RestartSec=10s User=libeufin-nexus Type=exec ExecStart=/usr/bin/libeufin-nexus serve -c /etc/libeufin/libeufin-nexus.conf ExecCondition=/usr/bin/libeufin-nexus serve -c /etc/libeufin/libeufin-nexus.conf --check -Restart=always RestartMode=direct -RestartSec=10ms -RestartSteps=5 -RestartPreventExitStatus=9 +RestartPreventExitStatus=6 9 -StartLimitBurst=5 -StartLimitInterval=5s RuntimeMaxSec=4d diff --git a/debian/libeufin-nexus.target b/debian/libeufin-nexus.target @@ -1,6 +1,7 @@ [Unit] +Wants=postgresql.service Description=LibEuFin Nexus -After=postgres.service network.target +After=postgresql.service network.target Wants=libeufin-nexus-ebics-fetch.service Wants=libeufin-nexus-ebics-submit.service diff --git a/debian/rules b/debian/rules @@ -40,7 +40,7 @@ override_dh_installsystemd: dh_installsystemd -p libeufin-ebisync --no-start --no-enable --no-stop-on-upgrade --name=libeufin-ebisync-fetch dh_installsystemd -p libeufin-ebisync --no-start --no-enable --no-stop-on-upgrade --name=libeufin-ebisync-httpd # final invocation to generate daemon reload - dh_installsystemd + dh_installsystemd --no-start --no-enable --no-stop-on-upgrade get-orig-source: uscan --force-download --rename diff --git a/libeufin-bank/src/main/kotlin/tech/libeufin/bank/cli/Serve.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/cli/Serve.kt @@ -27,6 +27,7 @@ import tech.libeufin.bank.corebankWebApp import tech.libeufin.bank.logger import tech.libeufin.bank.withDb import tech.libeufin.common.TalerCmd +import tech.libeufin.common.TalerConfigError import tech.libeufin.common.api.serve import kotlin.io.path.Path import kotlin.io.path.exists @@ -41,9 +42,9 @@ class Serve: TalerCmd("serve") { logger.info("Ensure exchange account exists") val info = db.account.bankInfo("exchange") if (info == null) { - throw Exception("Exchange account missing: an exchange account named 'exchange' is required for conversion to be enabled") + throw TalerConfigError.generic("Exchange account missing: an exchange account named 'exchange' is required for conversion to be enabled") } else if (!info.isTalerExchange) { - throw Exception("Account is not an exchange: an exchange account named 'exchange' is required for conversion to be enabled") + throw TalerConfigError.generic("Account is not an exchange: an exchange account named 'exchange' is required for conversion to be enabled") } logger.info("Ensure conversion is enabled") val sqlProcedures = Path("${cfg.dbCfg.sqlDir}/libeufin-conversion-setup.sql") diff --git a/libeufin-common/src/main/kotlin/Cli.kt b/libeufin-common/src/main/kotlin/Cli.kt @@ -59,6 +59,9 @@ abstract class TalerCmd(name: String? = null): CliktCommand(name) { } } catch (e: ProgramResult) { throw e + } catch (e: TalerConfigError) { + e.fmtLog(logger) + throw ProgramResult(6) } catch (e: Throwable) { e.fmtLog(logger) throw ProgramResult(1) diff --git a/libeufin-common/src/test/kotlin/CliExitTest.kt b/libeufin-common/src/test/kotlin/CliExitTest.kt @@ -0,0 +1,55 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2026 Taler Systems S.A. + * + * LibEuFin is free software; you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation; either version 3, or + * (at your option) any later version. + * + * LibEuFin is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General + * Public License for more details. + * + * You should have received a copy of the GNU Affero General Public + * License along with LibEuFin; see the file COPYING. If not, see + * <http://www.gnu.org/licenses/> + */ + +import com.github.ajalt.clikt.core.ProgramResult +import com.github.ajalt.clikt.testing.test +import org.slf4j.LoggerFactory +import tech.libeufin.common.TalerCmd +import tech.libeufin.common.TalerConfigError +import java.net.ConnectException +import kotlin.test.Test +import kotlin.test.assertEquals + +class CliExitTest { + private fun status(failure: Throwable): Int { + val cmd = object : TalerCmd("exit-test") { + override fun run() = cliCmd(LoggerFactory.getLogger("exit-test")) { + throw failure + } + } + return cmd.test("").statusCode + } + + @Test + fun configurationFailureStopsService() { + assertEquals(6, status(TalerConfigError.generic("Missing service configuration"))) + } + + @Test + fun dependencyOutageCanRestart() { + assertEquals(1, status(ConnectException("Connection refused"))) + } + + @Test + fun explicitResultsSurvive() { + for (code in listOf(0, 1, 6, 9)) { + assertEquals(code, status(ProgramResult(code))) + } + } +} diff --git a/libeufin-common/src/test/kotlin/ConfigTest.kt b/libeufin-common/src/test/kotlin/ConfigTest.kt @@ -45,7 +45,7 @@ class ConfigTest { val result = cmd.test("dump -c $configPath") System.setErr(prevErr) val lastLog = tmpErr.asUtf8().substringAfterLast(" - ").trimEnd('\n') - assertEquals(1, result.statusCode, lastLog) + assertEquals(6, result.statusCode, lastLog) assertEquals(msg, lastLog, lastLog) } diff --git a/libeufin-ebics/src/main/kotlin/tech/libeufin/ebics/keys.kt b/libeufin-ebics/src/main/kotlin/tech/libeufin/ebics/keys.kt @@ -19,6 +19,8 @@ package tech.libeufin.ebics +import tech.libeufin.common.TalerConfigError + import kotlinx.serialization.Contextual import kotlinx.serialization.KSerializer import kotlinx.serialization.Serializable @@ -164,14 +166,14 @@ inline fun <reified T> loadJsonFile(path: Path, name: String): T? { } catch (e: Exception) { when (e) { is NoSuchFileException -> return null - is AccessDeniedException -> throw Exception("Could not read $name at '$path': permission denied") - else -> throw Exception("Could not read $name at '$path'", e) + is AccessDeniedException -> throw TalerConfigError.generic("Could not read $name at '$path': permission denied") + else -> throw TalerConfigError.generic("Could not read $name at '$path'", e) } } return try { JSON.decodeFromString(content) } catch (e: Exception) { - throw Exception("Could not decode $name at '$path'", e) + throw TalerConfigError.generic("Could not decode $name at '$path'", e) } } @@ -206,15 +208,15 @@ fun loadClientKeys(location: Path): ClientPrivateKeysFile? = loadJsonFile(locati fun expectFullKeys(cfg: EbicsKeysConfig, setupCmd: String): Pair<ClientPrivateKeysFile, BankPublicKeysFile> { val clientKeys = loadClientKeys(cfg.clientPrivateKeysPath) if (clientKeys == null) { - throw Exception("Missing client private keys file at '${cfg.clientPrivateKeysPath}', run '$setupCmd' first") + throw TalerConfigError.generic("Missing client private keys file at '${cfg.clientPrivateKeysPath}', run '$setupCmd' first") } else if (!clientKeys.submitted_ini || !clientKeys.submitted_hia) { - throw Exception("Unsubmitted client private keys, run '$setupCmd' first") + throw TalerConfigError.generic("Unsubmitted client private keys, run '$setupCmd' first") } val bankKeys = loadBankKeys(cfg.bankPublicKeysPath) if (bankKeys == null) { - throw Exception("Missing bank public keys at '${cfg.bankPublicKeysPath}', run '$setupCmd' first") + throw TalerConfigError.generic("Missing bank public keys at '${cfg.bankPublicKeysPath}', run '$setupCmd' first") } else if (!bankKeys.accepted) { - throw Exception("Unaccepted bank public keys, run '$setupCmd' until accepting the bank keys") + throw TalerConfigError.generic("Unaccepted bank public keys, run '$setupCmd' until accepting the bank keys") } return Pair(clientKeys, bankKeys) } \ No newline at end of file diff --git a/libeufin-ebisync/src/main/kotlin/tech/libeufin/ebisync/cli/Fetch.kt b/libeufin-ebisync/src/main/kotlin/tech/libeufin/ebisync/cli/Fetch.kt @@ -103,20 +103,23 @@ class Fetch : EbicsCmd() { ).flag() override fun run() = cliCmd(logger) { - ebisyncConfig(config).withDb { db, cfg -> + val cfg = ebisyncConfig(config) + // ExecCondition must not turn a temporary database outage into a + // skipped service. Check only whether this operation is configured. + if (check) { + if (cfg.fetch.destination == Destination.None) { + logger.info("No destination configured, not starting the fetcher") + throw ProgramResult(1) + } + throw ProgramResult(0) + } + cfg.withDb { db, cfg -> val (clientKeys, bankKeys) = expectFullKeys(cfg) val httpClient = httpClient(); val dest = DestinationClient.prepare(cfg.fetch.destination, httpClient) - if (check) { - if (dest == null) { - logger.info("No destination configured, not starting the fetcher") - throw ProgramResult(1) - } else { - throw ProgramResult(0) - } - } else if (dest == null) { + if (dest == null) { throw ProgramResult(0) } diff --git a/libeufin-ebisync/src/test/kotlin/CheckTest.kt b/libeufin-ebisync/src/test/kotlin/CheckTest.kt @@ -0,0 +1,56 @@ +/* + * This file is part of LibEuFin. + * Copyright (C) 2026 Taler Systems S.A. + + * LibEuFin is free software; you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation; either version 3, or + * (at your option) any later version. + + * LibEuFin is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General + * Public License for more details. + + * You should have received a copy of the GNU Affero General Public + * License along with LibEuFin; see the file COPYING. If not, see + * <http://www.gnu.org/licenses/> + */ + +import com.github.ajalt.clikt.testing.test +import tech.libeufin.ebisync.cli.LibeufinEbisync +import kotlin.io.path.* +import kotlin.test.Test +import kotlin.test.assertEquals + +class CheckTest { + @Test + fun fetchConditionNeedsNeitherDatabaseNorKeys() { + val config = createTempFile("ebisync-check", ".conf") + try { + for ((destination, expected) in listOf("none" to 1, "azure-blob-storage" to 0)) { + config.writeText(""" + [ebisync] + HOST_BASE_URL = http://localhost:1/ebics + HOST_ID = test + USER_ID = test + PARTNER_ID = test + BANK_PUBLIC_KEYS_FILE = /nonexistent/dd102-bank-keys.json + CLIENT_PRIVATE_KEYS_FILE = /nonexistent/dd102-client-keys.json + [ebisyncdb-postgres] + CONFIG = postgresql://localhost:1/unavailable + [ebisync-fetch] + DESTINATION = $destination + AZURE_API_URL = http://localhost:1/ + AZURE_ACCOUNT_NAME = test + AZURE_ACCOUNT_KEY = unused + AZURE_CONTAINER = test + """.trimIndent()) + val result = LibeufinEbisync().test("fetch -c $config --check") + assertEquals(expected, result.statusCode, result.output) + } + } finally { + config.deleteIfExists() + } + } +}