Repository navigation
Expand file tree
/
Copy pathplaybook.yml
More file actions
324 lines (285 loc) · 12.2 KB
/
Copy pathplaybook.yml
File metadata and controls
324 lines (285 loc) · 12.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
---
- name: Provision and configure api-server host
hosts: all
become: true
gather_facts: true
vars:
app_name: "api-server"
working_dir: "/opt/{{ app_name }}"
# Set to true for local/dev installation (changes host and port, skips deploy user)
dev: "{{ install_dev | default(false) | bool }}"
# Host and ports
server_host: "{{ 'dev.chencraft.com' if dev else 'api.chencraft.com' }}"
proxy_port: "{{ 8085 if dev else 8080 }}"
# Nginx and TLS
# Determine nginx root directory dynamically: Homebrew path on macOS (Darwin), else standard Linux path
nginx_root_dir: "{{ '/opt/homebrew/etc/nginx' if ansible_facts.system == 'Darwin' else '/etc/nginx' }}"
nginx_sites_available_dir: "{{ nginx_root_dir }}/sites-available"
nginx_sites_enabled_dir: "{{ nginx_root_dir }}/sites-enabled"
nginx_site_available: "{{ nginx_sites_available_dir }}/{{ app_name }}.conf"
nginx_site_enabled: "{{ nginx_sites_enabled_dir }}/{{ app_name }}.conf"
ssl_cert_path: "/etc/ssl/certs/fullchain.cer"
ssl_cert_key_path: "/etc/ssl/certs/server.key"
ssl_client_cert_path: "/etc/ssl/certs/rootCA.crt"
# Inputs for PKCS#12 export (keystore)
p12_cert_path: "/etc/ssl/certs/server.crt"
p12_key_path: "/etc/ssl/private/server.key"
# Doppler is the source of truth for runtime secrets.
# CI passes -e doppler_token=<read-only prd service token>; laptop runs leave it
# empty and the logged-in user's doppler CLI session is used instead.
doppler_token: ""
doppler_project: "api-server"
doppler_config: "{{ 'dev' if dev else 'prd' }}"
# Both are fetched from Doppler below; empty defaults keep the nginx template
# renderable and let the PKCS#12 task self-skip if the fetch is skipped.
proxy_secret: ""
tls_keystore_password: ""
# Gate for the container-deploy task block (set true from CI; laptop runs leave false).
docker_deploy: false
ghcr_user: ""
ghcr_pat_ro: ""
# OS-specific defaults
# - On macOS: default group is 'staff' and work owned by app user/group
# - On Linux: default user/group root; working_dir owned by root:root per requirements
app_user: "{{ ansible_user | default('root') }}"
app_group: "{{ 'staff' if ansible_facts.system == 'Darwin' else ansible_user }}"
deploy_user: "githubdeploy"
deploy_user_public_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIpJneejmAWqksgGHCcjOqyM4vCcp6edAEnJjo3hm2up ubuntu@DESKTOP-7VSUP4J"
pre_tasks:
- name: Detect platform flags
ansible.builtin.set_fact:
is_macos: "{{ ansible_facts.system == 'Darwin' }}"
is_linux: "{{ ansible_facts.system != 'Darwin' }}"
- name: Show selected mode
ansible.builtin.debug:
msg: "Running in {{ 'DEV' if dev else 'PROD' }} mode for host {{ server_host }} (proxy_port={{ proxy_port }})"
tasks:
- name: Create GitHub Actions deploy user (non-dev only)
when: not dev
block:
- name: Ensure deploy user exists and is in docker group
ansible.builtin.user:
name: "{{ deploy_user }}"
state: present
create_home: true
groups: docker
append: true
password_lock: true
- name: Ensure .ssh directory exists
ansible.builtin.file:
path: "/home/{{ deploy_user }}/.ssh"
state: directory
owner: "{{ deploy_user }}"
group: "{{ deploy_user }}"
mode: "0700"
- name: Install GitHub Actions public key if provided
ansible.posix.authorized_key:
user: "{{ deploy_user }}"
key: "{{ deploy_user_public_key }}"
state: present
- name: Grant passwordless sudo to deploy user (required for CI-driven playbook runs)
# Trust model: githubdeploy is already in the `docker` group, which is effectively root
# (a docker-group member can `docker run --privileged -v /:/host alpine chroot /host`).
# So NOPASSWD: ALL widens nothing material — it just lets ansible's `become: true` work
# over the existing SSH key without an interactive password. Anyone with SERVER_SSH_KEY
# already has root-equivalent access via docker. If that trust model ever tightens
# (githubdeploy removed from docker group), revisit and narrow this to a Cmnd_Alias.
ansible.builtin.copy:
dest: "/etc/sudoers.d/{{ deploy_user }}"
content: "{{ deploy_user }} ALL=(ALL) NOPASSWD: ALL\n"
owner: root
group: root
mode: "0440"
validate: "visudo -cf %s"
- name: Ensure SSH (22/tcp) open for remote access (non-dev only)
when: not dev
block:
- name: Gather service facts
ansible.builtin.service_facts:
- name: Check if UFW is active
ansible.builtin.command: ufw status
register: ufw_status
failed_when: false
changed_when: false
- name: Allow SSH via UFW when active
ansible.builtin.command: ufw allow 22/tcp
register: ufw_allow_ssh
changed_when:
- "'Skipping adding existing rule' not in ufw_allow_ssh.stdout"
- "'Skipping adding existing rule (v6)' not in ufw_allow_ssh.stdout"
when:
- ufw_status.rc == 0
- "'Status: active' in ufw_status.stdout"
- name: Allow SSH via firewalld if running
ansible.posix.firewalld:
service: ssh
permanent: true
state: enabled
immediate: true
when:
- ansible_facts.services is defined
- ansible_facts.services['firewalld'] is defined
- ansible_facts.services['firewalld'].state == 'running'
- name: Ensure working directory exists
become: "{{ is_linux }}"
ansible.builtin.file:
path: "{{ working_dir }}"
state: directory
owner: "{{ app_user }}"
group: "{{ app_group }}"
mode: "0750"
- name: Ensure Nginx config directories exist (sites-available/sites-enabled)
become: "{{ is_linux }}"
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: "{{ 'root' if is_linux else app_user }}"
group: "{{ 'root' if is_linux else app_group }}"
mode: "0755"
loop:
- "{{ nginx_sites_available_dir }}"
- "{{ nginx_sites_enabled_dir }}"
- name: Ensure SSL directories exist
ansible.builtin.file:
path: "{{ item.path }}"
state: directory
owner: "{{ 'root' }}"
group: "{{ is_linux | ternary('root', 'wheel') }}"
mode: "{{ item.mode }}"
loop:
- { path: "/etc/ssl/certs", mode: "0755" }
- { path: "/etc/ssl/private", mode: "0700" }
- name: Fetch runtime secrets from Doppler
block:
- name: Download secrets with service token (CI)
ansible.builtin.uri:
url: "https://api.doppler.com/v3/configs/config/secrets/download?format=json"
headers:
Authorization: "Bearer {{ doppler_token }}"
return_content: true
register: doppler_api_secrets
no_log: true
when: doppler_token | length > 0
- name: Download secrets with logged-in doppler CLI (laptop)
become: false
ansible.builtin.command:
cmd: "doppler secrets download --no-file --format json --project {{ doppler_project }} --config {{ doppler_config }}"
register: doppler_cli_secrets
changed_when: false
no_log: true
when: doppler_token | length == 0
- name: Extract TLS keystore password and proxy secret (nginx -> Spring shared secret)
ansible.builtin.set_fact:
tls_keystore_password: "{{ doppler_secrets.APP_TLS_KEYSTORE_PASSWORD | default(tls_keystore_password) }}"
proxy_secret: "{{ doppler_secrets.APP_MTLS_PROXY_SECRET | default(proxy_secret) }}"
vars:
doppler_secrets: >-
{{ (doppler_token | length > 0)
| ternary(doppler_api_secrets.json | default({}),
doppler_cli_secrets.stdout | default('{}') | from_json) }}
no_log: true
- name: Write Doppler service token env file for docker compose
ansible.builtin.copy:
content: "DOPPLER_TOKEN={{ doppler_token }}\n"
dest: "{{ working_dir }}/doppler.env"
owner: "{{ app_user }}"
group: "{{ app_group }}"
mode: "0600"
no_log: true
when: doppler_token | length > 0
- name: Deploy Nginx site configuration
ansible.builtin.template:
src: "templates/api-server.conf.j2"
dest: "{{ nginx_site_available }}"
owner: "{{ 'root' if is_linux else app_user }}"
group: "{{ 'root' if is_linux else app_group }}"
mode: "0644"
notify: Reload nginx
- name: Enable Nginx site via symlink
ansible.builtin.file:
src: "{{ nginx_site_available }}"
dest: "{{ nginx_site_enabled }}"
state: link
notify: Reload nginx
- name: Generate PKCS#12 keystore
community.crypto.openssl_pkcs12:
path: "{{ working_dir }}/server.p12"
state: present
privatekey_path: "{{ p12_key_path }}"
certificate_path: "{{ p12_cert_path }}"
friendly_name: "{{ app_name }}"
passphrase: "{{ tls_keystore_password }}"
owner: "{{ app_user }}"
group: "{{ app_group }}"
mode: "0600"
when:
- tls_keystore_password is defined
- tls_keystore_password | length > 0
- name: Deploy container (CI-only; laptop runs leave docker_deploy=false)
when: docker_deploy | bool
tags: ["deploy"]
block:
- name: Copy docker-compose.yml to working directory
ansible.builtin.copy:
src: "docker-compose.yml"
dest: "{{ working_dir }}/docker-compose.yml"
owner: "{{ app_user }}"
group: "{{ app_group }}"
mode: "0644"
- name: Log in to GHCR
ansible.builtin.command: "docker login ghcr.io -u {{ ghcr_user }} --password-stdin"
args:
stdin: "{{ ghcr_pat_ro }}"
register: ghcr_login
changed_when: false
no_log: true
- name: Pull latest image
ansible.builtin.command: "docker compose pull"
args:
chdir: "{{ working_dir }}"
register: compose_pull
# Compose v2 may print "Pulled" / "Pulling" on either stream depending on version.
changed_when: >-
'Pulled' in (compose_pull.stdout | default('')) or
'Pulled' in (compose_pull.stderr | default(''))
- name: Bring up containers
ansible.builtin.command: "docker compose up -d"
args:
chdir: "{{ working_dir }}"
register: compose_up
# Compose v2 reports container lifecycle ("Started" / "Recreated" / "Created")
# on either stdout or stderr — check both so the play recap reflects reality.
changed_when: >-
(compose_up.stdout | default('')) is search('(Started|Recreated|Created)\b') or
(compose_up.stderr | default('')) is search('(Started|Recreated|Created)\b')
- name: Prune dangling images
ansible.builtin.command: "docker image prune -f"
changed_when: false
- name: Uninstall api-server resources
tags: ["never", "uninstall"]
block:
- name: Disable Nginx site symlink
ansible.builtin.file:
path: "{{ nginx_site_enabled }}"
state: absent
notify: Reload nginx
- name: Remove Nginx site configuration
ansible.builtin.file:
path: "{{ nginx_site_available }}"
state: absent
notify: Reload nginx
- name: Remove working directory
ansible.builtin.file:
path: "{{ working_dir }}"
state: absent
- name: Remove GitHub Actions deploy user and home (non-dev only)
when: not dev
ansible.builtin.user:
name: "{{ deploy_user }}"
state: absent
remove: true
handlers:
- name: Reload nginx
ansible.builtin.service:
name: nginx
state: reloaded