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
|
||||
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
|
||||
---@return nil
|
||||
function M.auth(args)
|
||||
|
|
@ -96,6 +139,10 @@ function M.auth(args)
|
|||
else
|
||||
log.info('s3: credentials valid')
|
||||
end
|
||||
local s3cfg = get_config()
|
||||
if not s3cfg or not s3cfg.bucket then
|
||||
create_bucket()
|
||||
end
|
||||
else
|
||||
local stderr = result.stderr or ''
|
||||
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 }]]
|
||||
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 fn fun(): nil
|
||||
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'))
|
||||
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()
|
||||
util.system = function(args)
|
||||
if vim.tbl_contains(args, 'get-caller-identity') then
|
||||
|
|
@ -133,6 +155,225 @@ describe('s3', function()
|
|||
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()
|
||||
it('uploads store to S3', function()
|
||||
local s = pending.store()
|
||||
|
|
|
|||
|
|
@ -80,7 +80,6 @@ describe('sync util', function()
|
|||
vim.g.pending = { data_path = tmpdir .. '/tasks.json' }
|
||||
config.reset()
|
||||
package.loaded['pending'] = nil
|
||||
local pending = require('pending')
|
||||
|
||||
local s = store_mod.new(tmpdir .. '/tasks.json')
|
||||
s:load()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue