8288d349f168
set up S3 by signing in to a Kepr with koh remote <host> (3.0.95)
- time
- 2026-09-27 15:23 UTC · 2 weeks ago
- machine
- Junes-MacBook-Pro.local
- agent
- deepseek/deepseek-flash
- session
- koh3 help and history
- parent
- 4d8f6c23e57c
↓ koh-8288d349.tar.gzatlas at this save
files changed5 files
VERSION+1 −1src/cli/cmd_remote.v+73 −2src/machine/settings.v+5 −0tests/cli_test.v+99 −0v.mod+1 −1src/cli/cmd_remote.v+73 −2
@@ -9,6 +9,8 @@99 module cli1010 1111 import json212+import net.http13+import net.urllib1214 import os1315 import src.fmt1416 import src.machine@@ -17,7 +19,8 @@1719 1820 pub fn cmd_remote(raw []string) int {1921 args := parse('remote', raw, Options{20- flags: ['json', 'forget']22+ values: ['code']23+ flags: ['json', 'forget']2124 }) or { return usage_error(err.msg()) }2225 if args.flag('forget') {2326 if !machine.has_remote() {@@ -76,6 +79,9 @@7679 }7780 7881 fn set_remote(args Args) int {82+ if args.positional_at(0) !in [machine.kind_folder, machine.kind_s3] {83+ return set_kepr_remote(args)84+ }7985 kind := args.positional_at(0)8086 match kind {8187 machine.kind_folder {@@ -106,11 +112,68 @@106112 return save_after_check(settings, args, access_key, secret_key)107113 }108114 else {109- return usage_error('remote takes "folder" or "s3", not "${kind}"')115+ return usage_error('remote takes "folder", "s3", or a Kepr host')110116 }111117 }112118 }113119 120+fn set_kepr_remote(args Args) int {121+ if args.positional.len != 1 {122+ return usage_error('koh remote <host> accepts one Kepr host')123+ }124+ mut host := args.positional_at(0)125+ if !host.starts_with('http://') && !host.starts_with('https://') {126+ host = 'https://${host}'127+ }128+ host = host.trim_right('/')129+ connect_url := '${host}/connect?machine=${machine.name()}'130+ println('Open ${connect_url} and sign in')131+ open_command := if os.user_os() == 'macos' { 'open' } else { 'xdg-open' }132+ // Quoted: a machine name can hold anything, and this line goes to the shell.133+ os.execute('${open_command} "${connect_url}" >/dev/null 2>&1 &')134+ mut code := args.value('code')135+ if code == '' {136+ if !interactive() {137+ return fail('setting up a Kepr remote needs a terminal, or use --code <code>')138+ }139+ print('paste the code: ')140+ code = os.get_line().trim_space()141+ }142+ response := http.fetch(143+ url: '${host}/api/connect'144+ method: .post145+ header: http.new_header(146+ key: .content_type147+ value: 'application/x-www-form-urlencoded'148+ )149+ data: 'code=${urllib.query_escape(code)}'150+ enable_http2: false151+ ) or { return refuse(args.json(), 'could not reach Kepr: ${err.msg()}') }152+ if response.status_code == 404 {153+ probe := http.fetch(url: connect_url, method: .get, enable_http2: false) or {154+ return refuse(args.json(), 'that code is wrong or has expired — nothing was changed')155+ }156+ if probe.status_code == 404 {157+ return refuse(args.json(), 'this Kepr doesn\'t hand out bucket access; use "koh remote s3"')158+ }159+ return refuse(args.json(), 'that code is wrong or has expired — nothing was changed')160+ }161+ if response.status_code < 200 || response.status_code >= 300 {162+ return refuse(args.json(), 'Kepr refused the code — nothing was changed')163+ }164+ reply := json2.decode[KeprReply](response.body) or {165+ return refuse(args.json(), 'Kepr returned an invalid response — nothing was changed')166+ }167+ settings := machine.RemoteSettings{168+ kind: machine.kind_s3169+ endpoint: reply.endpoint170+ bucket: reply.bucket171+ region: reply.region172+ kepr: host173+ }174+ return save_after_check(settings, args, reply.access_key_id, reply.secret_access_key)175+}176+114177 // save_after_check is the whole point: nothing is written to ~/.config/koh115178 // until the remote has passed the test.116179 fn save_after_check(settings machine.RemoteSettings, args Args, access_key string, secret_key string) int {@@ -245,4 +308,12 @@245308 configured bool246309 kind string247310 target string311+}312+313+struct KeprReply {314+ endpoint string315+ bucket string316+ region string317+ access_key_id string318+ secret_access_key string248319 }src/machine/settings.v+5 −0
@@ -21,6 +21,7 @@2121 endpoint string // s3 backend2222 bucket string2323 region string24+ kepr string2425 }2526 2627 pub struct Credentials {@@ -39,6 +40,9 @@3940 text += 'endpoint=${settings.endpoint}\n'4041 text += 'bucket=${settings.bucket}\n'4142 text += 'region=${settings.region}\n'43+ if settings.kepr != '' {44+ text += 'kepr=${settings.kepr}\n'45+ }4246 }4347 os.write_file(config_path(remote_file), text)!4448 }@@ -54,6 +58,7 @@5458 endpoint: values['endpoint'] or { '' }5559 bucket: values['bucket'] or { '' }5660 region: values['region'] or { '' }61+ kepr: values['kepr'] or { '' }5762 }5863 if settings.kind !in [kind_folder, kind_s3] {5964 return error('the remote setting in ${config_path(remote_file)} is not readable — run "koh remote" again')tests/cli_test.v+99 −0
@@ -1,5 +1,7 @@11 module tests22 3+import net4+import net.http35 import os46 import time57 @@ -1265,4 +1267,101 @@12651267 assert migrated.code == 0, migrated.err12661268 // They stay where they were, for the user to keep or delete.12671269 assert os.read_file(os.join_path(dir, '.kohconfig'))! == 'stable_days = 14\n'1270+}1271+1272+struct FakeKepr {1273+ no_access bool1274+mut:1275+ objects map[string][]u81276+}1277+1278+fn (mut h FakeKepr) handle(req http.Request) http.Response {1279+ if req.method == .post && req.url == '/api/connect' {1280+ if req.data == 'code=good' && !h.no_access {1281+ return http.Response{ status_code: 200, status_msg: 'OK', body: '{"endpoint":"http://${req.header.get_custom('Host', http.HeaderQueryConfig{}) or { '' }}","bucket":"bucket","region":"test","access_key_id":"access","secret_access_key":"secret"}' }1282+ }1283+ return http.Response{ status_code: 404, status_msg: 'Not Found', body: '{"error":"no_code","message":"no code"}' }1284+ }1285+ if req.method == .get && req.url.starts_with('/connect') {1286+ return http.Response{ status_code: if h.no_access { 404 } else { 302 }, status_msg: 'Found' }1287+ }1288+ key := req.url1289+ if key.starts_with('/bucket/') {1290+ match req.method {1291+ .put {1292+ if req.header.get_custom('If-None-Match', http.HeaderQueryConfig{}) or { '' } == '*' && key in h.objects {1293+ return http.Response{ status_code: 412, status_msg: 'Precondition Failed' }1294+ }1295+ h.objects[key] = req.data.bytes()1296+ return http.Response{ status_code: 200, status_msg: 'OK' }1297+ }1298+ .get {1299+ if body := h.objects[key] {1300+ return http.Response{1301+ status_code: 2001302+ status_msg: 'OK'1303+ body: body.bytestr()1304+ header: http.new_header(1305+ key: .etag1306+ value: '"tag"'1307+ )1308+ }1309+ }1310+ return http.Response{ status_code: 404, status_msg: 'Not Found' }1311+ }1312+ .delete {1313+ h.objects.delete(key)1314+ return http.Response{ status_code: 204, status_msg: 'No Content' }1315+ }1316+ else {}1317+ }1318+ }1319+ return http.Response{ status_code: 404, status_msg: 'Not Found' }1320+}1321+1322+fn start_fake_kepr(no_access bool) !(int, &http.Server) {1323+ for port in 19401 .. 19411 {1324+ listener := net.listen_tcp(.ip, '127.0.0.1:${port}') or { continue }1325+ mut server := &http.Server{ listener: listener, handler: &FakeKepr{ no_access: no_access }, show_startup_message: false }1326+ spawn server.listen_and_serve()1327+ server.wait_till_running()!1328+ return port, server1329+ }1330+ return error('no free fake Kepr port')1331+}1332+1333+fn test_remote_kepr_saves_and_refuses_without_changes() {1334+ repo := temp_repo('kepr')1335+ home := temp_repo('kepr-home')1336+ defer { os.rmdir_all(repo) or {} }1337+ defer { os.rmdir_all(home) or {} }1338+ init_repo(repo)1339+ port, mut server := start_fake_kepr(false)!1340+ defer { server.stop() }1341+ host := 'http://127.0.0.1:${port}'1342+ good := run_home(repo, home, 'remote', host, '--code', 'good')1343+ assert good.code == 0, good.err1344+ remote_path := os.join_path(home, '.config', 'koh', 'remote')1345+ assert os.read_file(remote_path)! == '# written by koh remote — the remote for every repo on this machine\nkind=s3\nendpoint=${host}\nbucket=bucket\nregion=test\nkepr=${host}\n'1346+ assert os.read_file(os.join_path(home, '.config', 'koh', 'credentials'))! == '# written by koh remote — keep this file private\naccess_key=access\nsecret_key=secret\n'1347+ before := os.read_file(remote_path)!1348+ bad := run_home(repo, home, 'remote', host, '--code', 'bad')1349+ assert bad.code == 11350+ assert bad.err.contains('wrong or has expired')1351+ assert os.read_file(remote_path)! == before1352+ assert run_home(repo, home, 'remote', host).code == 11353+}1354+1355+fn test_remote_kepr_without_bucket_access_is_explained() {1356+ repo := temp_repo('kepr-no-access')1357+ home := temp_repo('kepr-no-access-home')1358+ defer { os.rmdir_all(repo) or {} }1359+ defer { os.rmdir_all(home) or {} }1360+ init_repo(repo)1361+ port, mut server := start_fake_kepr(true)!1362+ defer { server.stop() }1363+ r := run_home(repo, home, 'remote', 'http://127.0.0.1:${port}', '--code', 'bad')1364+ assert r.code == 11365+ assert r.err.contains("doesn't hand out bucket access")1366+ assert !os.exists(os.join_path(home, '.config', 'koh', 'remote'))12681367 }v.mod+1 −1
@@ -1,6 +1,6 @@11 Module {22 name: 'koh3'3- version: '3.0.94'3+ version: '3.0.95'44 description: 'One SQLite file per repo, one line of history.'55 license: 'MIT'66 }