command-gate 0.2.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cgate/__init__.py +26 -0
- cgate/__main__.py +8 -0
- cgate/_version.py +24 -0
- cgate/cli/__init__.py +1 -0
- cgate/cli/_console.py +17 -0
- cgate/cli/connections.py +212 -0
- cgate/cli/history.py +191 -0
- cgate/cli/install.py +182 -0
- cgate/cli/main.py +115 -0
- cgate/cli/mcp.py +197 -0
- cgate/cli/uninstall.py +403 -0
- cgate/cli/update.py +538 -0
- cgate/cli/watch.py +20 -0
- cgate/connections/__init__.py +1 -0
- cgate/connections/auth.py +93 -0
- cgate/connections/detect.py +78 -0
- cgate/connections/store.py +88 -0
- cgate/core/__init__.py +1 -0
- cgate/core/path_env.py +218 -0
- cgate/core/paths.py +35 -0
- cgate/core/update_log.py +36 -0
- cgate/db/__init__.py +1 -0
- cgate/db/batches.py +111 -0
- cgate/db/commands.py +191 -0
- cgate/db/connection.py +104 -0
- cgate/db/mode.py +74 -0
- cgate/db/rows.py +99 -0
- cgate/db/schema.py +54 -0
- cgate/db/server_settings.py +105 -0
- cgate/db/types.py +77 -0
- cgate/executor/__init__.py +7 -0
- cgate/executor/base.py +71 -0
- cgate/executor/selector.py +61 -0
- cgate/executor/ssh.py +157 -0
- cgate/executor/winrm.py +129 -0
- cgate/helper/__init__.py +10 -0
- cgate/helper/__main__.py +112 -0
- cgate/helper/waiter.py +123 -0
- cgate/mcp_installer.py +161 -0
- cgate/mcp_server/__init__.py +6 -0
- cgate/mcp_server/__main__.py +6 -0
- cgate/mcp_server/auto_resolution.py +80 -0
- cgate/mcp_server/server.py +271 -0
- cgate/mcp_server/tools.py +351 -0
- cgate/risk.py +129 -0
- cgate/update.py +713 -0
- cgate/watch/__init__.py +7 -0
- cgate/watch/app.py +560 -0
- cgate/watch/approval.py +237 -0
- cgate/watch/command_detail_modal.py +68 -0
- cgate/watch/history_modal.py +242 -0
- cgate/watch/mode_modal.py +110 -0
- cgate/watch/queue.py +106 -0
- cgate/watch/render.py +156 -0
- cgate/watch/server_settings_modal.py +179 -0
- cgate/watch/session.py +40 -0
- cgate/watch/theme.py +32 -0
- cgate/watch/widgets.py +35 -0
- command_gate-0.2.4.dist-info/METADATA +204 -0
- command_gate-0.2.4.dist-info/RECORD +63 -0
- command_gate-0.2.4.dist-info/WHEEL +4 -0
- command_gate-0.2.4.dist-info/entry_points.txt +2 -0
- command_gate-0.2.4.dist-info/licenses/LICENSE +21 -0
cgate/db/commands.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""CommandsRepo: CRUD + status transitions for the `commands` table."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from datetime import UTC, datetime
|
|
5
|
+
from uuid import uuid4
|
|
6
|
+
|
|
7
|
+
from cgate.db.connection import Database, connect
|
|
8
|
+
from cgate.db.rows import col_int, iso, row_to_command
|
|
9
|
+
from cgate.db.types import (
|
|
10
|
+
BatchId,
|
|
11
|
+
Command,
|
|
12
|
+
CommandId,
|
|
13
|
+
CommandStatus,
|
|
14
|
+
ServerType,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
_TERMINAL_STATUSES: frozenset[CommandStatus] = frozenset(
|
|
18
|
+
{CommandStatus.EXECUTED, CommandStatus.REJECTED, CommandStatus.FAILED}
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def all_terminal(commands_for_batch: list[Command]) -> bool:
|
|
23
|
+
"""Return whether every command in the list has reached a terminal status.
|
|
24
|
+
|
|
25
|
+
Shared by the watch TUI's own approve/reject path and the MCP AUTO-mode
|
|
26
|
+
auto-execution path, both of which need to decide when a batch is done.
|
|
27
|
+
"""
|
|
28
|
+
return all(command.status in _TERMINAL_STATUSES for command in commands_for_batch)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class CommandsRepo:
|
|
32
|
+
"""Command CRUD + per-batch listing + status transitions."""
|
|
33
|
+
|
|
34
|
+
_db: Database # class-level annotation required by strict mode
|
|
35
|
+
|
|
36
|
+
def __init__(self, db: Database) -> None:
|
|
37
|
+
"""Store the database handle for subsequent operations."""
|
|
38
|
+
self._db = db
|
|
39
|
+
|
|
40
|
+
def add( # noqa: PLR0913 - one param per persisted command field, all but the first three optional
|
|
41
|
+
self,
|
|
42
|
+
*,
|
|
43
|
+
batch_id: BatchId,
|
|
44
|
+
server_alias: str,
|
|
45
|
+
server_type: ServerType,
|
|
46
|
+
command: str,
|
|
47
|
+
reason: str | None = None,
|
|
48
|
+
risk_label: str | None = None,
|
|
49
|
+
) -> Command:
|
|
50
|
+
"""Append a command to the end of a batch (position = max + 1, or 0 if empty).
|
|
51
|
+
|
|
52
|
+
Computes the position in the same statement as the INSERT (issue
|
|
53
|
+
#10) rather than a separate SELECT beforehand: two connections
|
|
54
|
+
racing the old SELECT-then-INSERT could both compute the same
|
|
55
|
+
``next_pos`` before either committed, and the second INSERT would
|
|
56
|
+
die on the ``UNIQUE (batch_id, position)`` constraint. A single
|
|
57
|
+
``INSERT ... SELECT`` is one write that SQLite serializes against
|
|
58
|
+
other writers, so the second racer recomputes against the first
|
|
59
|
+
racer's already-committed row instead of colliding with it.
|
|
60
|
+
"""
|
|
61
|
+
command_id = CommandId(str(uuid4()))
|
|
62
|
+
now = datetime.now(UTC)
|
|
63
|
+
with connect(self._db) as conn:
|
|
64
|
+
_ = conn.execute(
|
|
65
|
+
"""
|
|
66
|
+
INSERT INTO commands
|
|
67
|
+
(id, batch_id, position, server_alias, server_type,
|
|
68
|
+
command, status, created_at, reason, risk_label)
|
|
69
|
+
SELECT ?, ?, COALESCE(MAX(position), -1) + 1, ?, ?, ?, ?, ?, ?, ?
|
|
70
|
+
FROM commands WHERE batch_id = ?
|
|
71
|
+
""",
|
|
72
|
+
(
|
|
73
|
+
command_id,
|
|
74
|
+
batch_id,
|
|
75
|
+
server_alias,
|
|
76
|
+
server_type.value,
|
|
77
|
+
command,
|
|
78
|
+
CommandStatus.PENDING.value,
|
|
79
|
+
iso(now),
|
|
80
|
+
reason,
|
|
81
|
+
risk_label,
|
|
82
|
+
batch_id,
|
|
83
|
+
),
|
|
84
|
+
)
|
|
85
|
+
pos_row = conn.execute(
|
|
86
|
+
"SELECT position FROM commands WHERE id = ?", (command_id,)
|
|
87
|
+
).fetchone()
|
|
88
|
+
if pos_row is None:
|
|
89
|
+
msg = "inserted command disappeared before its position could be read"
|
|
90
|
+
raise RuntimeError(msg)
|
|
91
|
+
next_pos = col_int(pos_row, "position")
|
|
92
|
+
return Command(
|
|
93
|
+
id=command_id,
|
|
94
|
+
batch_id=batch_id,
|
|
95
|
+
position=next_pos,
|
|
96
|
+
server_alias=server_alias,
|
|
97
|
+
server_type=server_type,
|
|
98
|
+
command=command,
|
|
99
|
+
status=CommandStatus.PENDING,
|
|
100
|
+
result=None,
|
|
101
|
+
approved_by=None,
|
|
102
|
+
created_at=now,
|
|
103
|
+
resolved_at=None,
|
|
104
|
+
reason=reason,
|
|
105
|
+
risk_label=risk_label,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
def get(self, command_id: CommandId) -> Command | None:
|
|
109
|
+
"""Fetch a command by ID, or None if not found."""
|
|
110
|
+
with connect(self._db) as conn:
|
|
111
|
+
row = conn.execute(
|
|
112
|
+
"""
|
|
113
|
+
SELECT id, batch_id, position, server_alias, server_type,
|
|
114
|
+
command, status, result, approved_by, created_at, resolved_at,
|
|
115
|
+
reason, risk_label
|
|
116
|
+
FROM commands WHERE id = ?
|
|
117
|
+
""",
|
|
118
|
+
(command_id,),
|
|
119
|
+
).fetchone()
|
|
120
|
+
return row_to_command(row) if row is not None else None
|
|
121
|
+
|
|
122
|
+
def list_for_batch(self, batch_id: BatchId) -> list[Command]:
|
|
123
|
+
"""All commands in a batch, ordered by position."""
|
|
124
|
+
with connect(self._db) as conn:
|
|
125
|
+
rows = conn.execute(
|
|
126
|
+
"""
|
|
127
|
+
SELECT id, batch_id, position, server_alias, server_type,
|
|
128
|
+
command, status, result, approved_by, created_at, resolved_at,
|
|
129
|
+
reason, risk_label
|
|
130
|
+
FROM commands WHERE batch_id = ?
|
|
131
|
+
ORDER BY position ASC
|
|
132
|
+
""",
|
|
133
|
+
(batch_id,),
|
|
134
|
+
).fetchall()
|
|
135
|
+
return [row_to_command(r) for r in rows]
|
|
136
|
+
|
|
137
|
+
def list_by_status(self, status: CommandStatus) -> list[Command]:
|
|
138
|
+
"""All commands currently in the given status, across every batch."""
|
|
139
|
+
with connect(self._db) as conn:
|
|
140
|
+
rows = conn.execute(
|
|
141
|
+
"""
|
|
142
|
+
SELECT id, batch_id, position, server_alias, server_type,
|
|
143
|
+
command, status, result, approved_by, created_at, resolved_at,
|
|
144
|
+
reason, risk_label
|
|
145
|
+
FROM commands WHERE status = ?
|
|
146
|
+
""",
|
|
147
|
+
(status.value,),
|
|
148
|
+
).fetchall()
|
|
149
|
+
return [row_to_command(r) for r in rows]
|
|
150
|
+
|
|
151
|
+
def update_status(
|
|
152
|
+
self,
|
|
153
|
+
command_id: CommandId,
|
|
154
|
+
*,
|
|
155
|
+
status: CommandStatus,
|
|
156
|
+
approved_by: str | None = None,
|
|
157
|
+
result: str | None = None,
|
|
158
|
+
expected_status: CommandStatus | None = None,
|
|
159
|
+
) -> bool:
|
|
160
|
+
"""Transition a command's status; stamp resolved_at iff status is terminal.
|
|
161
|
+
|
|
162
|
+
When ``expected_status`` is given, the UPDATE only applies if the
|
|
163
|
+
row's current status still matches it -- a compare-and-swap guard
|
|
164
|
+
against a concurrent transition racing this one (issue #13; not
|
|
165
|
+
exploitable in today's single-process synchronous `watch`, but a
|
|
166
|
+
cheap guard against a future daemon/concurrent mode). Returns
|
|
167
|
+
whether the row was actually updated.
|
|
168
|
+
"""
|
|
169
|
+
guard = " AND status = ?" if expected_status is not None else ""
|
|
170
|
+
guard_params = (expected_status.value,) if expected_status is not None else ()
|
|
171
|
+
with connect(self._db) as conn:
|
|
172
|
+
if status in _TERMINAL_STATUSES:
|
|
173
|
+
cursor = conn.execute(
|
|
174
|
+
"UPDATE commands SET status = ?, approved_by = ?, result = ?, " # noqa: S608 -- guard is one of two fixed literals, never user input
|
|
175
|
+
f"resolved_at = ? WHERE id = ?{guard}",
|
|
176
|
+
(
|
|
177
|
+
status.value,
|
|
178
|
+
approved_by,
|
|
179
|
+
result,
|
|
180
|
+
iso(datetime.now(UTC)),
|
|
181
|
+
command_id,
|
|
182
|
+
*guard_params,
|
|
183
|
+
),
|
|
184
|
+
)
|
|
185
|
+
else:
|
|
186
|
+
cursor = conn.execute(
|
|
187
|
+
"UPDATE commands SET status = ?, approved_by = ?, result = ? " # noqa: S608 -- guard is one of two fixed literals, never user input
|
|
188
|
+
f"WHERE id = ?{guard}",
|
|
189
|
+
(status.value, approved_by, result, command_id, *guard_params),
|
|
190
|
+
)
|
|
191
|
+
return cursor.rowcount > 0
|
cgate/db/connection.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""SQLite connection wrapper and schema initialization."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import sqlite3
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from cgate.db.schema import SCHEMA_SQL, SCHEMA_VERSION
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from collections.abc import Generator
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
# Auto-approve mode tables (feature: modes + per-server opt-in). Kept out of
|
|
17
|
+
# schema.py's Phase-1 SCHEMA_SQL so the original schema constant stays frozen;
|
|
18
|
+
# both statements are IF NOT EXISTS, so init stays idempotent. No default
|
|
19
|
+
# app_mode row is seeded: absence means "unset" and AppModeRepo.get() raises.
|
|
20
|
+
_MODE_SETTINGS_SQL = """
|
|
21
|
+
CREATE TABLE IF NOT EXISTS app_mode (
|
|
22
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
23
|
+
mode TEXT NOT NULL CHECK (mode IN ('propose','auto')),
|
|
24
|
+
updated_at TEXT NOT NULL,
|
|
25
|
+
updated_by TEXT NOT NULL
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
CREATE TABLE IF NOT EXISTS server_settings (
|
|
29
|
+
server_alias TEXT PRIMARY KEY,
|
|
30
|
+
auto_allowed INTEGER NOT NULL DEFAULT 0 CHECK (auto_allowed IN (0,1)),
|
|
31
|
+
updated_at TEXT,
|
|
32
|
+
updated_by TEXT
|
|
33
|
+
);
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True, slots=True)
|
|
38
|
+
class Database:
|
|
39
|
+
"""A reference to a SQLite database file on disk."""
|
|
40
|
+
|
|
41
|
+
path: Path
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@contextmanager
|
|
45
|
+
def connect(database: Database) -> Generator[sqlite3.Connection, None, None]:
|
|
46
|
+
"""Open a SQLite connection. Commits on clean exit, rolls back on exception.
|
|
47
|
+
|
|
48
|
+
Sets row_factory=sqlite3.Row for column-name access, and PRAGMA foreign_keys=ON
|
|
49
|
+
so FK references in the schema are enforced.
|
|
50
|
+
|
|
51
|
+
``timeout=30`` (sqlite3's default is 5s) because `cgate watch` and the
|
|
52
|
+
MCP server backing an AI agent's AUTO-mode auto-execution are two
|
|
53
|
+
separate processes that can now write to the same file at the same
|
|
54
|
+
time -- a short busy-timeout would surface as a spurious "database is
|
|
55
|
+
locked" error in the TUI under nothing worse than ordinary contention.
|
|
56
|
+
"""
|
|
57
|
+
conn = sqlite3.connect(database.path, timeout=30)
|
|
58
|
+
conn.row_factory = sqlite3.Row
|
|
59
|
+
_ = conn.execute("PRAGMA foreign_keys = ON")
|
|
60
|
+
try:
|
|
61
|
+
yield conn
|
|
62
|
+
conn.commit()
|
|
63
|
+
except BaseException:
|
|
64
|
+
conn.rollback()
|
|
65
|
+
raise
|
|
66
|
+
finally:
|
|
67
|
+
conn.close()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _ensure_column(conn: sqlite3.Connection, *, table: str, column: str, sql_type: str) -> None:
|
|
71
|
+
"""Add one column to an existing table if it isn't already there.
|
|
72
|
+
|
|
73
|
+
Unlike the ``CREATE TABLE IF NOT EXISTS`` statements everywhere else
|
|
74
|
+
in this file, SQLite's ``ALTER TABLE ... ADD COLUMN`` has no
|
|
75
|
+
``IF NOT EXISTS`` form, so idempotency has to be a live
|
|
76
|
+
``PRAGMA table_info`` check instead of a fixed SQL string.
|
|
77
|
+
"""
|
|
78
|
+
# table/column/sql_type are always fixed literals from call sites below, never user input.
|
|
79
|
+
columns = {row["name"] for row in conn.execute(f"PRAGMA table_info({table})")}
|
|
80
|
+
if column not in columns:
|
|
81
|
+
_ = conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {sql_type}")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def init_database(database: Database) -> None:
|
|
85
|
+
"""Create parent dirs (if needed) and apply the schema; idempotent.
|
|
86
|
+
|
|
87
|
+
Uses ``INSERT OR IGNORE`` rather than a SELECT-then-INSERT (issue
|
|
88
|
+
#11): two ``cgate`` processes launched concurrently against a brand
|
|
89
|
+
new database file could otherwise both pass the SELECT before either
|
|
90
|
+
committed, then collide on the second's INSERT into the
|
|
91
|
+
``version`` PRIMARY KEY. ``OR IGNORE`` makes the row idempotent in a
|
|
92
|
+
single statement instead.
|
|
93
|
+
"""
|
|
94
|
+
database.path.parent.mkdir(parents=True, exist_ok=True)
|
|
95
|
+
with connect(database) as conn:
|
|
96
|
+
_ = conn.executescript(SCHEMA_SQL)
|
|
97
|
+
_ = conn.executescript(_MODE_SETTINGS_SQL)
|
|
98
|
+
_ensure_column(conn, table="commands", column="reason", sql_type="TEXT")
|
|
99
|
+
_ensure_column(conn, table="commands", column="risk_label", sql_type="TEXT")
|
|
100
|
+
applied_at = datetime.now(UTC).isoformat().replace("+00:00", "Z")
|
|
101
|
+
_ = conn.execute(
|
|
102
|
+
"INSERT OR IGNORE INTO schema_version (version, applied_at) VALUES (?, ?)",
|
|
103
|
+
(SCHEMA_VERSION, applied_at),
|
|
104
|
+
)
|
cgate/db/mode.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""AppModeRepo: read/upsert the single-row `app_mode` table (global behavior mode)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
|
|
8
|
+
from cgate.db.connection import Database, connect
|
|
9
|
+
from cgate.db.rows import col_str, iso, parse
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Mode(StrEnum):
|
|
13
|
+
"""Global behavior mode: queue every proposal, or allow per-server auto-execution."""
|
|
14
|
+
|
|
15
|
+
PROPOSE = "propose"
|
|
16
|
+
AUTO = "auto"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AppModeNotSetError(LookupError):
|
|
20
|
+
"""The app_mode row is absent: the global mode has never been explicitly set."""
|
|
21
|
+
|
|
22
|
+
def __init__(self) -> None:
|
|
23
|
+
"""Create the error with a fixed human-readable message."""
|
|
24
|
+
super().__init__("app mode is not set")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True, slots=True)
|
|
28
|
+
class AppMode:
|
|
29
|
+
"""The singleton global mode row (id = 1) with its audit fields."""
|
|
30
|
+
|
|
31
|
+
mode: Mode
|
|
32
|
+
updated_at: datetime
|
|
33
|
+
updated_by: str
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class AppModeRepo:
|
|
37
|
+
"""Read/upsert the single-row app_mode table; absence means "unset"."""
|
|
38
|
+
|
|
39
|
+
_db: Database # class-level annotation required by strict mode
|
|
40
|
+
|
|
41
|
+
def __init__(self, db: Database) -> None:
|
|
42
|
+
"""Store the database handle for subsequent operations."""
|
|
43
|
+
self._db = db
|
|
44
|
+
|
|
45
|
+
def get(self) -> AppMode:
|
|
46
|
+
"""Fetch the global mode, raising AppModeNotSetError if never set."""
|
|
47
|
+
with connect(self._db) as conn:
|
|
48
|
+
row = conn.execute(
|
|
49
|
+
"SELECT mode, updated_at, updated_by FROM app_mode WHERE id = 1"
|
|
50
|
+
).fetchone()
|
|
51
|
+
if row is None:
|
|
52
|
+
raise AppModeNotSetError
|
|
53
|
+
return AppMode(
|
|
54
|
+
mode=Mode(col_str(row, "mode")),
|
|
55
|
+
updated_at=parse(col_str(row, "updated_at")),
|
|
56
|
+
updated_by=col_str(row, "updated_by"),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
def set(self, *, mode: Mode, updated_by: str) -> AppMode:
|
|
60
|
+
"""Upsert the singleton row and return the value now in effect."""
|
|
61
|
+
now = datetime.now(UTC)
|
|
62
|
+
with connect(self._db) as conn:
|
|
63
|
+
_ = conn.execute(
|
|
64
|
+
"""
|
|
65
|
+
INSERT INTO app_mode (id, mode, updated_at, updated_by)
|
|
66
|
+
VALUES (1, ?, ?, ?)
|
|
67
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
68
|
+
mode = excluded.mode,
|
|
69
|
+
updated_at = excluded.updated_at,
|
|
70
|
+
updated_by = excluded.updated_by
|
|
71
|
+
""",
|
|
72
|
+
(mode.value, iso(now), updated_by),
|
|
73
|
+
)
|
|
74
|
+
return AppMode(mode=mode, updated_at=now, updated_by=updated_by)
|
cgate/db/rows.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""SQLite row parsing: read raw rows and convert them into typed domain values."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from datetime import UTC, datetime
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from cgate.db.types import (
|
|
8
|
+
Batch,
|
|
9
|
+
BatchId,
|
|
10
|
+
Command,
|
|
11
|
+
CommandId,
|
|
12
|
+
CommandStatus,
|
|
13
|
+
Connection,
|
|
14
|
+
ServerType,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
import sqlite3
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def iso(dt: datetime) -> str:
|
|
22
|
+
"""Serialize a datetime to ISO-8601 UTC with a Z suffix."""
|
|
23
|
+
if dt.tzinfo is None:
|
|
24
|
+
dt = dt.replace(tzinfo=UTC)
|
|
25
|
+
return dt.astimezone(UTC).isoformat().replace("+00:00", "Z")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def parse(s: str) -> datetime:
|
|
29
|
+
"""Parse an ISO-8601 timestamp (Z suffix supported) into a tz-aware datetime."""
|
|
30
|
+
return datetime.fromisoformat(s)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def col_str(row: sqlite3.Row, col: str) -> str:
|
|
34
|
+
"""Read a NOT NULL TEXT column from a row as str."""
|
|
35
|
+
return str(row[col])
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def col_opt_str(row: sqlite3.Row, col: str) -> str | None:
|
|
39
|
+
"""Read a nullable TEXT column from a row; None for SQL NULL."""
|
|
40
|
+
value: object = row[col]
|
|
41
|
+
if value is None:
|
|
42
|
+
return None
|
|
43
|
+
return str(value)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def col_int(row: sqlite3.Row, col: str) -> int:
|
|
47
|
+
"""Read an INTEGER column from a row as int."""
|
|
48
|
+
return int(str(row[col]))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def col_opt_dt(row: sqlite3.Row, col: str) -> datetime | None:
|
|
52
|
+
"""Read a nullable TIMESTAMP column from a row as datetime."""
|
|
53
|
+
raw: object = row[col]
|
|
54
|
+
if raw is None:
|
|
55
|
+
return None
|
|
56
|
+
return parse(str(raw))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def row_to_batch(row: sqlite3.Row) -> Batch:
|
|
60
|
+
"""Convert a SQLite row into a typed Batch value object."""
|
|
61
|
+
return Batch(
|
|
62
|
+
id=BatchId(col_str(row, "id")),
|
|
63
|
+
title=col_str(row, "title"),
|
|
64
|
+
description=col_opt_str(row, "description"),
|
|
65
|
+
requested_by_agent=col_opt_str(row, "requested_by_agent"),
|
|
66
|
+
created_at=parse(col_str(row, "created_at")),
|
|
67
|
+
resolved_at=col_opt_dt(row, "resolved_at"),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def row_to_command(row: sqlite3.Row) -> Command:
|
|
72
|
+
"""Convert a SQLite row into a typed Command value object."""
|
|
73
|
+
return Command(
|
|
74
|
+
id=CommandId(col_str(row, "id")),
|
|
75
|
+
batch_id=BatchId(col_str(row, "batch_id")),
|
|
76
|
+
position=col_int(row, "position"),
|
|
77
|
+
server_alias=col_str(row, "server_alias"),
|
|
78
|
+
server_type=ServerType(col_str(row, "server_type")),
|
|
79
|
+
command=col_str(row, "command"),
|
|
80
|
+
status=CommandStatus(col_str(row, "status")),
|
|
81
|
+
result=col_opt_str(row, "result"),
|
|
82
|
+
approved_by=col_opt_str(row, "approved_by"),
|
|
83
|
+
created_at=parse(col_str(row, "created_at")),
|
|
84
|
+
resolved_at=col_opt_dt(row, "resolved_at"),
|
|
85
|
+
reason=col_opt_str(row, "reason"),
|
|
86
|
+
risk_label=col_opt_str(row, "risk_label"),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def row_to_connection(row: sqlite3.Row) -> Connection:
|
|
91
|
+
"""Convert a SQLite row into a typed Connection value object."""
|
|
92
|
+
return Connection(
|
|
93
|
+
alias=col_str(row, "alias"),
|
|
94
|
+
hostname=col_str(row, "hostname"),
|
|
95
|
+
server_type=ServerType(col_str(row, "server_type")),
|
|
96
|
+
detection_ssh=bool(col_int(row, "detection_ssh")),
|
|
97
|
+
detection_winrm=bool(col_int(row, "detection_winrm")),
|
|
98
|
+
created_at=parse(col_str(row, "created_at")),
|
|
99
|
+
)
|
cgate/db/schema.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""SQL schema for command-gate (Phase 1, SQLite).
|
|
2
|
+
|
|
3
|
+
Source of truth: spec-inicial.md, "Esquema de datos (SQLite)" section.
|
|
4
|
+
Designed to migrate to SQL Server in Phase 2 with the same columns.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
SCHEMA_VERSION = 2
|
|
9
|
+
|
|
10
|
+
SCHEMA_SQL = """
|
|
11
|
+
CREATE TABLE IF NOT EXISTS schema_version (
|
|
12
|
+
version INTEGER PRIMARY KEY,
|
|
13
|
+
applied_at TEXT NOT NULL
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
CREATE TABLE IF NOT EXISTS batches (
|
|
17
|
+
id TEXT PRIMARY KEY,
|
|
18
|
+
title TEXT NOT NULL,
|
|
19
|
+
description TEXT,
|
|
20
|
+
requested_by_agent TEXT,
|
|
21
|
+
created_at TEXT NOT NULL,
|
|
22
|
+
resolved_at TEXT
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
CREATE TABLE IF NOT EXISTS commands (
|
|
26
|
+
id TEXT PRIMARY KEY,
|
|
27
|
+
batch_id TEXT NOT NULL REFERENCES batches(id),
|
|
28
|
+
position INTEGER NOT NULL,
|
|
29
|
+
server_alias TEXT NOT NULL,
|
|
30
|
+
server_type TEXT NOT NULL CHECK (server_type IN ('windows', 'linux')),
|
|
31
|
+
command TEXT NOT NULL,
|
|
32
|
+
status TEXT NOT NULL CHECK (
|
|
33
|
+
status IN ('pending', 'approved', 'rejected', 'executed', 'failed')
|
|
34
|
+
),
|
|
35
|
+
result TEXT,
|
|
36
|
+
approved_by TEXT,
|
|
37
|
+
created_at TEXT NOT NULL,
|
|
38
|
+
resolved_at TEXT,
|
|
39
|
+
UNIQUE (batch_id, position)
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
CREATE INDEX IF NOT EXISTS idx_commands_batch_id ON commands(batch_id);
|
|
43
|
+
CREATE INDEX IF NOT EXISTS idx_commands_status ON commands(status);
|
|
44
|
+
|
|
45
|
+
CREATE TABLE IF NOT EXISTS connections (
|
|
46
|
+
alias TEXT PRIMARY KEY,
|
|
47
|
+
hostname TEXT NOT NULL,
|
|
48
|
+
server_type TEXT NOT NULL CHECK (server_type IN ('windows', 'linux')),
|
|
49
|
+
detection_ssh INTEGER NOT NULL,
|
|
50
|
+
detection_winrm INTEGER NOT NULL,
|
|
51
|
+
created_at TEXT NOT NULL
|
|
52
|
+
);
|
|
53
|
+
CREATE INDEX IF NOT EXISTS idx_connections_server_type ON connections(server_type);
|
|
54
|
+
"""
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""ServerSettingsRepo: per-server auto-approve opt-in rows (`server_settings` table)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from cgate.db.connection import Database, connect
|
|
9
|
+
from cgate.db.rows import col_int, col_opt_dt, col_opt_str, col_str, iso
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
import sqlite3
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True, slots=True)
|
|
16
|
+
class ServerSetting:
|
|
17
|
+
"""Explicit auto-approve opt-in state for one server alias.
|
|
18
|
+
|
|
19
|
+
``updated_at``/``updated_by`` are None only for the implicit default
|
|
20
|
+
returned by ``get_or_default`` when no row exists -- persisted rows
|
|
21
|
+
always carry both audit fields.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
server_alias: str
|
|
25
|
+
auto_allowed: bool
|
|
26
|
+
updated_at: datetime | None
|
|
27
|
+
updated_by: str | None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _row_to_setting(row: sqlite3.Row) -> ServerSetting:
|
|
31
|
+
"""Convert a SQLite row into a typed ServerSetting value object."""
|
|
32
|
+
return ServerSetting(
|
|
33
|
+
server_alias=col_str(row, "server_alias"),
|
|
34
|
+
auto_allowed=bool(col_int(row, "auto_allowed")),
|
|
35
|
+
updated_at=col_opt_dt(row, "updated_at"),
|
|
36
|
+
updated_by=col_opt_str(row, "updated_by"),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ServerSettingsRepo:
|
|
41
|
+
"""Per-server opt-in CRUD; no row means auto-execute is NOT allowed."""
|
|
42
|
+
|
|
43
|
+
_db: Database # class-level annotation required by strict mode
|
|
44
|
+
|
|
45
|
+
def __init__(self, db: Database) -> None:
|
|
46
|
+
"""Store the database handle for subsequent operations."""
|
|
47
|
+
self._db = db
|
|
48
|
+
|
|
49
|
+
def get(self, alias: str) -> ServerSetting | None:
|
|
50
|
+
"""Fetch the explicit setting for an alias, or None if never set."""
|
|
51
|
+
with connect(self._db) as conn:
|
|
52
|
+
row = conn.execute(
|
|
53
|
+
"""
|
|
54
|
+
SELECT server_alias, auto_allowed, updated_at, updated_by
|
|
55
|
+
FROM server_settings WHERE server_alias = ?
|
|
56
|
+
""",
|
|
57
|
+
(alias,),
|
|
58
|
+
).fetchone()
|
|
59
|
+
return _row_to_setting(row) if row is not None else None
|
|
60
|
+
|
|
61
|
+
def get_or_default(self, alias: str) -> ServerSetting:
|
|
62
|
+
"""Fetch the setting, defaulting to auto_allowed=False when no row exists."""
|
|
63
|
+
setting = self.get(alias)
|
|
64
|
+
if setting is None:
|
|
65
|
+
return ServerSetting(
|
|
66
|
+
server_alias=alias,
|
|
67
|
+
auto_allowed=False,
|
|
68
|
+
updated_at=None,
|
|
69
|
+
updated_by=None,
|
|
70
|
+
)
|
|
71
|
+
return setting
|
|
72
|
+
|
|
73
|
+
def set(self, *, alias: str, auto_allowed: bool, updated_by: str) -> ServerSetting:
|
|
74
|
+
"""Upsert the opt-in row for an alias and return the value now in effect."""
|
|
75
|
+
now = datetime.now(UTC)
|
|
76
|
+
with connect(self._db) as conn:
|
|
77
|
+
_ = conn.execute(
|
|
78
|
+
"""
|
|
79
|
+
INSERT INTO server_settings
|
|
80
|
+
(server_alias, auto_allowed, updated_at, updated_by)
|
|
81
|
+
VALUES (?, ?, ?, ?)
|
|
82
|
+
ON CONFLICT(server_alias) DO UPDATE SET
|
|
83
|
+
auto_allowed = excluded.auto_allowed,
|
|
84
|
+
updated_at = excluded.updated_at,
|
|
85
|
+
updated_by = excluded.updated_by
|
|
86
|
+
""",
|
|
87
|
+
(alias, int(auto_allowed), iso(now), updated_by),
|
|
88
|
+
)
|
|
89
|
+
return ServerSetting(
|
|
90
|
+
server_alias=alias,
|
|
91
|
+
auto_allowed=auto_allowed,
|
|
92
|
+
updated_at=now,
|
|
93
|
+
updated_by=updated_by,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
def list_all(self) -> list[ServerSetting]:
|
|
97
|
+
"""Return every alias with an explicit setting, ordered by alias."""
|
|
98
|
+
with connect(self._db) as conn:
|
|
99
|
+
rows = conn.execute(
|
|
100
|
+
"""
|
|
101
|
+
SELECT server_alias, auto_allowed, updated_at, updated_by
|
|
102
|
+
FROM server_settings ORDER BY server_alias ASC
|
|
103
|
+
"""
|
|
104
|
+
).fetchall()
|
|
105
|
+
return [_row_to_setting(row) for row in rows]
|