feat: migrate provisioning API to Merkmal/Blueprint/Bereitstellungsvorlage model
Replaces the config.json-backed flat profile model with the
Postgres-backed Workspace/Merkmal/Backend/Blueprint/Bereitstellungsvorlage
schema (migrations 0003-0005). /api/v1/activate now returns Bereitstellungsvorlage
templates instead of profiles, and a new POST /api/v1/templates/{id}/resolve
endpoint resolves a chosen template to a full Runtime Blueprint (workspace,
backend, resolved Merkmal->Ansible-role blueprints, and installation
directives), recording the resulting device assignment.
Removes the now-unused config.json and file-based device registry
directory in favor of the PostgreSQL-backed activation codes and
device tables.
This commit is contained in:
parent
02e2b9de94
commit
11186dfdf9
2
.gitignore
vendored
2
.gitignore
vendored
@ -2,5 +2,3 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
activation.log
|
activation.log
|
||||||
|
|
||||||
data/devices/*.json
|
|
||||||
|
|||||||
164
app.py
164
app.py
@ -11,7 +11,6 @@ from pydantic import BaseModel
|
|||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
|
|
||||||
|
|
||||||
CONFIG_PATH = Path("config.json")
|
|
||||||
LOG_PATH = Path("activation.log")
|
LOG_PATH = Path("activation.log")
|
||||||
DATABASE_URL = os.environ.get("TUXFLOTTE_DATABASE_URL")
|
DATABASE_URL = os.environ.get("TUXFLOTTE_DATABASE_URL")
|
||||||
app = FastAPI(title="Provisioning Activation Server", version="0.1.0")
|
app = FastAPI(title="Provisioning Activation Server", version="0.1.0")
|
||||||
@ -26,9 +25,8 @@ class ActivationRequest(BaseModel):
|
|||||||
hardware: dict
|
hardware: dict
|
||||||
|
|
||||||
|
|
||||||
def load_config() -> dict:
|
class ResolveRequest(BaseModel):
|
||||||
with CONFIG_PATH.open("r", encoding="utf-8") as f:
|
device_id: str
|
||||||
return json.load(f)
|
|
||||||
|
|
||||||
|
|
||||||
def write_log(entry: dict) -> None:
|
def write_log(entry: dict) -> None:
|
||||||
@ -255,6 +253,55 @@ def store_hardware_snapshot(
|
|||||||
|
|
||||||
return str(snapshot_id)
|
return str(snapshot_id)
|
||||||
|
|
||||||
|
def find_activation_code(code: str):
|
||||||
|
with get_database_connection() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT ac.organization_id, o.name
|
||||||
|
FROM activation_codes ac
|
||||||
|
JOIN organizations o ON o.id = ac.organization_id
|
||||||
|
WHERE ac.code = %s AND ac.active
|
||||||
|
""",
|
||||||
|
(code,),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {"organization_id": str(row[0]), "organization_name": row[1]}
|
||||||
|
|
||||||
|
def fetch_templates(organization_id: str) -> list[dict]:
|
||||||
|
with get_database_connection() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
bv.id, bv.label, bv.is_default,
|
||||||
|
w.id, w.name,
|
||||||
|
b.id, b.name, b.version
|
||||||
|
FROM bereitstellungsvorlagen bv
|
||||||
|
JOIN workspaces w ON w.id = bv.workspace_id
|
||||||
|
JOIN backends b ON b.id = bv.backend_id
|
||||||
|
WHERE bv.organization_id = %s
|
||||||
|
ORDER BY bv.is_default DESC, bv.label
|
||||||
|
""",
|
||||||
|
(organization_id,),
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": str(row[0]),
|
||||||
|
"label": row[1],
|
||||||
|
"workspace": {"id": str(row[3]), "name": row[4]},
|
||||||
|
"backend": {"id": str(row[5]), "name": row[6], "version": row[7]},
|
||||||
|
"is_default": row[2],
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
def health():
|
def health():
|
||||||
return {
|
return {
|
||||||
@ -266,17 +313,16 @@ def health():
|
|||||||
|
|
||||||
@app.post("/api/v1/activate")
|
@app.post("/api/v1/activate")
|
||||||
def activate(payload: ActivationRequest, request: Request):
|
def activate(payload: ActivationRequest, request: Request):
|
||||||
config = load_config()
|
activation = find_activation_code(payload.activation_code)
|
||||||
activation_entry = config.get("activation_codes", {}).get(payload.activation_code)
|
|
||||||
|
|
||||||
success = activation_entry is not None
|
success = activation is not None
|
||||||
device = None
|
device = None
|
||||||
device_created = False
|
device_created = False
|
||||||
|
|
||||||
hardware_snapshot_id = None
|
hardware_snapshot_id = None
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
organization_id = activation_entry["customer"]["organization_id"]
|
organization_id = activation["organization_id"]
|
||||||
|
|
||||||
device, device_created = load_or_create_device(
|
device, device_created = load_or_create_device(
|
||||||
organization_id,
|
organization_id,
|
||||||
@ -306,13 +352,6 @@ def activate(payload: ActivationRequest, request: Request):
|
|||||||
"message": "Der Aktivierungscode ist ungültig."
|
"message": "Der Aktivierungscode ist ungültig."
|
||||||
}
|
}
|
||||||
|
|
||||||
profiles = config.get("profiles", {})
|
|
||||||
available_profiles = [
|
|
||||||
profiles[profile_id]
|
|
||||||
for profile_id in activation_entry.get("profile_ids", [])
|
|
||||||
if profile_id in profiles
|
|
||||||
]
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
|
|
||||||
@ -328,56 +367,75 @@ def activate(payload: ActivationRequest, request: Request):
|
|||||||
"hardware_snapshot_id": hardware_snapshot_id,
|
"hardware_snapshot_id": hardware_snapshot_id,
|
||||||
},
|
},
|
||||||
|
|
||||||
"customer": activation_entry["customer"],
|
"customer": {
|
||||||
"profiles": available_profiles
|
"id": activation["organization_id"],
|
||||||
|
"organization_id": activation["organization_id"],
|
||||||
|
"name": activation["organization_name"],
|
||||||
|
},
|
||||||
|
"templates": fetch_templates(activation["organization_id"]),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/profiles/{profile_id}")
|
@app.post("/api/v1/templates/{template_id}/resolve")
|
||||||
def get_profile(profile_id: str):
|
def resolve_template(template_id: str, payload: ResolveRequest):
|
||||||
config = load_config()
|
with get_database_connection() as conn:
|
||||||
profile = config.get("profiles", {}).get(profile_id)
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT workspace_id, backend_id, disk_encryption, partitioning, secure_boot_required
|
||||||
|
FROM bereitstellungsvorlagen
|
||||||
|
WHERE id = %s
|
||||||
|
""",
|
||||||
|
(template_id,),
|
||||||
|
)
|
||||||
|
template_row = cur.fetchone()
|
||||||
|
|
||||||
if not profile:
|
if template_row is None:
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"error": "profile_not_found",
|
"error": "template_not_found",
|
||||||
"message": "Das angeforderte Profil wurde nicht gefunden."
|
"message": "Die angeforderte Bereitstellungsvorlage wurde nicht gefunden."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
workspace_id, backend_id, disk_encryption, partitioning, secure_boot_required = template_row
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT m.key, bp.ansible_role
|
||||||
|
FROM workspace_merkmale wm
|
||||||
|
JOIN merkmale m ON m.id = wm.merkmal_id
|
||||||
|
JOIN blueprints bp ON bp.merkmal_id = m.id AND bp.backend_id = %s
|
||||||
|
WHERE wm.workspace_id = %s
|
||||||
|
""",
|
||||||
|
(backend_id, workspace_id),
|
||||||
|
)
|
||||||
|
blueprints = [
|
||||||
|
{"merkmal": key, "ansible_role": ansible_role}
|
||||||
|
for key, ansible_role in cur.fetchall()
|
||||||
|
]
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO assignments (id, device_id, bereitstellungsvorlage_id)
|
||||||
|
VALUES (%s, %s, %s)
|
||||||
|
ON CONFLICT (device_id) DO UPDATE
|
||||||
|
SET bereitstellungsvorlage_id = EXCLUDED.bereitstellungsvorlage_id
|
||||||
|
""",
|
||||||
|
(uuid4(), payload.device_id, template_id),
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"profile": profile
|
"runtime_blueprint": {
|
||||||
}
|
"workspace_id": str(workspace_id),
|
||||||
|
"backend_id": str(backend_id),
|
||||||
@app.get("/api/v1/profiles/{profile_id}/bootstrap")
|
"blueprints": blueprints,
|
||||||
def get_profile_bootstrap(profile_id: str):
|
"installation_directives": {
|
||||||
config = load_config()
|
"disk_encryption": disk_encryption,
|
||||||
profile = config.get("profiles", {}).get(profile_id)
|
"partitioning": partitioning,
|
||||||
|
"secure_boot_required": secure_boot_required,
|
||||||
if not profile:
|
|
||||||
return {
|
|
||||||
"success": False,
|
|
||||||
"error": "profile_not_found",
|
|
||||||
"message": "Das angeforderte Profil wurde nicht gefunden."
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"profile": {
|
|
||||||
"id": profile["id"],
|
|
||||||
"label": profile["label"],
|
|
||||||
"distribution": profile["distribution"],
|
|
||||||
"version": profile["version"]
|
|
||||||
},
|
},
|
||||||
"installer": profile.get("installer"),
|
|
||||||
"ansible": {
|
|
||||||
"repo": profile.get("ansible_repo"),
|
|
||||||
"mode": "pull"
|
|
||||||
},
|
},
|
||||||
"ssh": {
|
|
||||||
"authorized_keys_url": "https://anode.tuxflotte.de/keys/default.pub"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@app.get("/installers/fedora-workstation/ks.cfg")
|
@app.get("/installers/fedora-workstation/ks.cfg")
|
||||||
|
|||||||
41
config.json
41
config.json
@ -1,41 +0,0 @@
|
|||||||
{
|
|
||||||
"activation_codes": {
|
|
||||||
"LAB-2026-START": {
|
|
||||||
"customer": {
|
|
||||||
"id": "default",
|
|
||||||
"organization_id": "11111111-1111-4111-8111-111111111111",
|
|
||||||
"name": "Default Lab"
|
|
||||||
},
|
|
||||||
"profile_ids": [
|
|
||||||
"mint-desktop",
|
|
||||||
"fedora-workstation"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"profiles": {
|
|
||||||
"mint-desktop": {
|
|
||||||
"id": "mint-desktop",
|
|
||||||
"label": "Linux Mint Desktop",
|
|
||||||
"distribution": "linux-mint",
|
|
||||||
"version": "22",
|
|
||||||
"description": "Standard-Desktop-Profil für Linux Mint.",
|
|
||||||
"ansible_repo": "https://git.example.com/stallinux/mint-desktop.git",
|
|
||||||
"installer": {
|
|
||||||
"type": "autoinstall",
|
|
||||||
"url": "https://anode.tuxflotte.de/installers/mint-desktop/user-data"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"fedora-workstation": {
|
|
||||||
"id": "fedora-workstation",
|
|
||||||
"label": "Fedora Workstation",
|
|
||||||
"distribution": "fedora",
|
|
||||||
"version": "40",
|
|
||||||
"description": "Standard-Workstation-Profil für Fedora.",
|
|
||||||
"ansible_repo": "https://git.example.com/stallinux/fedora-workstation.git",
|
|
||||||
"installer": {
|
|
||||||
"type": "kickstart",
|
|
||||||
"url": "https://anode.tuxflotte.de/installers/fedora-workstation/ks.cfg"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
96
migrations/0003_workspace_backend_blueprint_model.sql
Normal file
96
migrations/0003_workspace_backend_blueprint_model.sql
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE TABLE workspaces (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
organization_id UUID
|
||||||
|
REFERENCES organizations(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE (organization_id, key)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE merkmale (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
key TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE workspace_merkmale (
|
||||||
|
workspace_id UUID NOT NULL
|
||||||
|
REFERENCES workspaces(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
merkmal_id UUID NOT NULL
|
||||||
|
REFERENCES merkmale(id)
|
||||||
|
ON DELETE RESTRICT,
|
||||||
|
PRIMARY KEY (workspace_id, merkmal_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE backends (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
key TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
installer_type TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE blueprints (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
merkmal_id UUID NOT NULL
|
||||||
|
REFERENCES merkmale(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
backend_id UUID NOT NULL
|
||||||
|
REFERENCES backends(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
ansible_role TEXT NOT NULL,
|
||||||
|
UNIQUE (merkmal_id, backend_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE bereitstellungsvorlagen (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
organization_id UUID NOT NULL
|
||||||
|
REFERENCES organizations(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
workspace_id UUID NOT NULL
|
||||||
|
REFERENCES workspaces(id)
|
||||||
|
ON DELETE RESTRICT,
|
||||||
|
backend_id UUID NOT NULL
|
||||||
|
REFERENCES backends(id)
|
||||||
|
ON DELETE RESTRICT,
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
is_default BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
disk_encryption BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
partitioning TEXT NOT NULL DEFAULT 'default',
|
||||||
|
secure_boot_required BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX bereitstellungsvorlagen_one_default_per_org
|
||||||
|
ON bereitstellungsvorlagen (organization_id)
|
||||||
|
WHERE is_default;
|
||||||
|
|
||||||
|
CREATE TABLE assignments (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
device_id UUID NOT NULL UNIQUE
|
||||||
|
REFERENCES devices(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
bereitstellungsvorlage_id UUID NOT NULL
|
||||||
|
REFERENCES bereitstellungsvorlagen(id)
|
||||||
|
ON DELETE RESTRICT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE activation_codes (
|
||||||
|
code TEXT PRIMARY KEY,
|
||||||
|
organization_id UUID NOT NULL
|
||||||
|
REFERENCES organizations(id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
5
migrations/0004_add_backend_version.sql
Normal file
5
migrations/0004_add_backend_version.sql
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
ALTER TABLE backends ADD COLUMN version TEXT;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
55
migrations/0005_seed_workspace_backend_blueprint_data.sql
Normal file
55
migrations/0005_seed_workspace_backend_blueprint_data.sql
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- Backends
|
||||||
|
|
||||||
|
INSERT INTO backends (id, key, name, installer_type, version) VALUES
|
||||||
|
('22222222-2222-4222-8222-222222222222', 'fedora', 'Fedora', 'kickstart', '40'),
|
||||||
|
('33333333-3333-4333-8333-333333333333', 'mint', 'Linux Mint', 'autoinstall', '22');
|
||||||
|
|
||||||
|
-- Workspaces (global, organization_id NULL)
|
||||||
|
|
||||||
|
INSERT INTO workspaces (id, organization_id, key, name, description) VALUES
|
||||||
|
('44444444-4444-4444-8444-444444444444', NULL, 'schulcomputer', 'Schulcomputer',
|
||||||
|
'Arbeitsplatz fuer den Schuleinsatz mit fluechtiger Gastsitzung.'),
|
||||||
|
('55555555-5555-4555-8555-555555555555', NULL, 'entwickler', 'Entwickler',
|
||||||
|
'Arbeitsplatz fuer Softwareentwicklung. Merkmale noch nicht konkretisiert.');
|
||||||
|
|
||||||
|
-- Merkmale (bisher nur fuer Schulcomputer konkretisiert)
|
||||||
|
|
||||||
|
INSERT INTO merkmale (id, key, name, description) VALUES
|
||||||
|
('66666666-6666-4666-8666-666666666666', 'guest-session-ephemeral', 'Fluechtige Gastsitzung',
|
||||||
|
'Loescht Dateien, Cache und Verlauf beim Abmelden.'),
|
||||||
|
('77777777-7777-4777-8777-777777777777', 'browser-brave', 'Brave Browser', NULL),
|
||||||
|
('88888888-8888-4888-8888-888888888888', 'office-onlyoffice', 'OnlyOffice', NULL);
|
||||||
|
|
||||||
|
INSERT INTO workspace_merkmale (workspace_id, merkmal_id) VALUES
|
||||||
|
('44444444-4444-4444-8444-444444444444', '66666666-6666-4666-8666-666666666666'),
|
||||||
|
('44444444-4444-4444-8444-444444444444', '77777777-7777-4777-8777-777777777777'),
|
||||||
|
('44444444-4444-4444-8444-444444444444', '88888888-8888-4888-8888-888888888888');
|
||||||
|
|
||||||
|
-- Blueprints je Merkmal x Backend (Ansible-Rollen aus ADR-0002 uebernommen bzw. nach demselben Schema ergaenzt)
|
||||||
|
|
||||||
|
INSERT INTO blueprints (id, merkmal_id, backend_id, ansible_role) VALUES
|
||||||
|
('b1000000-0000-4000-8000-000000000001', '66666666-6666-4666-8666-666666666666', '22222222-2222-4222-8222-222222222222', 'guest-session'),
|
||||||
|
('b1000000-0000-4000-8000-000000000002', '66666666-6666-4666-8666-666666666666', '33333333-3333-4333-8333-333333333333', 'guest-session'),
|
||||||
|
('b1000000-0000-4000-8000-000000000003', '77777777-7777-4777-8777-777777777777', '22222222-2222-4222-8222-222222222222', 'brave-fedora'),
|
||||||
|
('b1000000-0000-4000-8000-000000000004', '77777777-7777-4777-8777-777777777777', '33333333-3333-4333-8333-333333333333', 'brave-mint'),
|
||||||
|
('b1000000-0000-4000-8000-000000000005', '88888888-8888-4888-8888-888888888888', '22222222-2222-4222-8222-222222222222', 'onlyoffice-fedora'),
|
||||||
|
('b1000000-0000-4000-8000-000000000006', '88888888-8888-4888-8888-888888888888', '33333333-3333-4333-8333-333333333333', 'onlyoffice-mint');
|
||||||
|
|
||||||
|
-- Bereitstellungsvorlagen fuer die bestehende "Default Lab"-Organisation
|
||||||
|
|
||||||
|
INSERT INTO bereitstellungsvorlagen (id, organization_id, workspace_id, backend_id, label, is_default) VALUES
|
||||||
|
('c1000000-0000-4000-8000-000000000001', '11111111-1111-4111-8111-111111111111',
|
||||||
|
'44444444-4444-4444-8444-444444444444', '22222222-2222-4222-8222-222222222222',
|
||||||
|
'Schulcomputer (Fedora)', TRUE),
|
||||||
|
('c1000000-0000-4000-8000-000000000002', '11111111-1111-4111-8111-111111111111',
|
||||||
|
'44444444-4444-4444-8444-444444444444', '33333333-3333-4333-8333-333333333333',
|
||||||
|
'Schulcomputer (Mint)', FALSE);
|
||||||
|
|
||||||
|
-- Aktivierungscode (Ablösung des bisherigen config.json-Eintrags)
|
||||||
|
|
||||||
|
INSERT INTO activation_codes (code, organization_id, active) VALUES
|
||||||
|
('LAB-2026-START', '11111111-1111-4111-8111-111111111111', TRUE);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
Loading…
x
Reference in New Issue
Block a user