cmd_remote.v

7.6 KB · raw

1//! `koh remote` — the machine's remote, set up once and used by every repo.2//!3//! Setup is interactive by default, because it ends with a live connection4//! test: a remote is only saved once it has proved it can store an object and5//! refuse to overwrite it. `folder` and `s3` forms are available for a machine6//! being set up by a script, and an agent can read the current setting with7//! `--json` (which never prints the secret key).89module cli1011import json212import os13import src.fmt14import src.machine15import src.remote16import term.termios1718pub fn cmd_remote(raw []string) int {19	args := parse('remote', raw, Options{20		flags: ['json', 'forget']21	}) or { return usage_error(err.msg()) }22	if args.flag('forget') {23		if !machine.has_remote() {24			return refuse(args.json(), 'this machine has no remote to forget')25		}26		machine.forget_remote() or { return refuse(args.json(), err.msg()) }27		if args.json() {28			emit_json(json2.encode(RemoteJson{29				configured: false30			}))31			return 032		}33		ok("forgot this machine's remote")34		return 035	}36	if args.positional.len == 0 {37		if !machine.has_remote() {38			if args.json() {39				emit_json(json2.encode(RemoteJson{40					configured: false41				}))42				return 043			}44			return setup_wizard()45		}46		settings := machine.load_remote() or {47			// Otherwise the message to run "koh remote" again would loop.48			if !args.json() && interactive() {49				warn(err.msg())50				return setup_wizard()51			}52			return refuse(args.json(), err.msg())53		}54		if args.json() {55			emit_json(json2.encode(RemoteJson{56				configured: true57				kind:       settings.kind58				target:     describe(settings)59			}))60			return 061		}62		ok('remote: ${describe(settings)}')63		if !interactive() {64			note('run "koh remote folder <path>" or "koh remote s3 <endpoint> <bucket> <region>" to replace it')65			return 066		}67		print('  replace it? [y/N] ')68		if os.get_line().trim_right('\r') != 'y' {69			return 070		}71		// The old remote stays until the new one passes its test.72		return setup_wizard()73	}74	// A non-interactive form, still tested before it is saved.75	return set_remote(args)76}7778fn set_remote(args Args) int {79	kind := args.positional_at(0)80	match kind {81		machine.kind_folder {82			path := args.positional_at(1)83			if path == '' {84				return usage_error('remote folder needs a path: koh remote folder /Volumes/usb/koh')85			}86			settings := machine.RemoteSettings{87				kind: machine.kind_folder88				path: os.real_path(path)89			}90			return save_after_check(settings, args, '', '')91		}92		machine.kind_s3 {93			endpoint := args.positional_at(1)94			bucket := args.positional_at(2)95			region := args.positional_at(3)96			if endpoint == '' || bucket == '' || region == '' {97				return usage_error('remote s3 needs an endpoint, bucket and region: koh remote s3 https://s3.fr-par.scw.cloud my-bucket fr-par')98			}99			access_key, secret_key := s3_keys() or { return refuse(args.json(), err.msg()) }100			settings := machine.RemoteSettings{101				kind:     machine.kind_s3102				endpoint: endpoint103				bucket:   bucket104				region:   region105			}106			return save_after_check(settings, args, access_key, secret_key)107		}108		else {109			return usage_error('remote takes "folder" or "s3", not "${kind}"')110		}111	}112}113114// save_after_check is the whole point: nothing is written to ~/.config/koh115// until the remote has passed the test.116fn save_after_check(settings machine.RemoteSettings, args Args, access_key string, secret_key string) int {117	mut backend := build_backend(settings, access_key, secret_key) or {118		return refuse(args.json(), err.msg())119	}120	remote.check(mut backend) or {121		return refuse(args.json(), 'the remote did not pass its test: ${err.msg()}')122	}123	machine.save_remote(settings) or { return refuse(args.json(), err.msg()) }124	if settings.kind == machine.kind_s3 {125		machine.save_credentials(machine.Credentials{126			access_key: access_key127			secret_key: secret_key128		}) or { return refuse(args.json(), err.msg()) }129	}130	if args.json() {131		emit_json(json2.encode(RemoteJson{132			configured: true133			kind:       settings.kind134			target:     describe(settings)135		}))136		return 0137	}138	ok('remote set to ${describe(settings)}')139	note('every repo on this machine now offers to it')140	return 0141}142143fn build_backend(settings machine.RemoteSettings, access_key string, secret_key string) !remote.Backend {144	if settings.kind == machine.kind_folder {145		return remote.new_folder(settings.path)!146	}147	return remote.new_s3(settings.endpoint, settings.bucket, settings.region, access_key,148		secret_key)!149}150151fn setup_wizard() int {152	if !interactive() {153		return fail('setting up a remote needs a terminal, or use: koh remote folder <path>, koh remote s3 <endpoint> <bucket> <region>')154	}155	println('  choose a remote:')156	println('    ${fmt.amber('1')} folder   a directory, on a disk or a NAS')157	println('    ${fmt.amber('2')} s3       an S3-compatible bucket')158	print('  which? [1] ')159	if os.get_line().trim_space() == '2' {160		return wizard_s3()161	}162	print('  folder path: ')163	path := os.get_line().trim_space()164	if path == '' {165		return fail('no path given — nothing was saved')166	}167	settings := machine.RemoteSettings{168		kind: machine.kind_folder169		path: os.real_path(path)170	}171	return save_after_check(settings, Args{}, '', '')172}173174fn wizard_s3() int {175	print('  endpoint [https://s3.fr-par.scw.cloud]: ')176	endpoint := or_default(os.get_line().trim_space(), 'https://s3.fr-par.scw.cloud')177	print('  bucket: ')178	bucket := os.get_line().trim_space()179	print('  region [fr-par]: ')180	region := or_default(os.get_line().trim_space(), 'fr-par')181	if bucket == '' {182		return fail('no bucket given — nothing was saved')183	}184	access_key, secret_key := s3_keys() or { return fail(err.msg()) }185	settings := machine.RemoteSettings{186		kind:     machine.kind_s3187		endpoint: endpoint188		bucket:   bucket189		region:   region190	}191	return save_after_check(settings, Args{}, access_key, secret_key)192}193194// s3_keys takes the keys from the environment when a script supplies them, and195// otherwise asks, so they never have to be typed into a shell history.196fn s3_keys() !(string, string) {197	access_key := os.getenv('KOH_ACCESS_KEY')198	secret_key := os.getenv('KOH_SECRET_KEY')199	if access_key != '' && secret_key != '' {200		return access_key, secret_key201	}202	if !interactive() {203		return error('S3 keys are missing — set KOH_ACCESS_KEY and KOH_SECRET_KEY, or run koh remote on a terminal')204	}205	print('  access key: ')206	access_key_typed := os.get_line().trim_space()207	// Hidden input needs a terminal on both ends; with output redirected, the208	// key is read like any other line.209	mut secret_key_typed := ''210	mut saved := termios.Termios{}211	if os.is_atty(1) != 0 && os.getenv('TERM') != 'dumb' && termios.tcgetattr(0, mut saved) == 0 {212		// V's hidden read returns on end of input without turning echo back213		// on, so the terminal's state is kept here and put back. A failed214		// read is an empty key, which is refused below.215		secret_key_typed = os.input_password('  secret key: ') or {216			println('')217			if termios.set_state(0, saved) != 0 {218				warn('the terminal may no longer echo — run "stty sane"')219			}220			''221		}222	} else {223		print('  secret key: ')224		secret_key_typed = os.get_line()225	}226	secret_key_typed = secret_key_typed.trim_space()227	if access_key_typed == '' || secret_key_typed == '' {228		return error('both keys are needed — nothing was saved')229	}230	return access_key_typed, secret_key_typed231}232233fn describe(settings machine.RemoteSettings) string {234	if settings.kind == machine.kind_folder {235		return 'folder ${settings.path}'236	}237	return 's3 ${settings.endpoint}/${settings.bucket} (${settings.region})'238}239240fn or_default(value string, fallback string) string {241	return if value == '' { fallback } else { value }242}243244struct RemoteJson {245	configured bool246	kind       string247	target     string248}