@loicngr/kobo 2.1.16 → 2.1.19
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/AGENTS.md +101 -0
- package/CHANGELOG.md +12 -0
- package/CONFIGURATION.md +108 -17
- package/README.md +6 -2
- package/dist/mcp-server/kobo-tasks-server.js +28 -6
- package/dist/mcp-server/progress-bar-client.js +20 -0
- package/dist/mcp-server/workspace-dialogue-client.js +27 -7
- package/dist/server/db/migrations.js +2 -0
- package/dist/server/db/progress-bar-schema.js +27 -0
- package/dist/server/db/schema.js +2 -0
- package/dist/server/index.js +6 -0
- package/dist/server/middleware/workspace-creation-maintenance.js +13 -0
- package/dist/server/routes/maintenance.js +32 -0
- package/dist/server/routes/mcp.js +19 -9
- package/dist/server/routes/progress-bars.js +56 -0
- package/dist/server/routes/pull-requests.js +2 -1
- package/dist/server/routes/workspaces.js +35 -64
- package/dist/server/services/agent/engines/claude-code/engine.js +59 -16
- package/dist/server/services/agent/engines/claude-code/options-builder.js +13 -34
- package/dist/server/services/agent/engines/claude-code/stop-hook.js +8 -27
- package/dist/server/services/agent/engines/codex/engine.js +8 -1
- package/dist/server/services/agent/engines/codex/options-builder.js +11 -16
- package/dist/server/services/agent/orchestrator.js +73 -11
- package/dist/server/services/auto-loop-final-review-service.js +1 -1
- package/dist/server/services/auto-loop-service.js +10 -10
- package/dist/server/services/cron-service.js +7 -0
- package/dist/server/services/progress-bar-mcp-service.js +44 -0
- package/dist/server/services/progress-bar-runtime.js +29 -0
- package/dist/server/services/progress-bar-service.js +232 -0
- package/dist/server/services/quota-backoff-service.js +7 -0
- package/dist/server/services/update-maintenance-service.js +44 -0
- package/dist/server/services/wakeup-service.js +21 -3
- package/dist/server/services/workspace-creation-mcp-service.js +16 -0
- package/dist/shared/auto-loop-prompts.js +12 -12
- package/dist/shared/launch-instructions.js +57 -0
- package/dist/shared/progress-bar-prompts.js +7 -0
- package/dist/shared/progress-bar-tools.js +73 -0
- package/dist/shared/progress-bars.js +3 -0
- package/docker-compose.vps.yml +60 -0
- package/docs/deployment-vps.md +506 -0
- package/docs/getting-started.md +9 -0
- package/package.json +8 -1
- package/scripts/kobo-gh +9 -0
- package/scripts/kobo-github-app-token +190 -0
- package/scripts/kobo-install +392 -0
- package/scripts/kobo-update +499 -0
- package/src/client/dist/pwa/assets/ActivityFeed-DiUxnmGJ.js +2 -0
- package/src/client/dist/pwa/assets/{AutoLoopFinalReviewSettings-Bbrq02Xt.js → AutoLoopFinalReviewSettings-f4VDR5dd.js} +1 -1
- package/src/client/dist/pwa/assets/{ChangelogPage-BJfkAVYp.js → ChangelogPage-dEWpjTlp.js} +1 -1
- package/src/client/dist/pwa/assets/ComparisonPanel-Bwbqgvr4.js +2 -0
- package/src/client/dist/pwa/assets/CreatePage-CCCCAhrx.js +2 -0
- package/src/client/dist/pwa/assets/{DashboardPage-DZYsAh5y.js → DashboardPage-3byhBITf.js} +1 -1
- package/src/client/dist/pwa/assets/DiffViewer-Bj_NTfCF.js +8 -0
- package/src/client/dist/pwa/assets/{DocumentsPanel-BfPlfiwU.js → DocumentsPanel-xyLDV3T1.js} +1 -1
- package/src/client/dist/pwa/assets/{DrawerToggleButton-DG1Qjqk1.js → DrawerToggleButton-pwBSstQP.js} +1 -1
- package/src/client/dist/pwa/assets/GitPanel-DvIdrkVw.js +3 -0
- package/src/client/dist/pwa/assets/{HealthPage-_VXG1Zeo.js → HealthPage-BSwhZQRo.js} +1 -1
- package/src/client/dist/pwa/assets/MainLayout-DZJ9BvZw.js +2 -0
- package/src/client/dist/pwa/assets/{MainLayout-DnmUxNk7.css → MainLayout-DoAEgANU.css} +1 -1
- package/src/client/dist/pwa/assets/{MemoryPanel-CVlvGZXZ.js → MemoryPanel-ChR032zP.js} +1 -1
- package/src/client/dist/pwa/assets/{MemoryProposalList-SJsnzUAM.js → MemoryProposalList-BRKSoS4K.js} +1 -1
- package/src/client/dist/pwa/assets/{QBtnDropdown-DBgGsN-D.js → QBtnDropdown-o3QH0Bb8.js} +1 -1
- package/src/client/dist/pwa/assets/{QExpansionItem-DKgaMJ_l.js → QExpansionItem-BhFHiyq9.js} +1 -1
- package/src/client/dist/pwa/assets/{QScrollArea-D3OCsq3g.js → QScrollArea-BsNY6slB.js} +1 -1
- package/src/client/dist/pwa/assets/QSelect-bEq3g_io.js +1 -0
- package/src/client/dist/pwa/assets/{QTooltip-X5ps8Opw.js → QTooltip-xYZ6KNu2.js} +1 -1
- package/src/client/dist/pwa/assets/{SchedulePanel-Ww_FVlW5.js → SchedulePanel-DIFv_FTY.js} +1 -1
- package/src/client/dist/pwa/assets/{SearchPage-B87WEHf3.js → SearchPage-BqTBIt5E.js} +1 -1
- package/src/client/dist/pwa/assets/SettingsPage-BLD0xWFB.js +9 -0
- package/src/client/dist/pwa/assets/{SplitWorkspacePage-B99AJUbG.js → SplitWorkspacePage-D6r2pl5g.js} +1 -1
- package/src/client/dist/pwa/assets/{TerminalPanel-DnA1MXwu.js → TerminalPanel-CladK9Vv.js} +9 -9
- package/src/client/dist/pwa/assets/{ToolCallItem-2vFggaur.js → ToolCallItem-CdzEME4w.js} +3 -3
- package/src/client/dist/pwa/assets/{TouchPan-BSU3z0aO.js → TouchPan-B4gQPwGN.js} +1 -1
- package/src/client/dist/pwa/assets/{TourReplayButton-DQwS2xdD.js → TourReplayButton-B0j6AZwU.js} +1 -1
- package/src/client/dist/pwa/assets/{WorkspaceAttentionLabels-CM1tGDje.js → WorkspaceAttentionLabels-CeVhoidC.js} +1 -1
- package/src/client/dist/pwa/assets/WorkspacePage-CK5XCuG_.js +4 -0
- package/src/client/dist/pwa/assets/{WorkspacePage-B248nKIU.css → WorkspacePage-w2bF8wTd.css} +1 -1
- package/src/client/dist/pwa/assets/{browser-DPsupI9Q.js → browser-B8tNScg1.js} +1 -1
- package/src/client/dist/pwa/assets/{build-path-tree-CbIdt-p9.js → build-path-tree-kmVAcvs_.js} +1 -1
- package/src/client/dist/pwa/assets/custom-sounds-CJwd-8TL.js +1 -0
- package/src/client/dist/pwa/assets/{de-D3Ycvf_F.js → de-BaO6kbdO.js} +2 -2
- package/src/client/dist/pwa/assets/{editor.api-DvgAtnOj.js → editor.api-CgbPVW9s.js} +1 -1
- package/src/client/dist/pwa/assets/{efforts-nmxfTVQD.js → efforts-y7Wf_Rgd.js} +2 -2
- package/src/client/dist/pwa/assets/{es-TgeS5898.js → es-DTvSsbD0.js} +2 -2
- package/src/client/dist/pwa/assets/{expand-template-Brn5O4a1.js → expand-template-BFbbjj7X.js} +1 -1
- package/src/client/dist/pwa/assets/{format-DfJ3Fv1i.js → format-BETfc4z9.js} +1 -1
- package/src/client/dist/pwa/assets/{formatters-B30bNFhq.js → formatters-C22K9vlC.js} +1 -1
- package/src/client/dist/pwa/assets/{fr-CJafryNb.js → fr-BH7V66Yz.js} +2 -2
- package/src/client/dist/pwa/assets/{freemarker2-DnZe74tc.js → freemarker2-DiRVvp9O.js} +1 -1
- package/src/client/dist/pwa/assets/{handlebars-CmuMfxzO.js → handlebars-BLYRroSQ.js} +1 -1
- package/src/client/dist/pwa/assets/{html-BD7OJFGX.js → html-DAW2vteY.js} +1 -1
- package/src/client/dist/pwa/assets/i18n-DnuTmw3O.js +1 -0
- package/src/client/dist/pwa/assets/index-CbyHfF8I.js +20 -0
- package/src/client/dist/pwa/assets/{it-C5mN8Y61.js → it-DZGbkkoy.js} +2 -2
- package/src/client/dist/pwa/assets/{javascript-Y-OrgcmW.js → javascript-Bb5J8n0k.js} +1 -1
- package/src/client/dist/pwa/assets/{liquid-BqOkKePH.js → liquid-Dqx3adnJ.js} +1 -1
- package/src/client/dist/pwa/assets/{mdx-D-58x2h0.js → mdx-D_7igLV3.js} +1 -1
- package/src/client/dist/pwa/assets/{monaco-contributions-BW4ZMKO6.js → monaco-contributions-C-0CbrAy.js} +1 -1
- package/src/client/dist/pwa/assets/{monaco.contribution-DJWqzNG-.js → monaco.contribution-c6JPBEZI.js} +2 -2
- package/src/client/dist/pwa/assets/network-auth-BE4qbvWm.js +1 -0
- package/src/client/dist/pwa/assets/notify-theme-BVFBBGrJ.js +1 -0
- package/src/client/dist/pwa/assets/{project-color-gB5yeZPN.js → project-color-CHOR2jB5.js} +1 -1
- package/src/client/dist/pwa/assets/{python-BkBj1wDC.js → python-Dqubw1F5.js} +1 -1
- package/src/client/dist/pwa/assets/{razor-DOOJ_0b8.js → razor-BQphc9Kt.js} +1 -1
- package/src/client/dist/pwa/assets/{typescript-Bheo43G5.js → typescript-Dep00l0X.js} +1 -1
- package/src/client/dist/pwa/assets/{use-search-index-status-CBpnyV-R.js → use-search-index-status-CQHHvHwM.js} +1 -1
- package/src/client/dist/pwa/assets/{use-tours-BHNasCvg.js → use-tours-IVbXTmz2.js} +2 -2
- package/src/client/dist/pwa/assets/{use-worktree-restore-CgABsWno.js → use-worktree-restore-B3s5xDGR.js} +1 -1
- package/src/client/dist/pwa/assets/{workspace-templates-BnFkigYg.js → workspace-templates-CyuTpztP.js} +1 -1
- package/src/client/dist/pwa/assets/{xml-vHt2BD26.js → xml-BMS2dvaF.js} +1 -1
- package/src/client/dist/pwa/assets/{yaml-BvhLPzP9.js → yaml-CYw92aic.js} +1 -1
- package/src/client/dist/pwa/index.html +1 -1
- package/src/client/dist/pwa/sw.js +1 -1
- package/src/mcp-server/README.md +88 -1
- package/src/mcp-server/kobo-tasks-server.ts +34 -6
- package/src/mcp-server/progress-bar-client.ts +27 -0
- package/src/mcp-server/workspace-dialogue-client.ts +26 -7
- package/src/client/dist/pwa/assets/ActivityFeed-tJmJhmtl.js +0 -2
- package/src/client/dist/pwa/assets/ComparisonPanel-BfP8APKY.js +0 -2
- package/src/client/dist/pwa/assets/CreatePage-BbtNfo0x.js +0 -2
- package/src/client/dist/pwa/assets/DiffViewer-4Q-GJgyA.js +0 -8
- package/src/client/dist/pwa/assets/GitPanel-ECnbb_rx.js +0 -3
- package/src/client/dist/pwa/assets/MainLayout-BH6grTuW.js +0 -2
- package/src/client/dist/pwa/assets/QSelect-Cb_s_nox.js +0 -1
- package/src/client/dist/pwa/assets/SettingsPage-BAZLGUyg.js +0 -9
- package/src/client/dist/pwa/assets/WorkspacePage-CQN6EKIn.js +0 -4
- package/src/client/dist/pwa/assets/custom-sounds-DR68eyF8.js +0 -1
- package/src/client/dist/pwa/assets/i18n-BY4JeBjk.js +0 -1
- package/src/client/dist/pwa/assets/index-C5fAw9ll.js +0 -20
- package/src/client/dist/pwa/assets/network-auth-BGPRhUa6.js +0 -1
- package/src/client/dist/pwa/assets/notify-theme-DZDzfxph.js +0 -1
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Print a short-lived GitHub App installation token, or a secret-free --check result."""
|
|
3
|
+
import argparse
|
|
4
|
+
import base64
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
import fcntl
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
import re
|
|
12
|
+
import stat
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import tempfile
|
|
16
|
+
import time
|
|
17
|
+
import urllib.request
|
|
18
|
+
|
|
19
|
+
RENEW_MARGIN = 120
|
|
20
|
+
LOCK_TIMEOUT = 45
|
|
21
|
+
MAX_BYTES = 65536
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TokenError(Exception):
|
|
25
|
+
"""An operator-facing failure that never contains credential material."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
29
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def read_config(path):
|
|
34
|
+
try:
|
|
35
|
+
data = json.loads(Path(path).read_text())
|
|
36
|
+
app_id = data['app_id']
|
|
37
|
+
installation_id = data['installation_id']
|
|
38
|
+
key = data['private_key']
|
|
39
|
+
if isinstance(app_id, bool) or not re.fullmatch(r'[A-Za-z0-9_.-]{1,100}', str(app_id)):
|
|
40
|
+
raise ValueError()
|
|
41
|
+
if isinstance(installation_id, bool) or not re.fullmatch(r'[1-9][0-9]*', str(installation_id)):
|
|
42
|
+
raise ValueError()
|
|
43
|
+
if not isinstance(key, str) or not Path(key).is_absolute():
|
|
44
|
+
raise ValueError()
|
|
45
|
+
key_bytes = Path(key).read_bytes()
|
|
46
|
+
if not key_bytes or len(key_bytes) > MAX_BYTES:
|
|
47
|
+
raise ValueError()
|
|
48
|
+
config = {'app_id': str(app_id), 'installation_id': str(installation_id), 'private_key': key}
|
|
49
|
+
identity = hashlib.sha256(json.dumps(config, sort_keys=True).encode() + b'\0' + key_bytes).hexdigest()
|
|
50
|
+
return config, identity
|
|
51
|
+
except (OSError, ValueError, KeyError, TypeError):
|
|
52
|
+
raise TokenError('Cannot read GitHub App configuration: check IDs and the absolute RSA key path.') from None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def secure_directory(path):
|
|
56
|
+
path.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
57
|
+
info = path.lstat()
|
|
58
|
+
if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077:
|
|
59
|
+
raise TokenError('The token cache directory must be owned by this user, private (0700), and not a symlink.')
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def private_file(path, flags):
|
|
63
|
+
fd = os.open(path, flags | os.O_NOFOLLOW | os.O_NONBLOCK, 0o600)
|
|
64
|
+
info = os.fstat(fd)
|
|
65
|
+
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077:
|
|
66
|
+
os.close(fd)
|
|
67
|
+
raise TokenError('Token cache files must be regular private files (0600) owned by this user.')
|
|
68
|
+
return fd
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def usable_token(data):
|
|
72
|
+
if not isinstance(data, dict):
|
|
73
|
+
return False
|
|
74
|
+
token, expiry = data.get('token'), data.get('expires_at')
|
|
75
|
+
if not isinstance(token, str) or not re.fullmatch(r'[A-Za-z0-9_.-]{1,8192}', token):
|
|
76
|
+
return False
|
|
77
|
+
try:
|
|
78
|
+
parsed = datetime.fromisoformat(expiry.replace('Z', '+00:00'))
|
|
79
|
+
return parsed.tzinfo is not None and parsed.timestamp() > time.time() + RENEW_MARGIN
|
|
80
|
+
except (AttributeError, TypeError, ValueError, OverflowError):
|
|
81
|
+
return False
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def read_cache(path, identity):
|
|
85
|
+
try:
|
|
86
|
+
with os.fdopen(private_file(path, os.O_RDONLY)) as handle:
|
|
87
|
+
data = json.loads(handle.read(MAX_BYTES))
|
|
88
|
+
if data.get('identity') == identity and usable_token(data):
|
|
89
|
+
return data
|
|
90
|
+
except (FileNotFoundError, ValueError, AttributeError):
|
|
91
|
+
pass
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def write_cache(path, data):
|
|
96
|
+
fd, temp = tempfile.mkstemp(prefix='.token-', dir=path.parent)
|
|
97
|
+
try:
|
|
98
|
+
with os.fdopen(fd, 'w') as handle:
|
|
99
|
+
json.dump(data, handle)
|
|
100
|
+
handle.flush()
|
|
101
|
+
os.fsync(handle.fileno())
|
|
102
|
+
os.replace(temp, path)
|
|
103
|
+
finally:
|
|
104
|
+
if os.path.exists(temp):
|
|
105
|
+
os.unlink(temp)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def encode(data):
|
|
109
|
+
return base64.urlsafe_b64encode(data).rstrip(b'=').decode('ascii')
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def jwt(config):
|
|
113
|
+
now = int(time.time())
|
|
114
|
+
header = encode(json.dumps({'alg': 'RS256', 'typ': 'JWT'}, separators=(',', ':')).encode())
|
|
115
|
+
payload = encode(json.dumps({'iat': now - 60, 'exp': now + 540, 'iss': config['app_id']},
|
|
116
|
+
separators=(',', ':')).encode())
|
|
117
|
+
unsigned = f'{header}.{payload}'.encode()
|
|
118
|
+
try:
|
|
119
|
+
# Reject EC/encrypted keys instead of sending a mislabeled or interactive JWT.
|
|
120
|
+
subprocess.run(['openssl', 'rsa', '-in', config['private_key'], '-passin', 'pass:',
|
|
121
|
+
'-check', '-noout'], check=True, capture_output=True, timeout=10)
|
|
122
|
+
signature = subprocess.run(['openssl', 'dgst', '-sha256', '-sign', config['private_key'],
|
|
123
|
+
'-passin', 'pass:'], input=unsigned, check=True,
|
|
124
|
+
capture_output=True, timeout=10).stdout
|
|
125
|
+
except (OSError, subprocess.SubprocessError):
|
|
126
|
+
raise TokenError('Cannot sign the App JWT: install OpenSSL and provide an unencrypted RSA private key.') from None
|
|
127
|
+
return f'{header}.{payload}.{encode(signature)}'
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def mint(config):
|
|
131
|
+
request = urllib.request.Request(
|
|
132
|
+
f"https://api.github.com/app/installations/{config['installation_id']}/access_tokens",
|
|
133
|
+
data=b'{}', method='POST', headers={
|
|
134
|
+
'Authorization': f'Bearer {jwt(config)}',
|
|
135
|
+
'Accept': 'application/vnd.github+json',
|
|
136
|
+
'Content-Type': 'application/json',
|
|
137
|
+
'User-Agent': 'kobo-github-app-token',
|
|
138
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
139
|
+
})
|
|
140
|
+
try:
|
|
141
|
+
with urllib.request.build_opener(NoRedirect()).open(request, timeout=20) as response:
|
|
142
|
+
data = json.loads(response.read(MAX_BYTES))
|
|
143
|
+
if not usable_token(data):
|
|
144
|
+
raise ValueError()
|
|
145
|
+
return {'token': data['token'], 'expires_at': data['expires_at']}
|
|
146
|
+
except (OSError, ValueError, TypeError):
|
|
147
|
+
raise TokenError('GitHub App token renewal failed: check network, App installation, IDs and key.') from None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def get_token(config_path, cache_dir):
|
|
151
|
+
config, identity = read_config(config_path)
|
|
152
|
+
cache_dir = Path(cache_dir).expanduser()
|
|
153
|
+
secure_directory(cache_dir)
|
|
154
|
+
with os.fdopen(private_file(cache_dir / 'token.lock', os.O_CREAT | os.O_RDWR), 'w') as lock:
|
|
155
|
+
deadline = time.monotonic() + LOCK_TIMEOUT
|
|
156
|
+
while True:
|
|
157
|
+
try:
|
|
158
|
+
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
159
|
+
break
|
|
160
|
+
except BlockingIOError:
|
|
161
|
+
if time.monotonic() >= deadline:
|
|
162
|
+
raise TokenError('Timed out waiting for another GitHub App token renewal.') from None
|
|
163
|
+
time.sleep(0.05)
|
|
164
|
+
cached = read_cache(cache_dir / 'token.json', identity)
|
|
165
|
+
if cached:
|
|
166
|
+
return {key: cached[key] for key in ('token', 'expires_at')}
|
|
167
|
+
token = mint(config)
|
|
168
|
+
write_cache(cache_dir / 'token.json', {**token, 'identity': identity})
|
|
169
|
+
return token
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def main(argv=None):
|
|
173
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
174
|
+
parser.add_argument('--config', type=Path, default=Path('/etc/kobo/github-app.json'))
|
|
175
|
+
parser.add_argument('--cache-dir', type=Path, default=Path.home() / '.cache/kobo/github-app')
|
|
176
|
+
parser.add_argument('--check', action='store_true', help='Validate authentication without printing the token.')
|
|
177
|
+
args = parser.parse_args(argv)
|
|
178
|
+
try:
|
|
179
|
+
result = get_token(args.config, args.cache_dir)
|
|
180
|
+
print(json.dumps({'status': 'ok', 'expires_at': result['expires_at']}) if args.check else result['token'])
|
|
181
|
+
return 0
|
|
182
|
+
except TokenError as error:
|
|
183
|
+
print(str(error), file=sys.stderr)
|
|
184
|
+
except (OSError, ValueError):
|
|
185
|
+
print('Cannot access the private token configuration or cache. Check paths and permissions.', file=sys.stderr)
|
|
186
|
+
return 1
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
if __name__ == '__main__':
|
|
190
|
+
sys.exit(main())
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Install a fresh Kōbō instance behind an existing Traefik. Python 3.10+."""
|
|
3
|
+
import argparse
|
|
4
|
+
import fcntl
|
|
5
|
+
import hashlib
|
|
6
|
+
import importlib.machinery
|
|
7
|
+
import importlib.util
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
import re
|
|
12
|
+
import shutil
|
|
13
|
+
import sys
|
|
14
|
+
import uuid
|
|
15
|
+
|
|
16
|
+
sys.dont_write_bytecode = True
|
|
17
|
+
ASSETS = Path(__file__).resolve().parent
|
|
18
|
+
loader = importlib.machinery.SourceFileLoader('kobo_updater', str(ASSETS / 'kobo-update'))
|
|
19
|
+
spec = importlib.util.spec_from_loader(loader.name, loader)
|
|
20
|
+
updater = importlib.util.module_from_spec(spec)
|
|
21
|
+
loader.exec_module(updater)
|
|
22
|
+
InstallError = updater.UpdateError
|
|
23
|
+
TOOLS = ('kobo-install', 'kobo-update', 'kobo-github-app-token', 'kobo-gh')
|
|
24
|
+
ENV_KEYS = updater.COMPOSE_ENV_KEYS
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def safe_path(value):
|
|
28
|
+
path = Path(value)
|
|
29
|
+
if not path.is_absolute() or any(re.search(r'[\x00-\x1f\x7f\"\'`$,:\\]', str(p)) for p in (path, path.resolve())):
|
|
30
|
+
raise InstallError('Use absolute paths without control characters, quotes, $, commas, colons or backslashes.')
|
|
31
|
+
return path.resolve()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def validate_config(config):
|
|
35
|
+
domain = config['domain']
|
|
36
|
+
if (len(domain) > 253 or '.' not in domain or
|
|
37
|
+
not all(re.fullmatch(r'[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?', label)
|
|
38
|
+
for label in domain.split('.'))):
|
|
39
|
+
raise InstallError('Domain must be a DNS hostname, without scheme, port or path.')
|
|
40
|
+
for name in ('network', 'entrypoint', 'certresolver'):
|
|
41
|
+
if name == 'certresolver' and config[name] == '':
|
|
42
|
+
continue
|
|
43
|
+
if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_.-]{0,99}', config[name]):
|
|
44
|
+
raise InstallError(f'Invalid {name}; use letters, numbers, dots, underscores or hyphens.')
|
|
45
|
+
projects = safe_path(config['projects_directory'])
|
|
46
|
+
# A bind over the runtime/home/config would hide executables or persistent credentials.
|
|
47
|
+
protected = ('/root', '/app', '/usr', '/bin', '/sbin', '/etc', '/run', '/var', '/proc', '/sys', '/dev')
|
|
48
|
+
if projects == Path('/') or any(projects == Path(p) or Path(p) in projects.parents for p in protected):
|
|
49
|
+
raise InstallError('Projects must be outside container system paths, /app and /root (for example /opt/kobo/projects).')
|
|
50
|
+
if projects.exists() and not projects.is_dir():
|
|
51
|
+
raise InstallError('The projects path must be a directory.')
|
|
52
|
+
app = [config.get(k) for k in ('github_app_id', 'github_installation_id', 'github_private_key')]
|
|
53
|
+
if any(app):
|
|
54
|
+
if not all(app):
|
|
55
|
+
raise InstallError('GitHub App requires all three flags: App ID, installation ID and private key.')
|
|
56
|
+
if not re.fullmatch(r'[A-Za-z0-9_.-]{1,100}', app[0]) or not re.fullmatch(r'[1-9][0-9]*', app[1]):
|
|
57
|
+
raise InstallError('Invalid GitHub App or installation ID.')
|
|
58
|
+
key = safe_path(app[2])
|
|
59
|
+
if not key.is_file() or not os.access(key, os.R_OK) or key.stat().st_mode & 0o077:
|
|
60
|
+
raise InstallError('The GitHub private key must be a readable regular file with private permissions (0600).')
|
|
61
|
+
if config['version'] != 'latest':
|
|
62
|
+
updater.normalize_version(config['version'])
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def render(config, stack):
|
|
66
|
+
text = (ASSETS.parent / 'docker-compose.vps.yml').read_text()
|
|
67
|
+
if not config['certresolver']:
|
|
68
|
+
text = '\n'.join(line for line in text.split('\n') if 'tls.certresolver:' not in line)
|
|
69
|
+
if config['docker_socket']:
|
|
70
|
+
text = text.replace(' # - /var/run/docker.sock:', ' - /var/run/docker.sock:')
|
|
71
|
+
if config.get('github_app_id'):
|
|
72
|
+
mounts = ''
|
|
73
|
+
for source, target in (
|
|
74
|
+
(stack / 'bin/kobo-gh', '/usr/local/bin/gh'),
|
|
75
|
+
(stack / 'bin/kobo-github-app-token', '/usr/local/bin/kobo-github-app-token'),
|
|
76
|
+
(stack / 'auth/github-app.json', '/etc/kobo/github-app.json'),
|
|
77
|
+
(safe_path(config['github_private_key']), '/run/secrets/kobo-github-app.pem'),
|
|
78
|
+
):
|
|
79
|
+
mounts += (f' - type: bind\n source: {json.dumps(str(source))}\n'
|
|
80
|
+
f' target: {target}\n read_only: true\n'
|
|
81
|
+
' bind:\n create_host_path: false\n')
|
|
82
|
+
text = text.replace(' networks:\n', mounts + ' networks:\n', 1)
|
|
83
|
+
values = (config['domain'], config['projects_directory'], config['network'],
|
|
84
|
+
config['entrypoint'], config['certresolver'])
|
|
85
|
+
env = ''.join(f"{key}='{value}'\n" for key, value in zip(ENV_KEYS, values))
|
|
86
|
+
return text, env
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def digest(name, data):
|
|
90
|
+
# kobo-update owns the image pin after installation; all other edits still require review.
|
|
91
|
+
if name == 'compose.yaml':
|
|
92
|
+
data = updater.image_config(data.decode(), 'kobo:managed').encode()
|
|
93
|
+
return hashlib.sha256(data).hexdigest()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class Installer(updater.Updater):
|
|
97
|
+
def __init__(self, stack):
|
|
98
|
+
super().__init__(safe_path(str(stack)))
|
|
99
|
+
self.manifest = self.stack / '.kobo-install.json'
|
|
100
|
+
self.project_name = 'kobo'
|
|
101
|
+
self.state = json.loads(self.manifest.read_text()) if self.manifest.exists() else {}
|
|
102
|
+
if self.state and (self.state.get('format') != 1 or self.state.get('directory') != str(self.stack)):
|
|
103
|
+
raise InstallError('Unrecognized or relocated installation manifest; review it manually.')
|
|
104
|
+
|
|
105
|
+
def preflight(self, config):
|
|
106
|
+
for tool in ('docker', 'git'):
|
|
107
|
+
if not shutil.which(tool):
|
|
108
|
+
raise InstallError(f'Install {tool} first.')
|
|
109
|
+
endpoint = os.environ.get('DOCKER_HOST')
|
|
110
|
+
if not endpoint or os.environ.get('DOCKER_CONTEXT'):
|
|
111
|
+
context = json.loads(self.run(['docker', 'context', 'inspect']))[0]
|
|
112
|
+
endpoint = context['Endpoints']['docker']['Host']
|
|
113
|
+
if not endpoint.startswith('unix://'):
|
|
114
|
+
raise InstallError('Run this installer on the Docker host using a local Unix socket context.')
|
|
115
|
+
info = json.loads(self.run(['docker', 'info', '--format', '{{json .}}']))
|
|
116
|
+
if info.get('OSType') != 'linux' or info.get('Architecture') not in ('x86_64', 'amd64'):
|
|
117
|
+
raise InstallError('This image currently requires Linux amd64.')
|
|
118
|
+
self.run(['docker', 'compose', 'version'])
|
|
119
|
+
self.run(['docker', 'network', 'inspect', config['network']])
|
|
120
|
+
if config['docker_socket'] and not Path('/var/run/docker.sock').is_socket():
|
|
121
|
+
raise InstallError('Docker socket access requires /var/run/docker.sock on this host.')
|
|
122
|
+
|
|
123
|
+
def discover(self):
|
|
124
|
+
"""Suggest CLI/env-based Traefik settings without displaying raw container configuration."""
|
|
125
|
+
found = {name: set() for name in ('network', 'entrypoint', 'certresolver')}
|
|
126
|
+
ids = self.run(['docker', 'ps', '-q']).split()
|
|
127
|
+
if not ids:
|
|
128
|
+
return found
|
|
129
|
+
for container in json.loads(self.run(['docker', 'inspect', *ids])):
|
|
130
|
+
config = container.get('Config', {})
|
|
131
|
+
if 'traefik' not in config.get('Image', '').lower():
|
|
132
|
+
continue
|
|
133
|
+
found['network'].update(name for name in container.get('NetworkSettings', {}).get('Networks', {})
|
|
134
|
+
if name not in {'bridge', 'host', 'none'})
|
|
135
|
+
args = (config.get('Cmd') or []) + (config.get('Env') or [])
|
|
136
|
+
for arg in args:
|
|
137
|
+
entry = re.fullmatch(r'--entrypoints\.([\w-]+)\.address=:(?:443)(?:/tcp)?', arg, re.I)
|
|
138
|
+
env_entry = re.fullmatch(r'TRAEFIK_ENTRYPOINTS_([\w-]+)_ADDRESS=:443(?:/tcp)?', arg, re.I)
|
|
139
|
+
resolver = re.match(r'--certificatesresolvers\.([\w-]+)\.', arg, re.I)
|
|
140
|
+
env_resolver = re.match(r'TRAEFIK_CERTIFICATESRESOLVERS_([\w-]+)_ACME_', arg, re.I)
|
|
141
|
+
if entry or env_entry:
|
|
142
|
+
found['entrypoint'].add((entry or env_entry).group(1).lower())
|
|
143
|
+
if resolver or env_resolver:
|
|
144
|
+
found['certresolver'].add((resolver or env_resolver).group(1).lower())
|
|
145
|
+
return found
|
|
146
|
+
|
|
147
|
+
def collisions(self):
|
|
148
|
+
containers = self.run(['docker', 'ps', '-aq', '--filter', 'label=com.docker.compose.project=kobo'])
|
|
149
|
+
volumes = self.run(['docker', 'volume', 'ls', '-q', '--filter', 'name=^kobo_kobo-home$'])
|
|
150
|
+
routers = self.run(['docker', 'ps', '-aq', '--filter', 'label=traefik.http.routers.kobo.rule'])
|
|
151
|
+
if containers or volumes or routers:
|
|
152
|
+
raise InstallError('Compose project kobo, router kobo or volume kobo_kobo-home already exists. Refusing to adopt or replace it.')
|
|
153
|
+
|
|
154
|
+
def assert_fresh(self):
|
|
155
|
+
for name in ('compose.yaml', '.env', '.kobo-install.json', 'bin', 'auth', 'docker-compose.vps.yml'):
|
|
156
|
+
path = self.stack / name
|
|
157
|
+
if path.exists() or path.is_symlink():
|
|
158
|
+
raise InstallError(f'Unmanaged installation file already exists: {path}. Use the manual guide.')
|
|
159
|
+
|
|
160
|
+
def save(self, **changes):
|
|
161
|
+
self.state.update(changes)
|
|
162
|
+
updater.atomic_write(self.manifest, (json.dumps(self.state, indent=2) + '\n').encode())
|
|
163
|
+
|
|
164
|
+
def initialize(self, config):
|
|
165
|
+
self.assert_fresh()
|
|
166
|
+
compose, env = render(config, self.stack)
|
|
167
|
+
files = {'compose.yaml': compose.encode(), '.env': env.encode(),
|
|
168
|
+
'docker-compose.vps.yml': (ASSETS.parent / 'docker-compose.vps.yml').read_bytes()}
|
|
169
|
+
files.update({f'bin/{name}': (ASSETS / name).read_bytes() for name in TOOLS})
|
|
170
|
+
if config.get('github_app_id'):
|
|
171
|
+
files['auth/github-app.json'] = json.dumps({
|
|
172
|
+
'app_id': config['github_app_id'], 'installation_id': config['github_installation_id'],
|
|
173
|
+
'private_key': '/run/secrets/kobo-github-app.pem',
|
|
174
|
+
}, indent=2).encode()
|
|
175
|
+
self.stack.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
176
|
+
# Record intent first. An interrupted file copy is explicit, never silently adopted.
|
|
177
|
+
self.save(format=1, directory=str(self.stack), config=config, phase='writing-files',
|
|
178
|
+
hashes={name: digest(name, data) for name, data in files.items()})
|
|
179
|
+
for name, data in files.items():
|
|
180
|
+
path = self.stack / name
|
|
181
|
+
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
182
|
+
updater.atomic_write(path, data, 0o755 if name.startswith('bin/') else 0o600)
|
|
183
|
+
Path(config['projects_directory']).mkdir(parents=True, exist_ok=True)
|
|
184
|
+
self.save(phase='configured')
|
|
185
|
+
|
|
186
|
+
def verify_files(self):
|
|
187
|
+
if self.state['phase'] == 'writing-files':
|
|
188
|
+
raise InstallError('File preparation was interrupted. Review .kobo-install.json and files before retrying; nothing was started.')
|
|
189
|
+
for name, expected in self.state['hashes'].items():
|
|
190
|
+
path = self.stack / name
|
|
191
|
+
if path.is_symlink() or not path.is_file() or digest(name, path.read_bytes()) != expected:
|
|
192
|
+
raise InstallError(f'Managed file changed or missing: {path}. Preserved; review it manually.')
|
|
193
|
+
if self.state['phase'] != 'complete' and self.state.get('image'):
|
|
194
|
+
if f' image: {self.state["image"]}\n' not in self.compose_file.read_text():
|
|
195
|
+
raise InstallError('Prepared image was changed; refusing to start it.')
|
|
196
|
+
|
|
197
|
+
def validate_compose(self, config):
|
|
198
|
+
# No files or directories are needed for --check. All substitutions are supplied explicitly.
|
|
199
|
+
text, _ = render(config, self.stack)
|
|
200
|
+
for key, value in zip(ENV_KEYS, (config['domain'], config['projects_directory'], config['network'],
|
|
201
|
+
config['entrypoint'], config['certresolver'])):
|
|
202
|
+
text = re.sub(r'\$\{' + key + r'(?::[^}]*)?\}', lambda _: value, text)
|
|
203
|
+
self.run(['docker', 'compose', '--env-file', '/dev/null', '-p', 'kobo', '-f', '-', 'config', '--quiet'],
|
|
204
|
+
data=text.encode())
|
|
205
|
+
|
|
206
|
+
def configure_github(self):
|
|
207
|
+
self.run(self.compose('exec', '-T', 'kobo', '/usr/local/bin/kobo-github-app-token', '--check'))
|
|
208
|
+
helper = 'credential.https://github.com.helper'
|
|
209
|
+
self.run(self.compose('exec', '-T', 'kobo', 'git', 'config', '--global', '--replace-all', helper, ''))
|
|
210
|
+
self.run(self.compose('exec', '-T', 'kobo', 'git', 'config', '--global', '--add', helper,
|
|
211
|
+
'!/usr/local/bin/gh auth git-credential'))
|
|
212
|
+
|
|
213
|
+
def assert_started(self):
|
|
214
|
+
container = self.run(self.compose('ps', '-aq', 'kobo'))
|
|
215
|
+
if not container or len(container.split()) != 1:
|
|
216
|
+
raise InstallError('Expected installation container is missing or ambiguous. Inspect compose ps; no automatic restart was attempted.')
|
|
217
|
+
info = json.loads(self.run(['docker', 'inspect', container]))[0]
|
|
218
|
+
expected_hash = self.run(self.compose('config', '--hash', 'kobo')).split()
|
|
219
|
+
labels = info.get('Config', {}).get('Labels', {}) or {}
|
|
220
|
+
state = info.get('State', {})
|
|
221
|
+
expected_labels = {'com.docker.compose.project': 'kobo', 'com.docker.compose.service': 'kobo',
|
|
222
|
+
'com.docker.compose.project.working_dir': str(self.stack)}
|
|
223
|
+
if (not state.get('Running') or state.get('Health', {}).get('Status') != 'healthy'
|
|
224
|
+
or info.get('Config', {}).get('Image') != self.state['image']
|
|
225
|
+
or any(labels.get(key) != value for key, value in expected_labels.items())
|
|
226
|
+
or len(expected_hash) != 2 or expected_hash[0] != 'kobo'
|
|
227
|
+
or labels.get('com.docker.compose.config-hash') != expected_hash[1]):
|
|
228
|
+
raise InstallError('Installation container is unhealthy or differs from the prepared configuration. Inspect compose ps and private logs; no automatic restart was attempted.')
|
|
229
|
+
self.container = container
|
|
230
|
+
|
|
231
|
+
def finish(self, config):
|
|
232
|
+
# A previous up may have succeeded even if its CLI or the HTTPS check failed.
|
|
233
|
+
# Verify ownership before doing anything; never recreate an uncertain container.
|
|
234
|
+
self.assert_started()
|
|
235
|
+
self.save(phase='verifying')
|
|
236
|
+
if config.get('github_app_id') and not self.state.get('github_configured'):
|
|
237
|
+
self.configure_github()
|
|
238
|
+
self.save(github_configured=True)
|
|
239
|
+
self.public_url = 'https://' + config['domain']
|
|
240
|
+
self.verify(self.state['version'])
|
|
241
|
+
self.save(phase='complete')
|
|
242
|
+
print(f'Kōbō is ready at {self.public_url}. Follow docs/deployment-vps.md to retrieve the private login link and sign in to an engine.')
|
|
243
|
+
|
|
244
|
+
def execute(self, config, check=False, prepare=False):
|
|
245
|
+
validate_config(config)
|
|
246
|
+
if self.state:
|
|
247
|
+
if config != self.state['config']:
|
|
248
|
+
raise InstallError('This directory is already configured with different options. No reconfiguration was applied.')
|
|
249
|
+
self.verify_files()
|
|
250
|
+
else:
|
|
251
|
+
self.assert_fresh()
|
|
252
|
+
self.preflight(config)
|
|
253
|
+
if not self.state or self.state['phase'] in {'configured', 'building', 'prepared'}:
|
|
254
|
+
self.collisions()
|
|
255
|
+
self.validate_compose(config)
|
|
256
|
+
if check:
|
|
257
|
+
print('Configuration and local Docker checks passed. No files, images or containers changed.')
|
|
258
|
+
return
|
|
259
|
+
if self.state.get('phase') == 'complete':
|
|
260
|
+
print('Installation already complete; no restart or reconfiguration. Use bin/kobo-update to upgrade.')
|
|
261
|
+
return
|
|
262
|
+
if self.state.get('phase') in {'starting', 'verifying'}:
|
|
263
|
+
if prepare:
|
|
264
|
+
raise InstallError('Start was already attempted. Inspect compose ps or rerun without --prepare to verify it.')
|
|
265
|
+
self.finish(config)
|
|
266
|
+
return
|
|
267
|
+
if not self.state:
|
|
268
|
+
self.initialize(config)
|
|
269
|
+
if self.state['phase'] != 'prepared':
|
|
270
|
+
self.installed = '0.0.0'
|
|
271
|
+
version, sha = self.resolve(config['version'])
|
|
272
|
+
self.job = self.stack / 'install-builds' / uuid.uuid4().hex
|
|
273
|
+
self.job.mkdir(mode=0o700, parents=True)
|
|
274
|
+
self.log = self.job / 'install.log'
|
|
275
|
+
self.log.touch(mode=0o600)
|
|
276
|
+
self.save(phase='building', version=version, sha=sha, log=str(self.log))
|
|
277
|
+
print(f'Building Kōbō {version}. Private log: {self.log}', flush=True)
|
|
278
|
+
image = self.build(version, sha)
|
|
279
|
+
# Older release images also need the explicit helper dependencies.
|
|
280
|
+
self.run(['docker', 'run', '--rm', '--network', 'none', '--entrypoint', 'sh', image,
|
|
281
|
+
'-c', 'python3 -c "import sys; assert sys.version_info >= (3,10)" && openssl version'])
|
|
282
|
+
self.verify_files()
|
|
283
|
+
self.save(phase='pinning', image=image)
|
|
284
|
+
updater.atomic_write(self.compose_file, updater.image_config(self.compose_file.read_text(), image).encode())
|
|
285
|
+
self.save(phase='prepared')
|
|
286
|
+
if prepare:
|
|
287
|
+
print('Image and configuration ready. Run the same command without --prepare to start.')
|
|
288
|
+
return
|
|
289
|
+
self.verify_files()
|
|
290
|
+
self.collisions()
|
|
291
|
+
self.run(self.compose('config', '--quiet'))
|
|
292
|
+
self.save(phase='starting')
|
|
293
|
+
self.run(self.compose('up', '-d', '--wait', '--wait-timeout', '180', 'kobo'), timeout=240)
|
|
294
|
+
self.finish(config)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def arguments(argv=None):
|
|
298
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
299
|
+
parser.add_argument('--directory', type=Path, required=True)
|
|
300
|
+
for name in ('domain', 'projects-directory', 'network', 'entrypoint', 'certresolver', 'version',
|
|
301
|
+
'github-app-id', 'github-installation-id', 'github-private-key'):
|
|
302
|
+
parser.add_argument('--' + name)
|
|
303
|
+
parser.add_argument('--docker-socket', action='store_true', default=None,
|
|
304
|
+
help='Give agents root-equivalent access to this host through Docker.')
|
|
305
|
+
parser.add_argument('--yes', action='store_true', help='Use explicit flags without interactive questions.')
|
|
306
|
+
mode = parser.add_mutually_exclusive_group()
|
|
307
|
+
mode.add_argument('--check', action='store_true', help='Read-only local prerequisite/configuration checks.')
|
|
308
|
+
mode.add_argument('--prepare', action='store_true', help='Write configuration and build without starting.')
|
|
309
|
+
return parser.parse_args(argv)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def configure(args, installer):
|
|
313
|
+
names = ('domain', 'projects_directory', 'network', 'entrypoint', 'certresolver', 'version',
|
|
314
|
+
'github_app_id', 'github_installation_id', 'github_private_key', 'docker_socket')
|
|
315
|
+
config = installer.state.get('config', {}).copy()
|
|
316
|
+
config.update({name: getattr(args, name) for name in names if getattr(args, name) is not None})
|
|
317
|
+
interactive = sys.stdin.isatty() and not args.yes
|
|
318
|
+
suggestions = installer.discover() if interactive and not installer.state else {}
|
|
319
|
+
|
|
320
|
+
def ask(name, label, default=None):
|
|
321
|
+
if name in config:
|
|
322
|
+
return
|
|
323
|
+
if interactive:
|
|
324
|
+
options = sorted(suggestions.get(name, []))
|
|
325
|
+
if len(options) == 1:
|
|
326
|
+
default = options[0]
|
|
327
|
+
if options:
|
|
328
|
+
print(f'Detected {name}: {", ".join(options)}')
|
|
329
|
+
answer = input(f'{label}' + (f' [{default}]' if default is not None else '') + ': ').strip()
|
|
330
|
+
config[name] = answer or default
|
|
331
|
+
else:
|
|
332
|
+
config[name] = default
|
|
333
|
+
if config[name] is None:
|
|
334
|
+
raise InstallError(f'Missing --{name.replace("_", "-")}.')
|
|
335
|
+
|
|
336
|
+
ask('domain', 'Public hostname')
|
|
337
|
+
ask('projects_directory', 'Projects directory', str(installer.stack / 'projects'))
|
|
338
|
+
ask('network', 'Existing Traefik Docker network')
|
|
339
|
+
ask('entrypoint', 'Traefik HTTPS entrypoint')
|
|
340
|
+
ask('certresolver', 'Traefik certificate resolver (empty for preloaded certificate)', '')
|
|
341
|
+
ask('version', 'Stable release version', 'latest')
|
|
342
|
+
if config.get('docker_socket') is None:
|
|
343
|
+
config['docker_socket'] = interactive and input('Give agents Docker access (root-equivalent host control)? [y/N]: ').lower() == 'y'
|
|
344
|
+
if not any(config.get(name) for name in ('github_app_id', 'github_installation_id', 'github_private_key')):
|
|
345
|
+
if interactive and input('Configure a GitHub App bot? [y/N]: ').lower() == 'y':
|
|
346
|
+
ask('github_app_id', 'GitHub App client ID or App ID')
|
|
347
|
+
ask('github_installation_id', 'GitHub installation ID')
|
|
348
|
+
ask('github_private_key', 'Absolute path to private RSA key (0600)')
|
|
349
|
+
for name in ('github_app_id', 'github_installation_id', 'github_private_key'):
|
|
350
|
+
config.setdefault(name, None)
|
|
351
|
+
config['projects_directory'] = str(safe_path(config['projects_directory']))
|
|
352
|
+
if config['github_private_key']:
|
|
353
|
+
config['github_private_key'] = str(safe_path(config['github_private_key']))
|
|
354
|
+
return config
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def main(argv=None):
|
|
358
|
+
args = arguments(argv)
|
|
359
|
+
try:
|
|
360
|
+
installer = Installer(args.directory)
|
|
361
|
+
config = configure(args, installer)
|
|
362
|
+
validate_config(config)
|
|
363
|
+
if args.check:
|
|
364
|
+
installer.execute(config, check=True)
|
|
365
|
+
return 0
|
|
366
|
+
print(f'Install directory: {installer.stack}\nURL: https://{config["domain"]}\n'
|
|
367
|
+
f'Projects: {config["projects_directory"]}\nDocker socket: {config["docker_socket"]}\n'
|
|
368
|
+
f'GitHub App: {bool(config["github_app_id"])}')
|
|
369
|
+
if not args.yes and not installer.state:
|
|
370
|
+
if not sys.stdin.isatty() or input('Create this installation? [y/N]: ').lower() != 'y':
|
|
371
|
+
raise InstallError('Installation not started. Use --yes with explicit configuration for unattended setup.')
|
|
372
|
+
installer.stack.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
373
|
+
with (installer.stack / '.update.lock').open('a') as lock:
|
|
374
|
+
try:
|
|
375
|
+
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
376
|
+
except BlockingIOError:
|
|
377
|
+
raise InstallError('An installer or updater already holds this directory lock.') from None
|
|
378
|
+
# Reload after locking so concurrent installers cannot adopt one another's stale state.
|
|
379
|
+
installer = Installer(args.directory)
|
|
380
|
+
installer.execute(config, prepare=args.prepare)
|
|
381
|
+
return 0
|
|
382
|
+
except (InstallError, OSError, ValueError, KeyError, EOFError) as error:
|
|
383
|
+
print(str(error) if isinstance(error, InstallError) else
|
|
384
|
+
'Installation failed: check local paths, prerequisites and the private installation state.', file=sys.stderr)
|
|
385
|
+
return 1
|
|
386
|
+
except KeyboardInterrupt:
|
|
387
|
+
print('Interrupted. Files and data retained; inspect .kobo-install.json before resuming.', file=sys.stderr)
|
|
388
|
+
return 130
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
if __name__ == '__main__':
|
|
392
|
+
sys.exit(main())
|