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.
Files changed (63) hide show
  1. cgate/__init__.py +26 -0
  2. cgate/__main__.py +8 -0
  3. cgate/_version.py +24 -0
  4. cgate/cli/__init__.py +1 -0
  5. cgate/cli/_console.py +17 -0
  6. cgate/cli/connections.py +212 -0
  7. cgate/cli/history.py +191 -0
  8. cgate/cli/install.py +182 -0
  9. cgate/cli/main.py +115 -0
  10. cgate/cli/mcp.py +197 -0
  11. cgate/cli/uninstall.py +403 -0
  12. cgate/cli/update.py +538 -0
  13. cgate/cli/watch.py +20 -0
  14. cgate/connections/__init__.py +1 -0
  15. cgate/connections/auth.py +93 -0
  16. cgate/connections/detect.py +78 -0
  17. cgate/connections/store.py +88 -0
  18. cgate/core/__init__.py +1 -0
  19. cgate/core/path_env.py +218 -0
  20. cgate/core/paths.py +35 -0
  21. cgate/core/update_log.py +36 -0
  22. cgate/db/__init__.py +1 -0
  23. cgate/db/batches.py +111 -0
  24. cgate/db/commands.py +191 -0
  25. cgate/db/connection.py +104 -0
  26. cgate/db/mode.py +74 -0
  27. cgate/db/rows.py +99 -0
  28. cgate/db/schema.py +54 -0
  29. cgate/db/server_settings.py +105 -0
  30. cgate/db/types.py +77 -0
  31. cgate/executor/__init__.py +7 -0
  32. cgate/executor/base.py +71 -0
  33. cgate/executor/selector.py +61 -0
  34. cgate/executor/ssh.py +157 -0
  35. cgate/executor/winrm.py +129 -0
  36. cgate/helper/__init__.py +10 -0
  37. cgate/helper/__main__.py +112 -0
  38. cgate/helper/waiter.py +123 -0
  39. cgate/mcp_installer.py +161 -0
  40. cgate/mcp_server/__init__.py +6 -0
  41. cgate/mcp_server/__main__.py +6 -0
  42. cgate/mcp_server/auto_resolution.py +80 -0
  43. cgate/mcp_server/server.py +271 -0
  44. cgate/mcp_server/tools.py +351 -0
  45. cgate/risk.py +129 -0
  46. cgate/update.py +713 -0
  47. cgate/watch/__init__.py +7 -0
  48. cgate/watch/app.py +560 -0
  49. cgate/watch/approval.py +237 -0
  50. cgate/watch/command_detail_modal.py +68 -0
  51. cgate/watch/history_modal.py +242 -0
  52. cgate/watch/mode_modal.py +110 -0
  53. cgate/watch/queue.py +106 -0
  54. cgate/watch/render.py +156 -0
  55. cgate/watch/server_settings_modal.py +179 -0
  56. cgate/watch/session.py +40 -0
  57. cgate/watch/theme.py +32 -0
  58. cgate/watch/widgets.py +35 -0
  59. command_gate-0.2.4.dist-info/METADATA +204 -0
  60. command_gate-0.2.4.dist-info/RECORD +63 -0
  61. command_gate-0.2.4.dist-info/WHEEL +4 -0
  62. command_gate-0.2.4.dist-info/entry_points.txt +2 -0
  63. 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]