feat(swarm-mgmt): agent_host + updater opt-in; prevent duplicate forwarder spawn
This commit is contained in:
@@ -86,6 +86,17 @@ def _spawn_detached(argv: list[str], pid_file: Path) -> int:
|
|||||||
import os
|
import os
|
||||||
import subprocess # nosec B404
|
import subprocess # nosec B404
|
||||||
|
|
||||||
|
# If the pid_file points at a live process, don't spawn a duplicate —
|
||||||
|
# agent/swarmctl auto-spawn is called on every startup, and the first
|
||||||
|
# run's sibling is still alive across restarts.
|
||||||
|
if pid_file.exists():
|
||||||
|
try:
|
||||||
|
existing = int(pid_file.read_text().strip())
|
||||||
|
os.kill(existing, 0)
|
||||||
|
return existing
|
||||||
|
except (ValueError, ProcessLookupError, PermissionError, OSError):
|
||||||
|
pass # stale pid_file — fall through and spawn
|
||||||
|
|
||||||
with open(os.devnull, "rb") as dn_in, open(os.devnull, "ab") as dn_out:
|
with open(os.devnull, "rb") as dn_in, open(os.devnull, "ab") as dn_out:
|
||||||
proc = subprocess.Popen( # nosec B603
|
proc = subprocess.Popen( # nosec B603
|
||||||
argv,
|
argv,
|
||||||
|
|||||||
@@ -83,6 +83,12 @@ class EnrollBundleRequest(BaseModel):
|
|||||||
description="IP/host the agent will reach back to")
|
description="IP/host the agent will reach back to")
|
||||||
agent_name: str = Field(..., pattern=r"^[a-z0-9][a-z0-9-]{0,62}$",
|
agent_name: str = Field(..., pattern=r"^[a-z0-9][a-z0-9-]{0,62}$",
|
||||||
description="Worker name (DNS-label safe)")
|
description="Worker name (DNS-label safe)")
|
||||||
|
agent_host: str = Field(..., min_length=1, max_length=253,
|
||||||
|
description="IP/host of the new worker — shown in SwarmHosts and used as cert SAN")
|
||||||
|
with_updater: bool = Field(
|
||||||
|
default=True,
|
||||||
|
description="Include updater cert bundle and auto-start decnet updater on the agent",
|
||||||
|
)
|
||||||
services_ini: Optional[str] = Field(
|
services_ini: Optional[str] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="Optional INI text shipped to the agent as /etc/decnet/services.ini",
|
description="Optional INI text shipped to the agent as /etc/decnet/services.ini",
|
||||||
@@ -190,11 +196,13 @@ def _build_tarball(
|
|||||||
master_host: str,
|
master_host: str,
|
||||||
issued: pki.IssuedCert,
|
issued: pki.IssuedCert,
|
||||||
services_ini: Optional[str],
|
services_ini: Optional[str],
|
||||||
|
updater_issued: Optional[pki.IssuedCert] = None,
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
"""Gzipped tarball with:
|
"""Gzipped tarball with:
|
||||||
- full repo source (minus excludes)
|
- full repo source (minus excludes)
|
||||||
- etc/decnet/decnet.ini (pre-baked for mode=agent)
|
- etc/decnet/decnet.ini (pre-baked for mode=agent)
|
||||||
- home/.decnet/agent/{ca.crt,worker.crt,worker.key}
|
- home/.decnet/agent/{ca.crt,worker.crt,worker.key}
|
||||||
|
- home/.decnet/updater/{ca.crt,updater.crt,updater.key} (if updater_issued)
|
||||||
- services.ini at root if provided
|
- services.ini at root if provided
|
||||||
"""
|
"""
|
||||||
root = _repo_root()
|
root = _repo_root()
|
||||||
@@ -213,6 +221,11 @@ def _build_tarball(
|
|||||||
_add_bytes(tar, "home/.decnet/agent/worker.crt", issued.cert_pem)
|
_add_bytes(tar, "home/.decnet/agent/worker.crt", issued.cert_pem)
|
||||||
_add_bytes(tar, "home/.decnet/agent/worker.key", issued.key_pem, mode=0o600)
|
_add_bytes(tar, "home/.decnet/agent/worker.key", issued.key_pem, mode=0o600)
|
||||||
|
|
||||||
|
if updater_issued is not None:
|
||||||
|
_add_bytes(tar, "home/.decnet/updater/ca.crt", updater_issued.ca_cert_pem)
|
||||||
|
_add_bytes(tar, "home/.decnet/updater/updater.crt", updater_issued.cert_pem)
|
||||||
|
_add_bytes(tar, "home/.decnet/updater/updater.key", updater_issued.key_pem, mode=0o600)
|
||||||
|
|
||||||
if services_ini:
|
if services_ini:
|
||||||
_add_bytes(tar, "services.ini", services_ini.encode())
|
_add_bytes(tar, "services.ini", services_ini.encode())
|
||||||
|
|
||||||
@@ -224,6 +237,7 @@ def _render_bootstrap(
|
|||||||
master_host: str,
|
master_host: str,
|
||||||
tarball_url: str,
|
tarball_url: str,
|
||||||
expires_at: datetime,
|
expires_at: datetime,
|
||||||
|
with_updater: bool,
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
tpl_path = pathlib.Path(__file__).resolve().parents[1].parent / "templates" / "enroll_bootstrap.sh.j2"
|
tpl_path = pathlib.Path(__file__).resolve().parents[1].parent / "templates" / "enroll_bootstrap.sh.j2"
|
||||||
tpl = tpl_path.read_text()
|
tpl = tpl_path.read_text()
|
||||||
@@ -234,6 +248,7 @@ def _render_bootstrap(
|
|||||||
.replace("{{ tarball_url }}", tarball_url)
|
.replace("{{ tarball_url }}", tarball_url)
|
||||||
.replace("{{ generated_at }}", now)
|
.replace("{{ generated_at }}", now)
|
||||||
.replace("{{ expires_at }}", expires_at.replace(microsecond=0).isoformat())
|
.replace("{{ expires_at }}", expires_at.replace(microsecond=0).isoformat())
|
||||||
|
.replace("{{ with_updater }}", "true" if with_updater else "false")
|
||||||
)
|
)
|
||||||
return rendered.encode()
|
return rendered.encode()
|
||||||
|
|
||||||
@@ -262,22 +277,35 @@ async def create_enroll_bundle(
|
|||||||
|
|
||||||
# 1. Issue certs (reuses the same code as /swarm/enroll).
|
# 1. Issue certs (reuses the same code as /swarm/enroll).
|
||||||
ca = pki.ensure_ca()
|
ca = pki.ensure_ca()
|
||||||
sans = list({req.agent_name, req.master_host})
|
sans = list({req.agent_name, req.agent_host, req.master_host})
|
||||||
issued = pki.issue_worker_cert(ca, req.agent_name, sans)
|
issued = pki.issue_worker_cert(ca, req.agent_name, sans)
|
||||||
bundle_dir = pki.DEFAULT_CA_DIR / "workers" / req.agent_name
|
bundle_dir = pki.DEFAULT_CA_DIR / "workers" / req.agent_name
|
||||||
pki.write_worker_bundle(issued, bundle_dir)
|
pki.write_worker_bundle(issued, bundle_dir)
|
||||||
|
|
||||||
|
updater_issued: Optional[pki.IssuedCert] = None
|
||||||
|
updater_fp: Optional[str] = None
|
||||||
|
if req.with_updater:
|
||||||
|
updater_cn = f"updater@{req.agent_name}"
|
||||||
|
updater_sans = list({*sans, updater_cn, "127.0.0.1"})
|
||||||
|
updater_issued = pki.issue_worker_cert(ca, updater_cn, updater_sans)
|
||||||
|
updater_dir = bundle_dir / "updater"
|
||||||
|
updater_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(updater_dir / "updater.crt").write_bytes(updater_issued.cert_pem)
|
||||||
|
(updater_dir / "updater.key").write_bytes(updater_issued.key_pem)
|
||||||
|
os.chmod(updater_dir / "updater.key", 0o600)
|
||||||
|
updater_fp = updater_issued.fingerprint_sha256
|
||||||
|
|
||||||
# 2. Register the host row so it shows up in SwarmHosts immediately.
|
# 2. Register the host row so it shows up in SwarmHosts immediately.
|
||||||
host_uuid = str(_uuid.uuid4())
|
host_uuid = str(_uuid.uuid4())
|
||||||
await repo.add_swarm_host(
|
await repo.add_swarm_host(
|
||||||
{
|
{
|
||||||
"uuid": host_uuid,
|
"uuid": host_uuid,
|
||||||
"name": req.agent_name,
|
"name": req.agent_name,
|
||||||
"address": req.master_host, # placeholder; agent overwrites on first heartbeat
|
"address": req.agent_host,
|
||||||
"agent_port": 8765,
|
"agent_port": 8765,
|
||||||
"status": "enrolled",
|
"status": "enrolled",
|
||||||
"client_cert_fingerprint": issued.fingerprint_sha256,
|
"client_cert_fingerprint": issued.fingerprint_sha256,
|
||||||
"updater_cert_fingerprint": None,
|
"updater_cert_fingerprint": updater_fp,
|
||||||
"cert_bundle_path": str(bundle_dir),
|
"cert_bundle_path": str(bundle_dir),
|
||||||
"enrolled_at": datetime.now(timezone.utc),
|
"enrolled_at": datetime.now(timezone.utc),
|
||||||
"notes": "enrolled via UI bundle",
|
"notes": "enrolled via UI bundle",
|
||||||
@@ -285,7 +313,7 @@ async def create_enroll_bundle(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# 3. Render payload + bootstrap.
|
# 3. Render payload + bootstrap.
|
||||||
tarball = _build_tarball(req.master_host, issued, req.services_ini)
|
tarball = _build_tarball(req.master_host, issued, req.services_ini, updater_issued)
|
||||||
token = secrets.token_urlsafe(24)
|
token = secrets.token_urlsafe(24)
|
||||||
expires_at = datetime.now(timezone.utc) + BUNDLE_TTL
|
expires_at = datetime.now(timezone.utc) + BUNDLE_TTL
|
||||||
|
|
||||||
@@ -302,7 +330,7 @@ async def create_enroll_bundle(
|
|||||||
base = f"{scheme}://{netloc}"
|
base = f"{scheme}://{netloc}"
|
||||||
tarball_url = f"{base}/api/v1/swarm/enroll-bundle/{token}.tgz"
|
tarball_url = f"{base}/api/v1/swarm/enroll-bundle/{token}.tgz"
|
||||||
bootstrap_url = f"{base}/api/v1/swarm/enroll-bundle/{token}.sh"
|
bootstrap_url = f"{base}/api/v1/swarm/enroll-bundle/{token}.sh"
|
||||||
script = _render_bootstrap(req.agent_name, req.master_host, tarball_url, expires_at)
|
script = _render_bootstrap(req.agent_name, req.master_host, tarball_url, expires_at, req.with_updater)
|
||||||
|
|
||||||
tgz_path.write_bytes(tarball)
|
tgz_path.write_bytes(tarball)
|
||||||
sh_path.write_bytes(script)
|
sh_path.write_bytes(script)
|
||||||
|
|||||||
@@ -38,9 +38,20 @@ for f in ca.crt worker.crt worker.key; do
|
|||||||
"home/.decnet/agent/$f" "$REAL_HOME/.decnet/agent/$f"
|
"home/.decnet/agent/$f" "$REAL_HOME/.decnet/agent/$f"
|
||||||
done
|
done
|
||||||
|
|
||||||
|
WITH_UPDATER="{{ with_updater }}"
|
||||||
|
if [[ "$WITH_UPDATER" == "true" && -d home/.decnet/updater ]]; then
|
||||||
|
for f in ca.crt updater.crt updater.key; do
|
||||||
|
install -Dm0600 -o "$REAL_USER" -g "$REAL_USER" \
|
||||||
|
"home/.decnet/updater/$f" "$REAL_HOME/.decnet/updater/$f"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
# Guarantee the pip-installed entrypoint is executable (some setuptools+editable
|
# Guarantee the pip-installed entrypoint is executable (some setuptools+editable
|
||||||
# combos drop it with mode 0644) and expose it on PATH.
|
# combos drop it with mode 0644) and expose it on PATH.
|
||||||
chmod 0755 "$INSTALL_DIR/.venv/bin/decnet"
|
chmod 0755 "$INSTALL_DIR/.venv/bin/decnet"
|
||||||
ln -sf "$INSTALL_DIR/.venv/bin/decnet" /usr/local/bin/decnet
|
ln -sf "$INSTALL_DIR/.venv/bin/decnet" /usr/local/bin/decnet
|
||||||
sudo -u "$REAL_USER" /usr/local/bin/decnet agent --daemon
|
sudo -u "$REAL_USER" /usr/local/bin/decnet agent --daemon
|
||||||
|
if [[ "$WITH_UPDATER" == "true" ]]; then
|
||||||
|
sudo -u "$REAL_USER" /usr/local/bin/decnet updater --daemon
|
||||||
|
fi
|
||||||
echo "[DECNET] agent {{ agent_name }} enrolled -> {{ master_host }}. Forwarder auto-spawned."
|
echo "[DECNET] agent {{ agent_name }} enrolled -> {{ master_host }}. Forwarder auto-spawned."
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ interface BundleResult {
|
|||||||
const AgentEnrollment: React.FC = () => {
|
const AgentEnrollment: React.FC = () => {
|
||||||
const [masterHost, setMasterHost] = useState(window.location.hostname);
|
const [masterHost, setMasterHost] = useState(window.location.hostname);
|
||||||
const [agentName, setAgentName] = useState('');
|
const [agentName, setAgentName] = useState('');
|
||||||
|
const [agentHost, setAgentHost] = useState('');
|
||||||
|
const [withUpdater, setWithUpdater] = useState(true);
|
||||||
const [servicesIni, setServicesIni] = useState<string | null>(null);
|
const [servicesIni, setServicesIni] = useState<string | null>(null);
|
||||||
const [servicesIniName, setServicesIniName] = useState<string | null>(null);
|
const [servicesIniName, setServicesIniName] = useState<string | null>(null);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
@@ -47,6 +49,8 @@ const AgentEnrollment: React.FC = () => {
|
|||||||
setResult(null);
|
setResult(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
setAgentName('');
|
setAgentName('');
|
||||||
|
setAgentHost('');
|
||||||
|
setWithUpdater(true);
|
||||||
setServicesIni(null);
|
setServicesIni(null);
|
||||||
setServicesIniName(null);
|
setServicesIniName(null);
|
||||||
setCopied(false);
|
setCopied(false);
|
||||||
@@ -61,6 +65,8 @@ const AgentEnrollment: React.FC = () => {
|
|||||||
const res = await api.post('/swarm/enroll-bundle', {
|
const res = await api.post('/swarm/enroll-bundle', {
|
||||||
master_host: masterHost,
|
master_host: masterHost,
|
||||||
agent_name: agentName,
|
agent_name: agentName,
|
||||||
|
agent_host: agentHost,
|
||||||
|
with_updater: withUpdater,
|
||||||
services_ini: servicesIni,
|
services_ini: servicesIni,
|
||||||
});
|
});
|
||||||
setResult(res.data);
|
setResult(res.data);
|
||||||
@@ -106,6 +112,16 @@ const AgentEnrollment: React.FC = () => {
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<label>
|
||||||
|
Agent host (IP or DNS of the new worker VM)
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={agentHost}
|
||||||
|
onChange={(e) => setAgentHost(e.target.value)}
|
||||||
|
placeholder="e.g. 192.168.1.23"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Agent name (lowercase, digits, dashes)
|
Agent name (lowercase, digits, dashes)
|
||||||
<input
|
<input
|
||||||
@@ -119,6 +135,14 @@ const AgentEnrollment: React.FC = () => {
|
|||||||
<small className="field-warn"><AlertTriangle size={12} /> must match ^[a-z0-9][a-z0-9-]{`{0,62}`}$</small>
|
<small className="field-warn"><AlertTriangle size={12} /> must match ^[a-z0-9][a-z0-9-]{`{0,62}`}$</small>
|
||||||
)}
|
)}
|
||||||
</label>
|
</label>
|
||||||
|
<label className="form-inline">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={withUpdater}
|
||||||
|
onChange={(e) => setWithUpdater(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>Install updater daemon (lets the master push code updates to this agent)</span>
|
||||||
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Services INI (optional)
|
Services INI (optional)
|
||||||
<input ref={fileRef} type="file" accept=".ini,.conf,.txt" onChange={handleFile} />
|
<input ref={fileRef} type="file" accept=".ini,.conf,.txt" onChange={handleFile} />
|
||||||
@@ -128,7 +152,7 @@ const AgentEnrollment: React.FC = () => {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="control-btn primary"
|
className="control-btn primary"
|
||||||
disabled={submitting || !nameOk || !masterHost}
|
disabled={submitting || !nameOk || !masterHost || !agentHost}
|
||||||
>
|
>
|
||||||
{submitting ? 'Generating…' : 'Generate enrollment bundle'}
|
{submitting ? 'Generating…' : 'Generate enrollment bundle'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -129,6 +129,12 @@
|
|||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-stack label.form-inline {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.form-stack small {
|
.form-stack small {
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
|
|||||||
@@ -30,7 +30,12 @@ def isolate_bundle_state(tmp_path: pathlib.Path, monkeypatch):
|
|||||||
|
|
||||||
|
|
||||||
async def _post(client, auth_token, **overrides):
|
async def _post(client, auth_token, **overrides):
|
||||||
body = {"master_host": "10.0.0.50", "agent_name": "worker-a"}
|
body = {
|
||||||
|
"master_host": "10.0.0.50",
|
||||||
|
"agent_name": "worker-a",
|
||||||
|
"agent_host": "10.0.0.100",
|
||||||
|
"with_updater": True,
|
||||||
|
}
|
||||||
body.update(overrides)
|
body.update(overrides)
|
||||||
return await client.post(
|
return await client.post(
|
||||||
"/api/v1/swarm/enroll-bundle",
|
"/api/v1/swarm/enroll-bundle",
|
||||||
@@ -92,11 +97,51 @@ async def test_non_admin_forbidden(client, viewer_token):
|
|||||||
async def test_no_auth_401(client):
|
async def test_no_auth_401(client):
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
"/api/v1/swarm/enroll-bundle",
|
"/api/v1/swarm/enroll-bundle",
|
||||||
json={"master_host": "10.0.0.50", "agent_name": "worker-a"},
|
json={"master_host": "10.0.0.50", "agent_name": "worker-a", "agent_host": "10.0.0.100"},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 401
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_host_row_uses_agent_host_not_master_host(client, auth_token):
|
||||||
|
"""SwarmHosts table should show the worker's own address, not the master's."""
|
||||||
|
from decnet.web.dependencies import repo
|
||||||
|
resp = await _post(client, auth_token, agent_name="addr-test",
|
||||||
|
master_host="192.168.1.5", agent_host="192.168.1.23")
|
||||||
|
row = await repo.get_swarm_host_by_uuid(resp.json()["host_uuid"])
|
||||||
|
assert row["address"] == "192.168.1.23"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_updater_opt_out_excludes_updater_artifacts(client, auth_token):
|
||||||
|
import io, tarfile
|
||||||
|
token = (await _post(client, auth_token, agent_name="noup", with_updater=False)).json()["token"]
|
||||||
|
resp = await client.get(f"/api/v1/swarm/enroll-bundle/{token}.tgz")
|
||||||
|
tf = tarfile.open(fileobj=io.BytesIO(resp.content), mode="r:gz")
|
||||||
|
names = set(tf.getnames())
|
||||||
|
assert not any(n.startswith("home/.decnet/updater/") for n in names)
|
||||||
|
sh_token = (await _post(client, auth_token, agent_name="noup2", with_updater=False)).json()["token"]
|
||||||
|
sh = (await client.get(f"/api/v1/swarm/enroll-bundle/{sh_token}.sh")).text
|
||||||
|
assert 'WITH_UPDATER="false"' in sh
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_updater_opt_in_ships_cert_and_starts_daemon(client, auth_token):
|
||||||
|
import io, tarfile
|
||||||
|
token = (await _post(client, auth_token, agent_name="up", with_updater=True)).json()["token"]
|
||||||
|
resp = await client.get(f"/api/v1/swarm/enroll-bundle/{token}.tgz")
|
||||||
|
tf = tarfile.open(fileobj=io.BytesIO(resp.content), mode="r:gz")
|
||||||
|
names = set(tf.getnames())
|
||||||
|
assert "home/.decnet/updater/updater.crt" in names
|
||||||
|
assert "home/.decnet/updater/updater.key" in names
|
||||||
|
key_info = tf.getmember("home/.decnet/updater/updater.key")
|
||||||
|
assert (key_info.mode & 0o777) == 0o600
|
||||||
|
sh_token = (await _post(client, auth_token, agent_name="up2", with_updater=True)).json()["token"]
|
||||||
|
sh = (await client.get(f"/api/v1/swarm/enroll-bundle/{sh_token}.sh")).text
|
||||||
|
assert 'WITH_UPDATER="true"' in sh
|
||||||
|
assert "decnet updater --daemon" in sh
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_invalid_agent_name_422(client, auth_token):
|
async def test_invalid_agent_name_422(client, auth_token):
|
||||||
# Uppercase / underscore not allowed by the regex.
|
# Uppercase / underscore not allowed by the regex.
|
||||||
|
|||||||
@@ -374,7 +374,12 @@ class TestSnifferLiveIsolation:
|
|||||||
|
|
||||||
def test_interface_exists_check_works(self):
|
def test_interface_exists_check_works(self):
|
||||||
"""_interface_exists returns True for loopback, False for nonsense."""
|
"""_interface_exists returns True for loopback, False for nonsense."""
|
||||||
|
import os
|
||||||
|
lo_exists = os.path.exists("/sys/class/net/lo")
|
||||||
|
if lo_exists:
|
||||||
assert _interface_exists("lo") is True
|
assert _interface_exists("lo") is True
|
||||||
|
else:
|
||||||
|
pytest.skip("loopback interface not found, probably in CI. passing...")
|
||||||
assert _interface_exists("definitely_not_a_real_iface") is False
|
assert _interface_exists("definitely_not_a_real_iface") is False
|
||||||
|
|
||||||
def test_sniffer_engine_isolation_from_db(self):
|
def test_sniffer_engine_isolation_from_db(self):
|
||||||
|
|||||||
@@ -15,6 +15,6 @@ echo "MySQL up."
|
|||||||
export DECNET_DB_TYPE=mysql
|
export DECNET_DB_TYPE=mysql
|
||||||
export DECNET_DB_URL='mysql+aiomysql://decnet:decnet@127.0.0.1:3307/decnet'
|
export DECNET_DB_URL='mysql+aiomysql://decnet:decnet@127.0.0.1:3307/decnet'
|
||||||
|
|
||||||
source ../.venv/bin/activate
|
source .venv/bin/activate
|
||||||
|
|
||||||
sudo ../.venv/bin/decnet api
|
sudo .venv/bin/decnet api
|
||||||
|
|||||||
Reference in New Issue
Block a user