feat(sync): s3 backend (#112)
* feat(s3): create bucket interactively during auth when unconfigured Problem: when a user runs `:Pending s3 auth` with no bucket configured, auth succeeds but offers no way to create the bucket. The user must manually run `aws s3api create-bucket` and update their config. Solution: add `util.input()` coroutine-aware prompt wrapper and a `create_bucket()` flow in `s3.lua` that prompts for bucket name and region, handles the `us-east-1` LocationConstraint quirk, and logs a config snippet on success. Called automatically from `auth()` when `sync.s3.bucket` is absent. * ci: typing
This commit is contained in:
parent
ee75e6844e
commit
7640241cf2
4 changed files with 303 additions and 1 deletions
|
|
@ -66,6 +66,49 @@ local function ensure_sync_id(task)
|
||||||
return sync_id
|
return sync_id
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function create_bucket()
|
||||||
|
local name = util.input({ prompt = 'S3 bucket name: ', default = 'pending.nvim' })
|
||||||
|
if not name or name == '' then
|
||||||
|
log.info('s3: bucket creation cancelled')
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local region_cmd = base_cmd()
|
||||||
|
vim.list_extend(region_cmd, { 'configure', 'get', 'region' })
|
||||||
|
local region_result = util.system(region_cmd, { text = true })
|
||||||
|
local default_region = 'us-east-1'
|
||||||
|
if region_result.code == 0 and region_result.stdout then
|
||||||
|
local detected = vim.trim(region_result.stdout)
|
||||||
|
if detected ~= '' then
|
||||||
|
default_region = detected
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local region = util.input({ prompt = 'AWS region: ', default = default_region })
|
||||||
|
if not region or region == '' then
|
||||||
|
region = 'us-east-1'
|
||||||
|
end
|
||||||
|
|
||||||
|
local cmd = base_cmd()
|
||||||
|
vim.list_extend(cmd, { 's3api', 'create-bucket', '--bucket', name, '--region', region })
|
||||||
|
if region ~= 'us-east-1' then
|
||||||
|
vim.list_extend(cmd, { '--create-bucket-configuration', 'LocationConstraint=' .. region })
|
||||||
|
end
|
||||||
|
|
||||||
|
local result = util.system(cmd, { text = true })
|
||||||
|
if result.code == 0 then
|
||||||
|
log.info(
|
||||||
|
's3: bucket created. Add to your config:\n sync = { s3 = { bucket = "'
|
||||||
|
.. name
|
||||||
|
.. '", region = "'
|
||||||
|
.. region
|
||||||
|
.. '" } }'
|
||||||
|
)
|
||||||
|
else
|
||||||
|
log.error('s3: bucket creation failed — ' .. (result.stderr or 'unknown error'))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
---@param args? string
|
---@param args? string
|
||||||
---@return nil
|
---@return nil
|
||||||
function M.auth(args)
|
function M.auth(args)
|
||||||
|
|
@ -96,6 +139,10 @@ function M.auth(args)
|
||||||
else
|
else
|
||||||
log.info('s3: credentials valid')
|
log.info('s3: credentials valid')
|
||||||
end
|
end
|
||||||
|
local s3cfg = get_config()
|
||||||
|
if not s3cfg or not s3cfg.bucket then
|
||||||
|
create_bucket()
|
||||||
|
end
|
||||||
else
|
else
|
||||||
local stderr = result.stderr or ''
|
local stderr = result.stderr or ''
|
||||||
if stderr:find('SSO') or stderr:find('sso') then
|
if stderr:find('SSO') or stderr:find('sso') then
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,21 @@ function M.system(args, opts)
|
||||||
return coroutine.yield() --[[@as { code: integer, stdout: string, stderr: string }]]
|
return coroutine.yield() --[[@as { code: integer, stdout: string, stderr: string }]]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
---@param opts? {prompt?: string, default?: string}
|
||||||
|
---@return string?
|
||||||
|
function M.input(opts)
|
||||||
|
local co = coroutine.running()
|
||||||
|
if not co then
|
||||||
|
error('util.input() must be called inside a coroutine')
|
||||||
|
end
|
||||||
|
vim.ui.input(opts or {}, function(result)
|
||||||
|
vim.schedule(function()
|
||||||
|
coroutine.resume(co, result)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
return coroutine.yield() --[[@as string?]]
|
||||||
|
end
|
||||||
|
|
||||||
---@param name string
|
---@param name string
|
||||||
---@param fn fun(): nil
|
---@param fn fun(): nil
|
||||||
function M.with_guard(name, fn)
|
function M.with_guard(name, fn)
|
||||||
|
|
|
||||||
241
spec/s3_spec.lua
241
spec/s3_spec.lua
|
|
@ -99,6 +99,28 @@ describe('s3', function()
|
||||||
assert.truthy(msg and msg:find('authenticated'))
|
assert.truthy(msg and msg:find('authenticated'))
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
it('skips bucket creation when bucket is configured', function()
|
||||||
|
util.system = function(args)
|
||||||
|
if vim.tbl_contains(args, 'get-caller-identity') then
|
||||||
|
return {
|
||||||
|
code = 0,
|
||||||
|
stdout = '{"Account":"123456","Arn":"arn:aws:iam::user/test"}',
|
||||||
|
stderr = '',
|
||||||
|
}
|
||||||
|
end
|
||||||
|
return { code = 0, stdout = '', stderr = '' }
|
||||||
|
end
|
||||||
|
local orig_input = util.input
|
||||||
|
local input_called = false
|
||||||
|
util.input = function()
|
||||||
|
input_called = true
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
s3.auth()
|
||||||
|
util.input = orig_input
|
||||||
|
assert.is_false(input_called)
|
||||||
|
end)
|
||||||
|
|
||||||
it('detects SSO expiry', function()
|
it('detects SSO expiry', function()
|
||||||
util.system = function(args)
|
util.system = function(args)
|
||||||
if vim.tbl_contains(args, 'get-caller-identity') then
|
if vim.tbl_contains(args, 'get-caller-identity') then
|
||||||
|
|
@ -133,6 +155,225 @@ describe('s3', function()
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
describe('auth bucket creation', function()
|
||||||
|
local orig_input
|
||||||
|
|
||||||
|
before_each(function()
|
||||||
|
vim.g.pending = { data_path = tmpdir .. '/tasks.json', sync = { s3 = {} } }
|
||||||
|
config.reset()
|
||||||
|
package.loaded['pending'] = nil
|
||||||
|
package.loaded['pending.sync.s3'] = nil
|
||||||
|
pending = require('pending')
|
||||||
|
s3 = require('pending.sync.s3')
|
||||||
|
orig_input = util.input
|
||||||
|
end)
|
||||||
|
|
||||||
|
after_each(function()
|
||||||
|
util.input = orig_input
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('prompts for bucket when none configured', function()
|
||||||
|
local input_calls = {}
|
||||||
|
util.input = function(opts)
|
||||||
|
table.insert(input_calls, opts)
|
||||||
|
if opts.prompt:find('bucket') then
|
||||||
|
return 'my-bucket'
|
||||||
|
end
|
||||||
|
return opts.default
|
||||||
|
end
|
||||||
|
local create_args
|
||||||
|
util.system = function(args)
|
||||||
|
if vim.tbl_contains(args, 'get-caller-identity') then
|
||||||
|
return {
|
||||||
|
code = 0,
|
||||||
|
stdout = '{"Account":"123","Arn":"arn:aws:iam::user/test"}',
|
||||||
|
stderr = '',
|
||||||
|
}
|
||||||
|
end
|
||||||
|
if vim.tbl_contains(args, 'configure') then
|
||||||
|
return { code = 0, stdout = 'us-west-2\n', stderr = '' }
|
||||||
|
end
|
||||||
|
if vim.tbl_contains(args, 'create-bucket') then
|
||||||
|
create_args = args
|
||||||
|
return { code = 0, stdout = '', stderr = '' }
|
||||||
|
end
|
||||||
|
return { code = 0, stdout = '', stderr = '' }
|
||||||
|
end
|
||||||
|
local msg
|
||||||
|
local orig_notify = vim.notify
|
||||||
|
vim.notify = function(m)
|
||||||
|
msg = m
|
||||||
|
end
|
||||||
|
s3.auth()
|
||||||
|
vim.notify = orig_notify
|
||||||
|
assert.equals(2, #input_calls)
|
||||||
|
assert.is_not_nil(create_args)
|
||||||
|
assert.truthy(vim.tbl_contains(create_args, 'my-bucket'))
|
||||||
|
assert.truthy(msg and msg:find('bucket created'))
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('cancels when user provides empty bucket name', function()
|
||||||
|
util.input = function(opts)
|
||||||
|
if opts.prompt:find('bucket') then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return opts.default
|
||||||
|
end
|
||||||
|
util.system = function(args)
|
||||||
|
if vim.tbl_contains(args, 'get-caller-identity') then
|
||||||
|
return {
|
||||||
|
code = 0,
|
||||||
|
stdout = '{"Account":"123","Arn":"arn:aws:iam::user/test"}',
|
||||||
|
stderr = '',
|
||||||
|
}
|
||||||
|
end
|
||||||
|
return { code = 0, stdout = '', stderr = '' }
|
||||||
|
end
|
||||||
|
local msg
|
||||||
|
local orig_notify = vim.notify
|
||||||
|
vim.notify = function(m)
|
||||||
|
msg = m
|
||||||
|
end
|
||||||
|
s3.auth()
|
||||||
|
vim.notify = orig_notify
|
||||||
|
assert.truthy(msg and msg:find('cancelled'))
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('omits LocationConstraint for us-east-1', function()
|
||||||
|
util.input = function(opts)
|
||||||
|
if opts.prompt:find('bucket') then
|
||||||
|
return 'my-bucket'
|
||||||
|
end
|
||||||
|
if opts.prompt:find('region') then
|
||||||
|
return 'us-east-1'
|
||||||
|
end
|
||||||
|
return opts.default
|
||||||
|
end
|
||||||
|
local create_args
|
||||||
|
util.system = function(args)
|
||||||
|
if vim.tbl_contains(args, 'get-caller-identity') then
|
||||||
|
return {
|
||||||
|
code = 0,
|
||||||
|
stdout = '{"Account":"123","Arn":"arn:aws:iam::user/test"}',
|
||||||
|
stderr = '',
|
||||||
|
}
|
||||||
|
end
|
||||||
|
if vim.tbl_contains(args, 'configure') then
|
||||||
|
return { code = 0, stdout = 'us-east-1\n', stderr = '' }
|
||||||
|
end
|
||||||
|
if vim.tbl_contains(args, 'create-bucket') then
|
||||||
|
create_args = args
|
||||||
|
return { code = 0, stdout = '', stderr = '' }
|
||||||
|
end
|
||||||
|
return { code = 0, stdout = '', stderr = '' }
|
||||||
|
end
|
||||||
|
s3.auth()
|
||||||
|
assert.is_not_nil(create_args)
|
||||||
|
local joined = table.concat(create_args, ' ')
|
||||||
|
assert.falsy(joined:find('LocationConstraint'))
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('includes LocationConstraint for non-us-east-1 regions', function()
|
||||||
|
util.input = function(opts)
|
||||||
|
if opts.prompt:find('bucket') then
|
||||||
|
return 'my-bucket'
|
||||||
|
end
|
||||||
|
if opts.prompt:find('region') then
|
||||||
|
return 'eu-west-1'
|
||||||
|
end
|
||||||
|
return opts.default
|
||||||
|
end
|
||||||
|
local create_args
|
||||||
|
util.system = function(args)
|
||||||
|
if vim.tbl_contains(args, 'get-caller-identity') then
|
||||||
|
return {
|
||||||
|
code = 0,
|
||||||
|
stdout = '{"Account":"123","Arn":"arn:aws:iam::user/test"}',
|
||||||
|
stderr = '',
|
||||||
|
}
|
||||||
|
end
|
||||||
|
if vim.tbl_contains(args, 'configure') then
|
||||||
|
return { code = 0, stdout = 'eu-west-1\n', stderr = '' }
|
||||||
|
end
|
||||||
|
if vim.tbl_contains(args, 'create-bucket') then
|
||||||
|
create_args = args
|
||||||
|
return { code = 0, stdout = '', stderr = '' }
|
||||||
|
end
|
||||||
|
return { code = 0, stdout = '', stderr = '' }
|
||||||
|
end
|
||||||
|
s3.auth()
|
||||||
|
assert.is_not_nil(create_args)
|
||||||
|
assert.truthy(vim.tbl_contains(create_args, 'LocationConstraint=eu-west-1'))
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('reports error on bucket creation failure', function()
|
||||||
|
util.input = function(opts)
|
||||||
|
if opts.prompt:find('bucket') then
|
||||||
|
return 'bad-bucket'
|
||||||
|
end
|
||||||
|
return opts.default
|
||||||
|
end
|
||||||
|
util.system = function(args)
|
||||||
|
if vim.tbl_contains(args, 'get-caller-identity') then
|
||||||
|
return {
|
||||||
|
code = 0,
|
||||||
|
stdout = '{"Account":"123","Arn":"arn:aws:iam::user/test"}',
|
||||||
|
stderr = '',
|
||||||
|
}
|
||||||
|
end
|
||||||
|
if vim.tbl_contains(args, 'configure') then
|
||||||
|
return { code = 0, stdout = 'us-east-1\n', stderr = '' }
|
||||||
|
end
|
||||||
|
if vim.tbl_contains(args, 'create-bucket') then
|
||||||
|
return { code = 1, stdout = '', stderr = 'BucketAlreadyExists' }
|
||||||
|
end
|
||||||
|
return { code = 0, stdout = '', stderr = '' }
|
||||||
|
end
|
||||||
|
local msg
|
||||||
|
local orig_notify = vim.notify
|
||||||
|
vim.notify = function(m, level)
|
||||||
|
if level == vim.log.levels.ERROR then
|
||||||
|
msg = m
|
||||||
|
end
|
||||||
|
end
|
||||||
|
s3.auth()
|
||||||
|
vim.notify = orig_notify
|
||||||
|
assert.truthy(msg and msg:find('bucket creation failed'))
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('defaults region to us-east-1 when aws configure returns nothing', function()
|
||||||
|
util.input = function(opts)
|
||||||
|
if opts.prompt:find('bucket') then
|
||||||
|
return 'my-bucket'
|
||||||
|
end
|
||||||
|
return ''
|
||||||
|
end
|
||||||
|
local create_args
|
||||||
|
util.system = function(args)
|
||||||
|
if vim.tbl_contains(args, 'get-caller-identity') then
|
||||||
|
return {
|
||||||
|
code = 0,
|
||||||
|
stdout = '{"Account":"123","Arn":"arn:aws:iam::user/test"}',
|
||||||
|
stderr = '',
|
||||||
|
}
|
||||||
|
end
|
||||||
|
if vim.tbl_contains(args, 'configure') then
|
||||||
|
return { code = 1, stdout = '', stderr = '' }
|
||||||
|
end
|
||||||
|
if vim.tbl_contains(args, 'create-bucket') then
|
||||||
|
create_args = args
|
||||||
|
return { code = 0, stdout = '', stderr = '' }
|
||||||
|
end
|
||||||
|
return { code = 0, stdout = '', stderr = '' }
|
||||||
|
end
|
||||||
|
s3.auth()
|
||||||
|
assert.is_not_nil(create_args)
|
||||||
|
assert.truthy(vim.tbl_contains(create_args, 'us-east-1'))
|
||||||
|
local joined = table.concat(create_args, ' ')
|
||||||
|
assert.falsy(joined:find('LocationConstraint'))
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
describe('push', function()
|
describe('push', function()
|
||||||
it('uploads store to S3', function()
|
it('uploads store to S3', function()
|
||||||
local s = pending.store()
|
local s = pending.store()
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,6 @@ describe('sync util', function()
|
||||||
vim.g.pending = { data_path = tmpdir .. '/tasks.json' }
|
vim.g.pending = { data_path = tmpdir .. '/tasks.json' }
|
||||||
config.reset()
|
config.reset()
|
||||||
package.loaded['pending'] = nil
|
package.loaded['pending'] = nil
|
||||||
local pending = require('pending')
|
|
||||||
|
|
||||||
local s = store_mod.new(tmpdir .. '/tasks.json')
|
local s = store_mod.new(tmpdir .. '/tasks.json')
|
||||||
s:load()
|
s:load()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue