//! `koh remote` — the machine's remote, set up once and used by every repo. //! //! Setup is interactive by default, because it ends with a live connection //! test: a remote is only saved once it has proved it can store an object and //! refuse to overwrite it. `folder` and `s3` forms are available for a machine //! being set up by a script, and an agent can read the current setting with //! `--json` (which never prints the secret key). module cli import json2 import os import src.fmt import src.machine import src.remote import term.termios pub fn cmd_remote(raw []string) int { args := parse('remote', raw, Options{ flags: ['json', 'forget'] }) or { return usage_error(err.msg()) } if args.flag('forget') { if !machine.has_remote() { return refuse(args.json(), 'this machine has no remote to forget') } machine.forget_remote() or { return refuse(args.json(), err.msg()) } if args.json() { emit_json(json2.encode(RemoteJson{ configured: false })) return 0 } ok("forgot this machine's remote") return 0 } if args.positional.len == 0 { if !machine.has_remote() { if args.json() { emit_json(json2.encode(RemoteJson{ configured: false })) return 0 } return setup_wizard() } settings := machine.load_remote() or { // Otherwise the message to run "koh remote" again would loop. if !args.json() && interactive() { warn(err.msg()) return setup_wizard() } return refuse(args.json(), err.msg()) } if args.json() { emit_json(json2.encode(RemoteJson{ configured: true kind: settings.kind target: describe(settings) })) return 0 } ok('remote: ${describe(settings)}') if !interactive() { note('run "koh remote folder " or "koh remote s3 " to replace it') return 0 } print(' replace it? [y/N] ') if os.get_line().trim_right('\r') != 'y' { return 0 } // The old remote stays until the new one passes its test. return setup_wizard() } // A non-interactive form, still tested before it is saved. return set_remote(args) } fn set_remote(args Args) int { kind := args.positional_at(0) match kind { machine.kind_folder { path := args.positional_at(1) if path == '' { return usage_error('remote folder needs a path: koh remote folder /Volumes/usb/koh') } settings := machine.RemoteSettings{ kind: machine.kind_folder path: os.real_path(path) } return save_after_check(settings, args, '', '') } machine.kind_s3 { endpoint := args.positional_at(1) bucket := args.positional_at(2) region := args.positional_at(3) if endpoint == '' || bucket == '' || region == '' { return usage_error('remote s3 needs an endpoint, bucket and region: koh remote s3 https://s3.fr-par.scw.cloud my-bucket fr-par') } access_key, secret_key := s3_keys() or { return refuse(args.json(), err.msg()) } settings := machine.RemoteSettings{ kind: machine.kind_s3 endpoint: endpoint bucket: bucket region: region } return save_after_check(settings, args, access_key, secret_key) } else { return usage_error('remote takes "folder" or "s3", not "${kind}"') } } } // save_after_check is the whole point: nothing is written to ~/.config/koh // until the remote has passed the test. fn save_after_check(settings machine.RemoteSettings, args Args, access_key string, secret_key string) int { mut backend := build_backend(settings, access_key, secret_key) or { return refuse(args.json(), err.msg()) } remote.check(mut backend) or { return refuse(args.json(), 'the remote did not pass its test: ${err.msg()}') } machine.save_remote(settings) or { return refuse(args.json(), err.msg()) } if settings.kind == machine.kind_s3 { machine.save_credentials(machine.Credentials{ access_key: access_key secret_key: secret_key }) or { return refuse(args.json(), err.msg()) } } if args.json() { emit_json(json2.encode(RemoteJson{ configured: true kind: settings.kind target: describe(settings) })) return 0 } ok('remote set to ${describe(settings)}') note('every repo on this machine now offers to it') return 0 } fn build_backend(settings machine.RemoteSettings, access_key string, secret_key string) !remote.Backend { if settings.kind == machine.kind_folder { return remote.new_folder(settings.path)! } return remote.new_s3(settings.endpoint, settings.bucket, settings.region, access_key, secret_key)! } fn setup_wizard() int { if !interactive() { return fail('setting up a remote needs a terminal, or use: koh remote folder , koh remote s3 ') } println(' choose a remote:') println(' ${fmt.amber('1')} folder a directory, on a disk or a NAS') println(' ${fmt.amber('2')} s3 an S3-compatible bucket') print(' which? [1] ') if os.get_line().trim_space() == '2' { return wizard_s3() } print(' folder path: ') path := os.get_line().trim_space() if path == '' { return fail('no path given — nothing was saved') } settings := machine.RemoteSettings{ kind: machine.kind_folder path: os.real_path(path) } return save_after_check(settings, Args{}, '', '') } fn wizard_s3() int { print(' endpoint [https://s3.fr-par.scw.cloud]: ') endpoint := or_default(os.get_line().trim_space(), 'https://s3.fr-par.scw.cloud') print(' bucket: ') bucket := os.get_line().trim_space() print(' region [fr-par]: ') region := or_default(os.get_line().trim_space(), 'fr-par') if bucket == '' { return fail('no bucket given — nothing was saved') } access_key, secret_key := s3_keys() or { return fail(err.msg()) } settings := machine.RemoteSettings{ kind: machine.kind_s3 endpoint: endpoint bucket: bucket region: region } return save_after_check(settings, Args{}, access_key, secret_key) } // s3_keys takes the keys from the environment when a script supplies them, and // otherwise asks, so they never have to be typed into a shell history. fn s3_keys() !(string, string) { access_key := os.getenv('KOH_ACCESS_KEY') secret_key := os.getenv('KOH_SECRET_KEY') if access_key != '' && secret_key != '' { return access_key, secret_key } if !interactive() { return error('S3 keys are missing — set KOH_ACCESS_KEY and KOH_SECRET_KEY, or run koh remote on a terminal') } print(' access key: ') access_key_typed := os.get_line().trim_space() // Hidden input needs a terminal on both ends; with output redirected, the // key is read like any other line. mut secret_key_typed := '' mut saved := termios.Termios{} if os.is_atty(1) != 0 && os.getenv('TERM') != 'dumb' && termios.tcgetattr(0, mut saved) == 0 { // V's hidden read returns on end of input without turning echo back // on, so the terminal's state is kept here and put back. A failed // read is an empty key, which is refused below. secret_key_typed = os.input_password(' secret key: ') or { println('') if termios.set_state(0, saved) != 0 { warn('the terminal may no longer echo — run "stty sane"') } '' } } else { print(' secret key: ') secret_key_typed = os.get_line() } secret_key_typed = secret_key_typed.trim_space() if access_key_typed == '' || secret_key_typed == '' { return error('both keys are needed — nothing was saved') } return access_key_typed, secret_key_typed } fn describe(settings machine.RemoteSettings) string { if settings.kind == machine.kind_folder { return 'folder ${settings.path}' } return 's3 ${settings.endpoint}/${settings.bucket} (${settings.region})' } fn or_default(value string, fallback string) string { return if value == '' { fallback } else { value } } struct RemoteJson { configured bool kind string target string }