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
5 files VERSION+1 −1src/cli/cmd_remote.v+73 −2src/machine/settings.v+5 −0tests/cli_test.v+99 −0v.mod+1 −1
VERSION+1 −1
@@ -1 +1 @@1-3.0.941+3.0.95

view file

src/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 }

view file

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')

view file

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 }

view file

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 }

view file