@ai-support-agent/cli 0.3.0-beta.1 → 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.
Files changed (59) hide show
  1. package/ansible/callback_plugins/json.py +146 -0
  2. package/ansible/callback_plugins/tests/test_json_callback.py +171 -0
  3. package/ansible/playbook.yml +52 -0
  4. package/ansible/roles/database/tasks/main.yml +121 -0
  5. package/ansible/roles/dns_tls/tasks/main.yml +75 -0
  6. package/ansible/roles/dns_tls/templates/Caddyfile.j2 +4 -0
  7. package/ansible/roles/docker/tasks/main.yml +51 -0
  8. package/ansible/roles/os_init/tasks/main.yml +61 -0
  9. package/ansible/roles/web_server/tasks/main.yml +47 -0
  10. package/dist/api-client.d.ts +25 -1
  11. package/dist/api-client.d.ts.map +1 -1
  12. package/dist/api-client.js +33 -0
  13. package/dist/api-client.js.map +1 -1
  14. package/dist/commands/chat-executor.js +1 -1
  15. package/dist/commands/chat-executor.js.map +1 -1
  16. package/dist/commands/index.d.ts.map +1 -1
  17. package/dist/commands/index.js +14 -0
  18. package/dist/commands/index.js.map +1 -1
  19. package/dist/constants.d.ts +2 -0
  20. package/dist/constants.d.ts.map +1 -1
  21. package/dist/constants.js +6 -0
  22. package/dist/constants.js.map +1 -1
  23. package/dist/mcp/config-writer.d.ts +24 -1
  24. package/dist/mcp/config-writer.d.ts.map +1 -1
  25. package/dist/mcp/config-writer.js +27 -2
  26. package/dist/mcp/config-writer.js.map +1 -1
  27. package/dist/mcp/server.d.ts.map +1 -1
  28. package/dist/mcp/server.js +2 -0
  29. package/dist/mcp/server.js.map +1 -1
  30. package/dist/mcp/tools/update-system-knowledge.d.ts +29 -0
  31. package/dist/mcp/tools/update-system-knowledge.d.ts.map +1 -0
  32. package/dist/mcp/tools/update-system-knowledge.js +63 -0
  33. package/dist/mcp/tools/update-system-knowledge.js.map +1 -0
  34. package/dist/oneshot-runner.d.ts.map +1 -1
  35. package/dist/oneshot-runner.js +5 -2
  36. package/dist/oneshot-runner.js.map +1 -1
  37. package/dist/server-setup/known-hosts-store.d.ts +16 -0
  38. package/dist/server-setup/known-hosts-store.d.ts.map +1 -0
  39. package/dist/server-setup/known-hosts-store.js +89 -0
  40. package/dist/server-setup/known-hosts-store.js.map +1 -0
  41. package/dist/server-setup/server-setup-runner.d.ts +27 -0
  42. package/dist/server-setup/server-setup-runner.d.ts.map +1 -0
  43. package/dist/server-setup/server-setup-runner.js +576 -0
  44. package/dist/server-setup/server-setup-runner.js.map +1 -0
  45. package/dist/types/command.d.ts +5 -1
  46. package/dist/types/command.d.ts.map +1 -1
  47. package/dist/types/command.js.map +1 -1
  48. package/dist/types/index.d.ts +1 -0
  49. package/dist/types/index.d.ts.map +1 -1
  50. package/dist/types/index.js +1 -0
  51. package/dist/types/index.js.map +1 -1
  52. package/dist/types/server-setup.d.ts +30 -0
  53. package/dist/types/server-setup.d.ts.map +1 -0
  54. package/dist/types/server-setup.js +12 -0
  55. package/dist/types/server-setup.js.map +1 -0
  56. package/dist/types/tool-output.d.ts +62 -0
  57. package/dist/types/tool-output.d.ts.map +1 -1
  58. package/docker/Dockerfile +30 -1
  59. 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,4 @@
1
+ {# Generated by ai-support-agent server setup (dns_tls role). Do not edit by hand. #}
2
+ {{ domain }} {
3
+ reverse_proxy localhost:8080
4
+ }
@@ -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"