@ai-support-agent/cli 0.3.0 → 0.3.1-beta.0
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.
- package/ansible/callback_plugins/json.py +146 -0
- package/ansible/callback_plugins/tests/test_json_callback.py +171 -0
- package/ansible/playbook.yml +52 -0
- package/ansible/roles/database/tasks/main.yml +121 -0
- package/ansible/roles/dns_tls/tasks/main.yml +75 -0
- package/ansible/roles/dns_tls/templates/Caddyfile.j2 +4 -0
- package/ansible/roles/docker/tasks/main.yml +51 -0
- package/ansible/roles/os_init/tasks/main.yml +61 -0
- package/ansible/roles/web_server/tasks/main.yml +47 -0
- package/dist/api-client.d.ts +26 -1
- package/dist/api-client.d.ts.map +1 -1
- package/dist/api-client.js +40 -0
- package/dist/api-client.js.map +1 -1
- package/dist/commands/chat-executor.js +1 -1
- package/dist/commands/chat-executor.js.map +1 -1
- package/dist/commands/index.d.ts.map +1 -1
- package/dist/commands/index.js +14 -0
- package/dist/commands/index.js.map +1 -1
- package/dist/constants.d.ts +3 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +7 -0
- package/dist/constants.js.map +1 -1
- package/dist/mcp/config-writer.d.ts +24 -1
- package/dist/mcp/config-writer.d.ts.map +1 -1
- package/dist/mcp/config-writer.js +27 -2
- package/dist/mcp/config-writer.js.map +1 -1
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +4 -0
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/tools/send-slack-file.d.ts +5 -0
- package/dist/mcp/tools/send-slack-file.d.ts.map +1 -0
- package/dist/mcp/tools/send-slack-file.js +22 -0
- package/dist/mcp/tools/send-slack-file.js.map +1 -0
- package/dist/mcp/tools/update-system-knowledge.d.ts +29 -0
- package/dist/mcp/tools/update-system-knowledge.d.ts.map +1 -0
- package/dist/mcp/tools/update-system-knowledge.js +63 -0
- package/dist/mcp/tools/update-system-knowledge.js.map +1 -0
- package/dist/oneshot-runner.d.ts.map +1 -1
- package/dist/oneshot-runner.js +5 -2
- package/dist/oneshot-runner.js.map +1 -1
- package/dist/server-setup/known-hosts-store.d.ts +16 -0
- package/dist/server-setup/known-hosts-store.d.ts.map +1 -0
- package/dist/server-setup/known-hosts-store.js +89 -0
- package/dist/server-setup/known-hosts-store.js.map +1 -0
- package/dist/server-setup/server-setup-runner.d.ts +27 -0
- package/dist/server-setup/server-setup-runner.d.ts.map +1 -0
- package/dist/server-setup/server-setup-runner.js +576 -0
- package/dist/server-setup/server-setup-runner.js.map +1 -0
- package/dist/types/command.d.ts +5 -1
- package/dist/types/command.d.ts.map +1 -1
- package/dist/types/command.js.map +1 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +1 -0
- package/dist/types/index.js.map +1 -1
- package/dist/types/server-setup.d.ts +30 -0
- package/dist/types/server-setup.d.ts.map +1 -0
- package/dist/types/server-setup.js +12 -0
- package/dist/types/server-setup.js.map +1 -0
- package/dist/types/tool-output.d.ts +71 -0
- package/dist/types/tool-output.d.ts.map +1 -1
- package/docker/Dockerfile +30 -1
- package/package.json +2 -1
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Bundled `ANSIBLE_STDOUT_CALLBACK=json` stdout callback.
|
|
2
|
+
|
|
3
|
+
WHY THIS FILE EXISTS: ansible-core does not ship a `json` stdout callback
|
|
4
|
+
plugin (confirmed absent from `ansible-core`'s bundled `callback_plugins/`
|
|
5
|
+
directory across the 2.x line at the time this file was written — see
|
|
6
|
+
`ansible-doc -t callback -l`, which does not list one). Before this file was
|
|
7
|
+
added, `src/server-setup/server-setup-runner.ts` set
|
|
8
|
+
`ANSIBLE_STDOUT_CALLBACK=json` and got no matching plugin, silently falling
|
|
9
|
+
back to the human-readable `default` callback; `parseStepResults()` would
|
|
10
|
+
then fail to `JSON.parse` that output and report *every* requested step as
|
|
11
|
+
"skipped" regardless of what actually happened on the target host.
|
|
12
|
+
|
|
13
|
+
Ansible auto-discovers callback plugins from a `callback_plugins/`
|
|
14
|
+
directory next to the running playbook (this file lives at
|
|
15
|
+
`ansible/callback_plugins/json.py`, next to `ansible/playbook.yml`), so no
|
|
16
|
+
collection install or extra configuration is required to pick this up.
|
|
17
|
+
|
|
18
|
+
OUTPUT CONTRACT: emits exactly one line of JSON at the end of the run,
|
|
19
|
+
shaped as
|
|
20
|
+
{"plays": [{"task": {"name": <task name>},
|
|
21
|
+
"hosts": {<hostname>: {"changed":, "failed":, "skipped":,
|
|
22
|
+
"msg":, ...other module fields}}}]}
|
|
23
|
+
matching the `AnsibleJsonOutput` shape parsed by
|
|
24
|
+
`parseStepResults()` in `src/server-setup/server-setup-runner.ts`. Task
|
|
25
|
+
names in the bundled roles are prefixed `"<stepType> : <description>"`, and
|
|
26
|
+
`parseStepResults()` groups task outcomes back to a step by taking the text
|
|
27
|
+
before the first `:` in the task name.
|
|
28
|
+
"""
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import json
|
|
32
|
+
|
|
33
|
+
from ansible.plugins.callback import CallbackBase
|
|
34
|
+
|
|
35
|
+
DOCUMENTATION = r"""
|
|
36
|
+
callback: json
|
|
37
|
+
type: stdout
|
|
38
|
+
short_description: Bundled JSON stdout callback for server-setup-exec
|
|
39
|
+
description:
|
|
40
|
+
- Emits a single JSON document at the end of the run grouping
|
|
41
|
+
per-task, per-host results, consumed by the agent CLI's
|
|
42
|
+
server_setup_exec command (src/server-setup/server-setup-runner.ts).
|
|
43
|
+
- Bundled here because ansible-core does not ship a `json` stdout
|
|
44
|
+
callback plugin out of the box.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class CallbackModule(CallbackBase):
|
|
49
|
+
"""Accumulate per-task/per-host results and dump them as one JSON blob."""
|
|
50
|
+
|
|
51
|
+
CALLBACK_VERSION = 2.0
|
|
52
|
+
CALLBACK_TYPE = 'stdout'
|
|
53
|
+
CALLBACK_NAME = 'json'
|
|
54
|
+
|
|
55
|
+
def __init__(self):
|
|
56
|
+
super().__init__()
|
|
57
|
+
self.results = {"plays": []}
|
|
58
|
+
self._current_play = None
|
|
59
|
+
self._current_task = None
|
|
60
|
+
|
|
61
|
+
def _new_task_entry(self, task_name):
|
|
62
|
+
entry = {"task": {"name": task_name}, "hosts": {}}
|
|
63
|
+
if self._current_play is not None:
|
|
64
|
+
self._current_play["tasks"].append(entry)
|
|
65
|
+
return entry
|
|
66
|
+
|
|
67
|
+
def v2_playbook_on_play_start(self, play):
|
|
68
|
+
self._current_play = {"play": {"name": play.get_name()}, "tasks": []}
|
|
69
|
+
self.results["plays"].append(self._current_play)
|
|
70
|
+
|
|
71
|
+
def v2_playbook_on_task_start(self, task, is_conditional):
|
|
72
|
+
self._current_task = self._new_task_entry(task.get_name())
|
|
73
|
+
|
|
74
|
+
def v2_playbook_on_handler_task_start(self, task):
|
|
75
|
+
self._current_task = self._new_task_entry(task.get_name())
|
|
76
|
+
|
|
77
|
+
@staticmethod
|
|
78
|
+
def _is_no_log(result, raw_result):
|
|
79
|
+
"""True if this result must be censored because of `no_log: true`.
|
|
80
|
+
|
|
81
|
+
IMPORTANT: `result._result` (the raw dict callbacks receive via
|
|
82
|
+
`v2_runner_on_*`) is NOT pre-censored by Ansible core. Core's
|
|
83
|
+
censorship logic lives in `TaskResult.clean_copy()`
|
|
84
|
+
(ansible/executor/task_result.py), but the strategy plugin
|
|
85
|
+
(ansible/plugins/strategy/__init__.py) passes the *original*,
|
|
86
|
+
uncensored `TaskResult` to `send_callback()` — `clean_copy()` is used
|
|
87
|
+
for other internal purposes (e.g. the `ansible_failed_result`
|
|
88
|
+
rescue/always fact), not for what reaches stdout callback plugins.
|
|
89
|
+
A callback plugin that assumes core already censored `_result` (as
|
|
90
|
+
this one previously did) will leak whatever `no_log: true` was
|
|
91
|
+
meant to hide — e.g. `db_root_password` from
|
|
92
|
+
ansible/roles/database/tasks/main.yml's `mysql_user`/
|
|
93
|
+
`postgresql_user` tasks — straight into this plugin's JSON output.
|
|
94
|
+
So this plugin must replicate (a minimal version of) that censorship
|
|
95
|
+
itself, checking both the task's own `no_log` flag and the
|
|
96
|
+
`_ansible_no_log` flag a module can set on its own result.
|
|
97
|
+
"""
|
|
98
|
+
task = getattr(result, "_task", None)
|
|
99
|
+
task_no_log = bool(getattr(task, "no_log", False)) if task is not None else False
|
|
100
|
+
result_no_log = bool(raw_result.get("_ansible_no_log", False)) if isinstance(raw_result, dict) else False
|
|
101
|
+
return task_no_log or result_no_log
|
|
102
|
+
|
|
103
|
+
def _record(self, result, *, failed, skipped):
|
|
104
|
+
host = result._host.get_name()
|
|
105
|
+
raw_result = dict(result._result)
|
|
106
|
+
if self._is_no_log(result, raw_result):
|
|
107
|
+
# Minimal, non-secret summary only — no module fields (which may
|
|
108
|
+
# include the no_log'd value or closely related data) are
|
|
109
|
+
# forwarded.
|
|
110
|
+
payload = {"censored": True, "changed": raw_result.get("changed", False)}
|
|
111
|
+
else:
|
|
112
|
+
payload = raw_result
|
|
113
|
+
payload["failed"] = failed
|
|
114
|
+
payload["skipped"] = skipped
|
|
115
|
+
payload.setdefault("changed", False)
|
|
116
|
+
if self._current_task is None:
|
|
117
|
+
# Defensive: a result arrived with no preceding task-start event.
|
|
118
|
+
self._current_task = self._new_task_entry(getattr(result, "task_name", "") or "")
|
|
119
|
+
self._current_task["hosts"][host] = payload
|
|
120
|
+
|
|
121
|
+
def v2_runner_on_ok(self, result):
|
|
122
|
+
self._record(result, failed=False, skipped=False)
|
|
123
|
+
|
|
124
|
+
def v2_runner_on_failed(self, result, ignore_errors=False):
|
|
125
|
+
self._record(result, failed=True, skipped=False)
|
|
126
|
+
|
|
127
|
+
def v2_runner_on_skipped(self, result):
|
|
128
|
+
self._record(result, failed=False, skipped=True)
|
|
129
|
+
|
|
130
|
+
def v2_runner_on_unreachable(self, result):
|
|
131
|
+
host = result._host.get_name()
|
|
132
|
+
raw_result = dict(result._result)
|
|
133
|
+
if self._is_no_log(result, raw_result):
|
|
134
|
+
payload = {"censored": True, "changed": raw_result.get("changed", False)}
|
|
135
|
+
else:
|
|
136
|
+
payload = raw_result
|
|
137
|
+
payload["unreachable"] = True
|
|
138
|
+
payload["failed"] = True
|
|
139
|
+
payload["skipped"] = False
|
|
140
|
+
payload.setdefault("changed", False)
|
|
141
|
+
if self._current_task is None:
|
|
142
|
+
self._current_task = self._new_task_entry(getattr(result, "task_name", "") or "")
|
|
143
|
+
self._current_task["hosts"][host] = payload
|
|
144
|
+
|
|
145
|
+
def v2_playbook_on_stats(self, stats):
|
|
146
|
+
self._display.display(json.dumps(self.results))
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Unit tests for the bundled `json` stdout callback plugin (json.py).
|
|
2
|
+
|
|
3
|
+
Focus: `no_log: true` must actually be respected by this plugin's own JSON
|
|
4
|
+
output. `result._result` (what `v2_runner_on_*` receives from Ansible's
|
|
5
|
+
strategy plugin) is NOT pre-censored by Ansible core — core's censorship
|
|
6
|
+
(`TaskResult.clean_copy()`) is used for other internal purposes, not for
|
|
7
|
+
what reaches callback plugins — so this plugin must apply its own
|
|
8
|
+
censorship, and these tests exist to prove it actually does.
|
|
9
|
+
|
|
10
|
+
Run with (requires ansible-core installed — see docker/Dockerfile's pinned
|
|
11
|
+
`ansible-core>=2.16,<2.18`, or `pip install 'ansible-core>=2.16,<2.18'` into
|
|
12
|
+
a local virtualenv):
|
|
13
|
+
|
|
14
|
+
python3 ansible/callback_plugins/tests/test_json_callback.py -v
|
|
15
|
+
|
|
16
|
+
Run as a plain script (NOT via `python3 -m unittest <dotted.path>` from the
|
|
17
|
+
repo root, and NOT from inside `ansible/callback_plugins/` itself): this
|
|
18
|
+
repo's top-level `ansible/` directory would shadow the real `ansible`
|
|
19
|
+
package on `sys.path`, and the plugin under test is itself named `json.py`,
|
|
20
|
+
which would shadow the stdlib `json` module if its own directory ended up
|
|
21
|
+
on `sys.path`. Running this file directly puts only *this* `tests/`
|
|
22
|
+
directory on `sys.path[0]`, avoiding both collisions; `json.py` itself is
|
|
23
|
+
loaded by absolute file path via `importlib`, independent of `sys.path`.
|
|
24
|
+
|
|
25
|
+
Loads json.py directly by file path (rather than relying on package/
|
|
26
|
+
collection discovery) since this plugin is only ever auto-discovered by
|
|
27
|
+
ansible-core at `ansible-playbook` runtime via the `callback_plugins/`
|
|
28
|
+
directory convention, not importable as a regular Python package.
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import importlib.util
|
|
33
|
+
import json
|
|
34
|
+
import os
|
|
35
|
+
import unittest
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _load_callback_module_class():
|
|
39
|
+
plugin_path = os.path.join(os.path.dirname(__file__), "..", "json.py")
|
|
40
|
+
spec = importlib.util.spec_from_file_location("server_setup_json_stdout_callback", plugin_path)
|
|
41
|
+
assert spec is not None and spec.loader is not None
|
|
42
|
+
module = importlib.util.module_from_spec(spec)
|
|
43
|
+
spec.loader.exec_module(module)
|
|
44
|
+
return module.CallbackModule
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class _FakeNamed:
|
|
48
|
+
"""Minimal stand-in for anything exposing ansible-core's `get_name()`."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, name):
|
|
51
|
+
self._name = name
|
|
52
|
+
|
|
53
|
+
def get_name(self):
|
|
54
|
+
return self._name
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class _FakeTask:
|
|
58
|
+
def __init__(self, name, no_log=False):
|
|
59
|
+
self._name = name
|
|
60
|
+
self.no_log = no_log
|
|
61
|
+
|
|
62
|
+
def get_name(self):
|
|
63
|
+
return self._name
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class _FakeResult:
|
|
67
|
+
"""Minimal stand-in for the `TaskResult` ansible-core hands to `v2_runner_on_*`."""
|
|
68
|
+
|
|
69
|
+
def __init__(self, host_name, result_dict, task=None, task_name=""):
|
|
70
|
+
self._host = _FakeNamed(host_name)
|
|
71
|
+
self._result = result_dict
|
|
72
|
+
self._task = task
|
|
73
|
+
self.task_name = task_name
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class JsonCallbackNoLogTest(unittest.TestCase):
|
|
77
|
+
def setUp(self):
|
|
78
|
+
callback_cls = _load_callback_module_class()
|
|
79
|
+
self.cb = callback_cls()
|
|
80
|
+
self.cb.v2_playbook_on_play_start(_FakeNamed("AI Support Agent server setup"))
|
|
81
|
+
|
|
82
|
+
def _host_payload(self, task_index=0, host="203.0.113.10"):
|
|
83
|
+
return self.cb.results["plays"][0]["tasks"][task_index]["hosts"][host]
|
|
84
|
+
|
|
85
|
+
def test_task_marked_no_log_is_censored_in_ok_result(self):
|
|
86
|
+
task = _FakeTask("database : Set MySQL root password", no_log=True)
|
|
87
|
+
self.cb.v2_playbook_on_task_start(task, is_conditional=False)
|
|
88
|
+
|
|
89
|
+
result = _FakeResult(
|
|
90
|
+
"203.0.113.10",
|
|
91
|
+
{"changed": True, "password": "hunter2", "msg": "ALTER USER root ... IDENTIFIED BY 'hunter2'"},
|
|
92
|
+
task=task,
|
|
93
|
+
)
|
|
94
|
+
self.cb.v2_runner_on_ok(result)
|
|
95
|
+
|
|
96
|
+
payload = self._host_payload()
|
|
97
|
+
self.assertEqual(payload, {"censored": True, "changed": True, "failed": False, "skipped": False})
|
|
98
|
+
|
|
99
|
+
# (The task *name* legitimately contains the word "password" — that's
|
|
100
|
+
# just a human-readable description, not the secret value itself.)
|
|
101
|
+
dumped = json.dumps(self.cb.results)
|
|
102
|
+
self.assertNotIn("hunter2", dumped)
|
|
103
|
+
self.assertNotIn("IDENTIFIED BY", dumped)
|
|
104
|
+
|
|
105
|
+
def test_module_set_ansible_no_log_flag_is_censored_even_if_task_no_log_is_false(self):
|
|
106
|
+
# A module can mark its own result no_log (`_ansible_no_log`) even
|
|
107
|
+
# when the *task* itself wasn't declared `no_log: true` — this must
|
|
108
|
+
# be censored too.
|
|
109
|
+
task = _FakeTask("database : Set MySQL root password", no_log=False)
|
|
110
|
+
self.cb.v2_playbook_on_task_start(task, is_conditional=False)
|
|
111
|
+
|
|
112
|
+
result = _FakeResult(
|
|
113
|
+
"203.0.113.10",
|
|
114
|
+
{"changed": True, "_ansible_no_log": True, "password": "hunter2"},
|
|
115
|
+
task=task,
|
|
116
|
+
)
|
|
117
|
+
self.cb.v2_runner_on_ok(result)
|
|
118
|
+
|
|
119
|
+
payload = self._host_payload()
|
|
120
|
+
self.assertEqual(payload, {"censored": True, "changed": True, "failed": False, "skipped": False})
|
|
121
|
+
self.assertNotIn("hunter2", json.dumps(self.cb.results))
|
|
122
|
+
|
|
123
|
+
def test_no_log_result_is_censored_on_failure_too(self):
|
|
124
|
+
task = _FakeTask("database : Set MySQL root password", no_log=True)
|
|
125
|
+
self.cb.v2_playbook_on_task_start(task, is_conditional=False)
|
|
126
|
+
|
|
127
|
+
result = _FakeResult(
|
|
128
|
+
"203.0.113.10",
|
|
129
|
+
{"changed": False, "password": "hunter2", "msg": "Access denied for user 'root'@'localhost' (using password: YES)"},
|
|
130
|
+
task=task,
|
|
131
|
+
)
|
|
132
|
+
self.cb.v2_runner_on_failed(result, ignore_errors=False)
|
|
133
|
+
|
|
134
|
+
payload = self._host_payload()
|
|
135
|
+
self.assertEqual(payload, {"censored": True, "changed": False, "failed": True, "skipped": False})
|
|
136
|
+
self.assertNotIn("hunter2", json.dumps(self.cb.results))
|
|
137
|
+
|
|
138
|
+
def test_no_log_result_is_censored_on_unreachable_too(self):
|
|
139
|
+
task = _FakeTask("database : Set MySQL root password", no_log=True)
|
|
140
|
+
self.cb.v2_playbook_on_task_start(task, is_conditional=False)
|
|
141
|
+
|
|
142
|
+
result = _FakeResult(
|
|
143
|
+
"203.0.113.10",
|
|
144
|
+
{"changed": False, "password": "hunter2"},
|
|
145
|
+
task=task,
|
|
146
|
+
)
|
|
147
|
+
self.cb.v2_runner_on_unreachable(result)
|
|
148
|
+
|
|
149
|
+
payload = self._host_payload()
|
|
150
|
+
self.assertEqual(
|
|
151
|
+
payload,
|
|
152
|
+
{"censored": True, "changed": False, "failed": True, "skipped": False, "unreachable": True},
|
|
153
|
+
)
|
|
154
|
+
self.assertNotIn("hunter2", json.dumps(self.cb.results))
|
|
155
|
+
|
|
156
|
+
def test_task_without_no_log_is_not_censored(self):
|
|
157
|
+
# Control case: a normal (non-secret) task's result must pass through
|
|
158
|
+
# unmodified, proving the censorship above is targeted rather than
|
|
159
|
+
# blanket-applied.
|
|
160
|
+
task = _FakeTask("os_init : Update apt cache", no_log=False)
|
|
161
|
+
self.cb.v2_playbook_on_task_start(task, is_conditional=False)
|
|
162
|
+
|
|
163
|
+
result = _FakeResult("203.0.113.10", {"changed": True, "msg": "cache updated"}, task=task)
|
|
164
|
+
self.cb.v2_runner_on_ok(result)
|
|
165
|
+
|
|
166
|
+
payload = self._host_payload()
|
|
167
|
+
self.assertEqual(payload, {"changed": True, "msg": "cache updated", "failed": False, "skipped": False})
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
if __name__ == "__main__":
|
|
171
|
+
unittest.main()
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
---
|
|
2
|
+
# Server setup playbook bundled with the agent CLI (agent/ansible/playbook.yml).
|
|
3
|
+
#
|
|
4
|
+
# Invoked by src/server-setup/server-setup-runner.ts as:
|
|
5
|
+
# ansible-playbook -i <inventory.yml> playbook.yml --tags <stepType,...> -e @<extra-vars.json>
|
|
6
|
+
#
|
|
7
|
+
# The inventory is JSON content written with a .yml extension (JSON is valid
|
|
8
|
+
# YAML, so ansible-core's bundled `yaml` inventory plugin — matched by file
|
|
9
|
+
# extension — parses it unambiguously); see server-setup-runner.ts's
|
|
10
|
+
# buildInventory() for why.
|
|
11
|
+
#
|
|
12
|
+
# Each role below corresponds 1:1 to a ServerSetupStepType (os_init / docker /
|
|
13
|
+
# web_server / database / dns_tls). `--tags` selects which roles run; task
|
|
14
|
+
# names inside each role are prefixed "<stepType> : ..." so the runner can
|
|
15
|
+
# group the `ansible-playbook --stdout-callback=json` output (produced by
|
|
16
|
+
# the bundled `callback_plugins/json.py` — ansible-core does not ship a
|
|
17
|
+
# `json` stdout callback of its own) back into one result per requested step.
|
|
18
|
+
#
|
|
19
|
+
# See admin-docs docs/features/server-setup.md for the full design,
|
|
20
|
+
# especially "秘密鍵の受け渡し設計" (this playbook never touches the SSH key
|
|
21
|
+
# file directly — it is only referenced via the inventory's
|
|
22
|
+
# ansible_ssh_private_key_file).
|
|
23
|
+
#
|
|
24
|
+
# Supported OS: MVP scope is Ubuntu 22.04/24.04 LTS only (see precheck task
|
|
25
|
+
# below, tagged `always` so it runs regardless of which --tags are passed).
|
|
26
|
+
- name: AI Support Agent server setup
|
|
27
|
+
hosts: all
|
|
28
|
+
become: true
|
|
29
|
+
gather_facts: true
|
|
30
|
+
|
|
31
|
+
tasks:
|
|
32
|
+
- name: "precheck : Verify supported OS"
|
|
33
|
+
ansible.builtin.fail:
|
|
34
|
+
msg: >-
|
|
35
|
+
Unsupported OS: {{ ansible_distribution }} {{ ansible_distribution_version }}.
|
|
36
|
+
Only Ubuntu 22.04/24.04 LTS are supported by server setup execution.
|
|
37
|
+
when: >-
|
|
38
|
+
ansible_distribution != 'Ubuntu' or
|
|
39
|
+
ansible_distribution_version not in ['22.04', '24.04']
|
|
40
|
+
tags: always
|
|
41
|
+
|
|
42
|
+
roles:
|
|
43
|
+
- role: os_init
|
|
44
|
+
tags: os_init
|
|
45
|
+
- role: docker
|
|
46
|
+
tags: docker
|
|
47
|
+
- role: web_server
|
|
48
|
+
tags: web_server
|
|
49
|
+
- role: database
|
|
50
|
+
tags: database
|
|
51
|
+
- role: dns_tls
|
|
52
|
+
tags: dns_tls
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
---
|
|
2
|
+
# database role: installs and starts MySQL or PostgreSQL, selected by the
|
|
3
|
+
# `db_type` variable ('mysql' | 'postgresql'), and sets the root/postgres
|
|
4
|
+
# admin password using the ansible.mysql / community.postgresql collections
|
|
5
|
+
# (bundled into the agent CLI's Docker image via `ansible-galaxy collection
|
|
6
|
+
# install`, see docker/Dockerfile).
|
|
7
|
+
#
|
|
8
|
+
# `ansible.mysql.mysql_user` (not `community.mysql.mysql_user`, which as of
|
|
9
|
+
# community.mysql 5.x is deprecated in favor of this collection and slated
|
|
10
|
+
# for removal in community.mysql 6.0.0) is used for MySQL.
|
|
11
|
+
#
|
|
12
|
+
# IMPORTANT: do not build the password-setting SQL as a raw string with the
|
|
13
|
+
# password Jinja-interpolated directly into it (e.g.
|
|
14
|
+
# `mysql -u root -e "ALTER USER 'root'@'localhost' IDENTIFIED BY '{{ db_root_password }}'"`
|
|
15
|
+
# via `ansible.builtin.command`). A password containing a single quote would
|
|
16
|
+
# break out of the SQL string literal there, letting arbitrary SQL run as
|
|
17
|
+
# root/postgres. The mysql_user / postgresql_user modules below instead pass
|
|
18
|
+
# the password as a bound module parameter, never as literal SQL text, so no
|
|
19
|
+
# password value can escape its parameter position regardless of its
|
|
20
|
+
# content.
|
|
21
|
+
#
|
|
22
|
+
# Any task that touches a password value MUST be marked `no_log: true` so
|
|
23
|
+
# the value never reaches Ansible's own stdout/stderr or the JSON callback
|
|
24
|
+
# output consumed by src/server-setup/server-setup-runner.ts.
|
|
25
|
+
#
|
|
26
|
+
# `db_type` is validated as an enum ('mysql' | 'postgresql') by
|
|
27
|
+
# `ALLOWED_STEP_PARAMS`/`STEP_PARAM_ENUM_VALUES` in
|
|
28
|
+
# src/server-setup/server-setup-runner.ts before this playbook ever runs, so
|
|
29
|
+
# the `assert` below should never fire in practice. It exists as a second,
|
|
30
|
+
# independent safety net: every task in this file is gated by a plain
|
|
31
|
+
# `when: db_type == '...'` string comparison, so a value that doesn't match
|
|
32
|
+
# ANY known engine (a typo, wrong case, ...) would otherwise match none of
|
|
33
|
+
# them, causing every task to silently skip while `ansible-playbook` still
|
|
34
|
+
# exits 0 — i.e. `runServerSetup` would report success despite installing
|
|
35
|
+
# nothing.
|
|
36
|
+
|
|
37
|
+
- name: "database : Validate db_type is supported"
|
|
38
|
+
ansible.builtin.assert:
|
|
39
|
+
that:
|
|
40
|
+
- db_type in ['mysql', 'postgresql']
|
|
41
|
+
fail_msg: >-
|
|
42
|
+
db_type must be one of 'mysql' or 'postgresql': got {{ db_type | to_json }}
|
|
43
|
+
when: db_type is defined
|
|
44
|
+
|
|
45
|
+
- name: "database : Install MySQL server"
|
|
46
|
+
ansible.builtin.apt:
|
|
47
|
+
name:
|
|
48
|
+
- mysql-server
|
|
49
|
+
state: present
|
|
50
|
+
update_cache: true
|
|
51
|
+
when: (db_type | default('')) == 'mysql'
|
|
52
|
+
|
|
53
|
+
- name: "database : Enable and start MySQL"
|
|
54
|
+
ansible.builtin.service:
|
|
55
|
+
name: mysql
|
|
56
|
+
state: started
|
|
57
|
+
enabled: true
|
|
58
|
+
when: (db_type | default('')) == 'mysql'
|
|
59
|
+
|
|
60
|
+
- name: "database : Install PyMySQL (required by ansible.mysql.mysql_user)"
|
|
61
|
+
ansible.builtin.apt:
|
|
62
|
+
name: python3-pymysql
|
|
63
|
+
state: present
|
|
64
|
+
update_cache: true
|
|
65
|
+
when: (db_type | default('')) == 'mysql' and db_root_password is defined
|
|
66
|
+
|
|
67
|
+
# `check_implicit_admin: true` + the explicit `login_user`/`login_password`
|
|
68
|
+
# fallback make this task idempotent across re-runs. On a fresh MySQL
|
|
69
|
+
# install, root@localhost authenticates via the `auth_socket` plugin (no
|
|
70
|
+
# password) over the unix socket — the "implicit admin" connection
|
|
71
|
+
# `check_implicit_admin` tries first. Setting `password` here switches
|
|
72
|
+
# root@localhost to password auth, so a *second* run can no longer connect
|
|
73
|
+
# as the implicit admin; `check_implicit_admin` then falls back to
|
|
74
|
+
# connecting as `login_user`/`login_password` (the same, now-current,
|
|
75
|
+
# `db_root_password`), which succeeds and is a no-op since the password
|
|
76
|
+
# already matches. Without this, a second run would fail outright once the
|
|
77
|
+
# implicit-admin connection stops working. This mirrors the "Handle multiple
|
|
78
|
+
# non-idempotent password changed states" example in the upstream
|
|
79
|
+
# community.mysql.mysql_user documentation (ansible.mysql.mysql_user shares
|
|
80
|
+
# the same module interface).
|
|
81
|
+
- name: "database : Set MySQL root password"
|
|
82
|
+
ansible.mysql.mysql_user:
|
|
83
|
+
name: root
|
|
84
|
+
host: localhost
|
|
85
|
+
password: "{{ db_root_password }}"
|
|
86
|
+
login_unix_socket: /var/run/mysqld/mysqld.sock
|
|
87
|
+
check_implicit_admin: true
|
|
88
|
+
login_user: root
|
|
89
|
+
login_password: "{{ db_root_password }}"
|
|
90
|
+
when: (db_type | default('')) == 'mysql' and db_root_password is defined
|
|
91
|
+
no_log: true
|
|
92
|
+
|
|
93
|
+
- name: "database : Install PostgreSQL server"
|
|
94
|
+
ansible.builtin.apt:
|
|
95
|
+
name:
|
|
96
|
+
- postgresql
|
|
97
|
+
state: present
|
|
98
|
+
update_cache: true
|
|
99
|
+
when: (db_type | default('')) == 'postgresql'
|
|
100
|
+
|
|
101
|
+
- name: "database : Enable and start PostgreSQL"
|
|
102
|
+
ansible.builtin.service:
|
|
103
|
+
name: postgresql
|
|
104
|
+
state: started
|
|
105
|
+
enabled: true
|
|
106
|
+
when: (db_type | default('')) == 'postgresql'
|
|
107
|
+
|
|
108
|
+
- name: "database : Install psycopg2 (required by community.postgresql.postgresql_user)"
|
|
109
|
+
ansible.builtin.apt:
|
|
110
|
+
name: python3-psycopg2
|
|
111
|
+
state: present
|
|
112
|
+
update_cache: true
|
|
113
|
+
when: (db_type | default('')) == 'postgresql' and db_root_password is defined
|
|
114
|
+
|
|
115
|
+
- name: "database : Set PostgreSQL postgres user password"
|
|
116
|
+
become_user: postgres
|
|
117
|
+
community.postgresql.postgresql_user:
|
|
118
|
+
name: postgres
|
|
119
|
+
password: "{{ db_root_password }}"
|
|
120
|
+
when: (db_type | default('')) == 'postgresql' and db_root_password is defined
|
|
121
|
+
no_log: true
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
---
|
|
2
|
+
# dns_tls role: Caddy (reverse proxy with automatic Let's Encrypt TLS),
|
|
3
|
+
# configured from the `domain` variable.
|
|
4
|
+
|
|
5
|
+
- name: "dns_tls : Install prerequisite packages"
|
|
6
|
+
ansible.builtin.apt:
|
|
7
|
+
name:
|
|
8
|
+
- debian-keyring
|
|
9
|
+
- debian-archive-keyring
|
|
10
|
+
- apt-transport-https
|
|
11
|
+
- curl
|
|
12
|
+
- gnupg
|
|
13
|
+
state: present
|
|
14
|
+
update_cache: true
|
|
15
|
+
|
|
16
|
+
- name: "dns_tls : Create apt keyrings directory"
|
|
17
|
+
ansible.builtin.file:
|
|
18
|
+
path: /etc/apt/keyrings
|
|
19
|
+
state: directory
|
|
20
|
+
mode: '0755'
|
|
21
|
+
|
|
22
|
+
- name: "dns_tls : Add Caddy GPG key"
|
|
23
|
+
ansible.builtin.get_url:
|
|
24
|
+
url: https://dl.cloudsmith.io/public/caddy/stable/gpg.key
|
|
25
|
+
dest: /etc/apt/keyrings/caddy-stable.asc
|
|
26
|
+
mode: '0644'
|
|
27
|
+
|
|
28
|
+
- name: "dns_tls : Add Caddy apt repository"
|
|
29
|
+
ansible.builtin.apt_repository:
|
|
30
|
+
repo: >-
|
|
31
|
+
deb [signed-by=/etc/apt/keyrings/caddy-stable.asc]
|
|
32
|
+
https://dl.cloudsmith.io/public/caddy/stable/deb/debian any-version main
|
|
33
|
+
state: present
|
|
34
|
+
filename: caddy-stable
|
|
35
|
+
|
|
36
|
+
- name: "dns_tls : Install Caddy"
|
|
37
|
+
ansible.builtin.apt:
|
|
38
|
+
name: caddy
|
|
39
|
+
state: present
|
|
40
|
+
update_cache: true
|
|
41
|
+
|
|
42
|
+
- name: "dns_tls : Validate domain is a well-formed FQDN"
|
|
43
|
+
ansible.builtin.assert:
|
|
44
|
+
that:
|
|
45
|
+
# Anchored (`match` = re.match, and the pattern ends in `$`) so a value
|
|
46
|
+
# with an embedded newline, space, or Caddyfile-syntax character
|
|
47
|
+
# (`{`, `}`, `#`, etc.) cannot slip through and be injected into
|
|
48
|
+
# Caddyfile.j2's generated reverse_proxy block.
|
|
49
|
+
- domain is match('^([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,63}$')
|
|
50
|
+
fail_msg: >-
|
|
51
|
+
domain must be a well-formed FQDN (letters/digits/hyphens and dots
|
|
52
|
+
only): got {{ domain | to_json }}
|
|
53
|
+
when: domain is defined
|
|
54
|
+
|
|
55
|
+
- name: "dns_tls : Generate Caddyfile"
|
|
56
|
+
ansible.builtin.template:
|
|
57
|
+
src: Caddyfile.j2
|
|
58
|
+
dest: /etc/caddy/Caddyfile
|
|
59
|
+
owner: root
|
|
60
|
+
group: root
|
|
61
|
+
mode: '0644'
|
|
62
|
+
when: domain is defined
|
|
63
|
+
register: dns_tls_caddyfile
|
|
64
|
+
|
|
65
|
+
- name: "dns_tls : Enable and start Caddy"
|
|
66
|
+
ansible.builtin.service:
|
|
67
|
+
name: caddy
|
|
68
|
+
state: started
|
|
69
|
+
enabled: true
|
|
70
|
+
|
|
71
|
+
- name: "dns_tls : Restart Caddy to apply Caddyfile changes"
|
|
72
|
+
ansible.builtin.service:
|
|
73
|
+
name: caddy
|
|
74
|
+
state: restarted
|
|
75
|
+
when: dns_tls_caddyfile is changed
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
---
|
|
2
|
+
# docker role: Docker Engine + docker compose plugin, installed from Docker's
|
|
3
|
+
# official apt repository. `ansible.builtin.apt` is naturally idempotent, so
|
|
4
|
+
# re-running this role when Docker is already installed is a no-op.
|
|
5
|
+
|
|
6
|
+
- name: "docker : Install prerequisite packages"
|
|
7
|
+
ansible.builtin.apt:
|
|
8
|
+
name:
|
|
9
|
+
- ca-certificates
|
|
10
|
+
- curl
|
|
11
|
+
- gnupg
|
|
12
|
+
state: present
|
|
13
|
+
update_cache: true
|
|
14
|
+
|
|
15
|
+
- name: "docker : Create apt keyrings directory"
|
|
16
|
+
ansible.builtin.file:
|
|
17
|
+
path: /etc/apt/keyrings
|
|
18
|
+
state: directory
|
|
19
|
+
mode: '0755'
|
|
20
|
+
|
|
21
|
+
- name: "docker : Add Docker GPG key"
|
|
22
|
+
ansible.builtin.get_url:
|
|
23
|
+
url: https://download.docker.com/linux/ubuntu/gpg
|
|
24
|
+
dest: /etc/apt/keyrings/docker.asc
|
|
25
|
+
mode: '0644'
|
|
26
|
+
|
|
27
|
+
- name: "docker : Add Docker apt repository"
|
|
28
|
+
ansible.builtin.apt_repository:
|
|
29
|
+
repo: >-
|
|
30
|
+
deb [arch={{ 'amd64' if ansible_architecture == 'x86_64' else 'arm64' }}
|
|
31
|
+
signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu
|
|
32
|
+
{{ ansible_distribution_release }} stable
|
|
33
|
+
state: present
|
|
34
|
+
filename: docker
|
|
35
|
+
|
|
36
|
+
- name: "docker : Install Docker Engine and compose plugin"
|
|
37
|
+
ansible.builtin.apt:
|
|
38
|
+
name:
|
|
39
|
+
- docker-ce
|
|
40
|
+
- docker-ce-cli
|
|
41
|
+
- containerd.io
|
|
42
|
+
- docker-buildx-plugin
|
|
43
|
+
- docker-compose-plugin
|
|
44
|
+
state: present
|
|
45
|
+
update_cache: true
|
|
46
|
+
|
|
47
|
+
- name: "docker : Enable and start the docker service"
|
|
48
|
+
ansible.builtin.service:
|
|
49
|
+
name: docker
|
|
50
|
+
state: started
|
|
51
|
+
enabled: true
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
---
|
|
2
|
+
# os_init role: user creation, OS package upgrade, and ufw firewall.
|
|
3
|
+
#
|
|
4
|
+
# IMPORTANT: the "allow OpenSSH" rule is applied BEFORE the default-deny
|
|
5
|
+
# incoming policy and BEFORE `ufw --force enable`. Reversing this order can
|
|
6
|
+
# drop the very SSH session used to run this playbook.
|
|
7
|
+
|
|
8
|
+
- name: "os_init : Update apt cache"
|
|
9
|
+
ansible.builtin.apt:
|
|
10
|
+
update_cache: true
|
|
11
|
+
cache_valid_time: 3600
|
|
12
|
+
|
|
13
|
+
- name: "os_init : Upgrade all packages"
|
|
14
|
+
ansible.builtin.apt:
|
|
15
|
+
upgrade: dist
|
|
16
|
+
|
|
17
|
+
- name: "os_init : Create setup user"
|
|
18
|
+
ansible.builtin.user:
|
|
19
|
+
name: "{{ os_init_user | default('appuser') }}"
|
|
20
|
+
shell: /bin/bash
|
|
21
|
+
create_home: true
|
|
22
|
+
groups: sudo
|
|
23
|
+
append: true
|
|
24
|
+
|
|
25
|
+
- name: "os_init : Install ufw"
|
|
26
|
+
ansible.builtin.apt:
|
|
27
|
+
name: ufw
|
|
28
|
+
state: present
|
|
29
|
+
|
|
30
|
+
- name: "os_init : Check ufw status"
|
|
31
|
+
ansible.builtin.command:
|
|
32
|
+
cmd: ufw status
|
|
33
|
+
register: os_init_ufw_status
|
|
34
|
+
changed_when: false
|
|
35
|
+
|
|
36
|
+
# --- Ordering guard: OpenSSH must be allowed before anything below enables
|
|
37
|
+
# --- the firewall or sets a default-deny incoming policy.
|
|
38
|
+
- name: "os_init : Allow OpenSSH through ufw"
|
|
39
|
+
ansible.builtin.command:
|
|
40
|
+
cmd: ufw allow OpenSSH
|
|
41
|
+
register: os_init_ufw_allow_ssh
|
|
42
|
+
changed_when: "'Skipping' not in os_init_ufw_allow_ssh.stdout"
|
|
43
|
+
|
|
44
|
+
- name: "os_init : Set ufw default incoming policy to deny"
|
|
45
|
+
ansible.builtin.command:
|
|
46
|
+
cmd: ufw default deny incoming
|
|
47
|
+
register: os_init_ufw_default_incoming
|
|
48
|
+
changed_when: "'Default incoming policy changed' in os_init_ufw_default_incoming.stdout"
|
|
49
|
+
|
|
50
|
+
- name: "os_init : Set ufw default outgoing policy to allow"
|
|
51
|
+
ansible.builtin.command:
|
|
52
|
+
cmd: ufw default allow outgoing
|
|
53
|
+
register: os_init_ufw_default_outgoing
|
|
54
|
+
changed_when: "'Default outgoing policy changed' in os_init_ufw_default_outgoing.stdout"
|
|
55
|
+
|
|
56
|
+
- name: "os_init : Enable ufw"
|
|
57
|
+
ansible.builtin.command:
|
|
58
|
+
cmd: ufw --force enable
|
|
59
|
+
when: "'inactive' in os_init_ufw_status.stdout"
|
|
60
|
+
register: os_init_ufw_enable
|
|
61
|
+
changed_when: "'Firewall is active' in os_init_ufw_enable.stdout"
|