Exportar archivos a Amazon S3 en Lua con LuaSocket
Para exportar un archivo a Amazon S3 desde Lua, deja que libcurl firme la subida y verifique HTTPS; luego descarga el objeto para comprobar sus bytes. Este ejemplo llama a libcurl mediante la FFI de LuaJIT y usa LuaSocket para las pausas entre reintentos. Admite archivos binarios y vacíos, credenciales temporales de AWS y claves con espacios, Unicode y signos de porcentaje literales.
Instalación y configuración
La configuración probada es Linux con LuaJIT 2.1.1788460057, LuaRocks 3.13.0, LuaSocket 3.1.0-1,
libcurl 8.22.0 y AWS CLI v2 2.37.10. Son versiones probadas, no mínimos intrínsecos.
Usa una compilación de LuaJIT 2.1 actual y una
versión de libcurl con los parches aplicados y con HTTPS y AWS SigV4
habilitados. La mera presencia de opciones antiguas de libcurl no demuestra la compatibilidad con
su componente de firma para S3.
Instala LuaJIT y sus archivos de cabecera de desarrollo, LuaRocks, un compilador de C, Make,
pkg-config, libcurl, un paquete de certificados CA del sistema,
AWS CLI v2,
cmp y mktemp mediante el gestor de paquetes de confianza de tu sistema.
El paquete de desarrollo de LuaJIT debe proporcionar la entrada luajit de pkg-config.
Trabaja en un directorio vacío fuera de un proyecto Lua existente e instala el
paquete rock de LuaSocket en su propio árbol:
(
set -eu
for tool in luajit luarocks cc make pkg-config aws cmp mktemp; do
command -v "$tool" >/dev/null || { printf 'Missing prerequisite: %s\n' "$tool" >&2; exit 1; }
done
case "$(aws --version)" in
aws-cli/2.*) ;;
*) printf 'AWS CLI v2 is required\n' >&2; exit 1 ;;
esac
lua_headers=$(pkg-config --variable=includedir luajit)
test -f "$lua_headers/lua.h"
luarocks --lua-version=5.1 --tree="$PWD/rocks" install luasocket 3.1.0-1 \
LUA_INCDIR="$lua_headers" LUA="$(command -v luajit)"
)
Si la instalación falla, resuelve el requisito previo indicado y repite este bloque en el mismo
directorio; no sobrescribe tus archivos de código fuente Lua. El ejecutor que aparece más abajo
carga LuaSocket desde ./rocks, en lugar de depender de las rutas de búsqueda de Lua de
un proyecto que lo contenga. Establece LIBCURL_PATH en una ruta absoluta de confianza a una
biblioteca compartida solo cuando libcurl esté fuera de la ruta de búsqueda normal del cargador.
OpenResty y LuaSec no son necesarios.
El módulo establece una conexión HTTPS directa y deshabilita los proxies y las redirecciones;
tu red debe permitir el acceso directo a S3.
Configuración de credenciales de AWS
Usa un bucket de propósito general existente y credenciales autorizadas para s3:PutObject y
s3:GetObject en el prefijo que elijas. El ejecutor usa la
resolución de credenciales de AWS CLI
para pasar las credenciales a Lua en la memoria del proceso. Primero inicia sesión mediante el
flujo de trabajo de AWS CLI existente en tu organización, o proporciona las credenciales mediante
el gestor de secretos de tu despliegue. Las credenciales temporales necesitan
AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY y AWS_SESSION_TOKEN; las de larga duración omiten el token.
No pegues credenciales en el programa ni habilites el rastreo del shell.
Establece AWS_REGION y S3_BUCKET en la región y el nombre reales del bucket.
El endpoint predeterminado que aparece más abajo admite buckets convencionales de las regiones
comerciales de AWS cuyos nombres contienen letras minúsculas, dígitos y guiones. Otras particiones,
los nombres con puntos, los puntos de acceso y los buckets de directorio necesitan su propia
configuración de endpoint verificada. Este ejemplo no configura IAM ni crea un bucket.
Uso de un transporte verificado para la firma
CURLOPT_AWS_SIGV4 de libcurl firma la solicitud.
El token de sesión se envía como x-amz-security-token y se incluye en las cabeceras firmadas.
Las claves se representan como bytes UTF-8, conservando las barras que actúan como separadores.
El módulo rechaza los componentes de ruta independientes . y ..
para evitar que la normalización de la URL cambie la clave del objeto. El componente de firma
calcula el hash de los bytes exactos de POSTFIELDS, aunque CUSTOMREQUEST selecciona
el método HTTP PUT. Así se evita mantener una implementación criptográfica
independiente en el tutorial.
Subida desde LuaJIT
Guarda este módulo como s3_upload.lua. Limita deliberadamente cada objeto a
16 MiB en memoria; las subidas más grandes necesitan una implementación de streaming
o de carga multiparte verificada por separado. Cada intento tiene un límite de 10 segundos para
la conexión y de 60 segundos para la transferencia completa, por lo que incluso un archivo más
pequeño puede agotar el tiempo de espera en una conexión lenta. endpoint y
cafile son opciones de despliegue de confianza opcionales, útiles para probar con
un recurso de prueba HTTPS. Nunca las aceptes de una solicitud no autenticada.
local ffi = require("ffi")
local socket = require("socket")
ffi.cdef[[
typedef void CURL;
struct curl_slist { char *data; struct curl_slist *next; };
int curl_global_init(long flags);
CURL *curl_easy_init(void);
void curl_easy_cleanup(CURL *handle);
int curl_easy_setopt(CURL *handle, int option, ...);
int curl_easy_perform(CURL *handle);
int curl_easy_getinfo(CURL *handle, int info, ...);
struct curl_slist *curl_slist_append(struct curl_slist *list, const char *value);
void curl_slist_free_all(struct curl_slist *list);
]]
local curl = ffi.load(os.getenv("LIBCURL_PATH") or "curl")
assert(curl.curl_global_init(3) == 0, "Cannot initialize libcurl")
-- Stable option identifiers from libcurl's public curl.h ABI.
local O = { URL=10002, PROXY=10004, WRITEFUNCTION=20011, POSTFIELDS=10015,
HTTPHEADER=10023, CUSTOMREQUEST=10036, SSL_VERIFYPEER=64, CAINFO=10065,
POSTFIELDSIZE=60, SSL_VERIFYHOST=81, CONNECTTIMEOUT=78, TIMEOUT=13,
USERNAME=10173, PASSWORD=10174, AWS_SIGV4=10305,
PROTOCOLS_STR=10318, FOLLOWLOCATION=52 }
local M = {}
function M.encode_key(key)
assert(type(key) == "string" and #key > 0 and #key <= 1024, "Invalid object key")
for part in key:gmatch("[^/]+") do
assert(part ~= "." and part ~= "..", "Dot path components are unsupported")
end
return (key:gsub("([^A-Za-z0-9/_.~-])", function(byte)
return string.format("%%%02X", byte:byte())
end))
end
local function header(value)
assert(type(value) == "string" and #value > 0 and not value:find("[%c]"), "Invalid header value")
return value
end
local function request(config, key, body, content_type)
local handle = curl.curl_easy_init()
assert(handle ~= nil, "Cannot allocate curl handle")
local headers = nil
local function append_header(value)
local updated = curl.curl_slist_append(headers, value)
assert(updated ~= nil, "Cannot allocate curl header")
headers = updated
end
local received = 0
local writer = ffi.cast("size_t (*)(char *, size_t, size_t, void *)", function(_, size, count)
local length = tonumber(size * count)
received = received + length
if received > 1024 * 1024 then return 0 end
return length
end)
local function option(name, value)
if type(value) == "number" then value = ffi.new("long", value) end
assert(curl.curl_easy_setopt(handle, O[name], value) == 0, "Unsupported curl option: " .. name)
end
local ok, result = pcall(function()
local endpoint = config.endpoint or ("https://" .. config.bucket .. ".s3." .. config.region .. ".amazonaws.com")
assert(endpoint:match("^https://[A-Za-z0-9.-]+:?%d*$"), "Expected a trusted HTTPS origin")
option("URL", endpoint .. "/" .. M.encode_key(key))
option("PROTOCOLS_STR", "https")
option("PROXY", "")
option("FOLLOWLOCATION", 0)
option("SSL_VERIFYPEER", 1)
option("SSL_VERIFYHOST", 2)
if config.cafile then option("CAINFO", config.cafile) end
option("CONNECTTIMEOUT", 10)
option("TIMEOUT", 60)
option("USERNAME", header(config.access_key))
option("PASSWORD", header(config.secret_key))
option("AWS_SIGV4", "aws:amz:" .. config.region .. ":s3")
option("CUSTOMREQUEST", "PUT")
option("POSTFIELDSIZE", #body)
option("POSTFIELDS", body)
option("WRITEFUNCTION", writer)
for _, value in ipairs({"Content-Type: " .. header(content_type), "Expect:"}) do
append_header(value)
end
if config.session_token then
append_header("x-amz-security-token: " .. header(config.session_token))
end
option("HTTPHEADER", headers)
local code = curl.curl_easy_perform(handle)
local status = ffi.new("long[1]")
assert(curl.curl_easy_getinfo(handle, 0x200002, status) == 0, "Cannot read HTTP status")
return { transport=tonumber(code), status=tonumber(status[0]) }
end)
curl.curl_easy_cleanup(handle)
curl.curl_slist_free_all(headers)
writer:free()
if not ok then error(result) end
return result
end
-- libcurl calls a Lua callback; this call path must stay outside compiled FFI traces.
jit.off(request, true)
function M.retryable(result)
if result.transport ~= 0 then
return ({[6]=true, [7]=true, [18]=true, [28]=true, [55]=true, [56]=true})[result.transport] == true
end
return ({[408]=true, [429]=true, [500]=true, [502]=true, [503]=true, [504]=true})[result.status] == true
end
function M.upload(config, file_path, key, content_type)
assert(config.region and config.region:match("^[a-z0-9-]+$"), "Invalid region")
assert(config.bucket and #config.bucket >= 3 and #config.bucket <= 63
and config.bucket:match("^[a-z0-9][a-z0-9-]*[a-z0-9]$"), "Invalid bucket name")
local file = assert(io.open(file_path, "rb"))
local body, read_error = file:read(16 * 1024 * 1024 + 1)
file:close()
if read_error then error(read_error) end
body = body or ""
assert(#body <= 16 * 1024 * 1024, "Use multipart upload for larger files")
for attempt = 1, 4 do
local result = request(config, key, body, content_type)
if result.transport == 0 and result.status == 200 then return true end
if attempt == 4 or not M.retryable(result) then
return nil, "Upload failed (transport " .. result.transport .. ", HTTP " .. result.status .. ")"
end
socket.sleep(math.min(8, 0.25 * 2^(attempt - 1)) * (0.5 + math.random()))
end
end
return M
Guarda este programa que llama al módulo como upload.lua:
local s3 = require("s3_upload")
local ok, err = s3.upload({
access_key = os.getenv("AWS_ACCESS_KEY_ID"),
secret_key = os.getenv("AWS_SECRET_ACCESS_KEY"),
session_token = os.getenv("AWS_SESSION_TOKEN"),
region = os.getenv("AWS_REGION"),
bucket = os.getenv("S3_BUCKET")
}, assert(arg[1], "local file required"), assert(arg[2], "object key required"), "application/octet-stream")
assert(ok, err)
print("Uploaded")
Guarda lo siguiente como upload.sh. Ejecútalo desde el directorio que contiene ambos
archivos Lua. Las credenciales exportadas proceden del ejecutable de confianza de AWS CLI; el
script elimina cualquier token de sesión obsoleto antes de cargar las credenciales resueltas.
Sus ajustes se aplican solo al proceso hijo.
#!/usr/bin/env bash
set +x
set -eu
for tool in aws luajit; do
command -v "$tool" >/dev/null || { printf 'Missing prerequisite: %s\n' "$tool" >&2; exit 1; }
done
: "${AWS_REGION:?Set AWS_REGION to the bucket region}"
: "${S3_BUCKET:?Set S3_BUCKET to the bucket name}"
credentials=$(aws configure export-credentials --format env)
unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN
eval "$credentials"
export LUA_PATH='./?.lua;./rocks/share/lua/5.1/?.lua;./rocks/share/lua/5.1/?/init.lua'
export LUA_CPATH='./rocks/lib/lua/5.1/?.so'
exec luajit upload.lua "$@"
Usa un archivo regular existente de hasta 16 MiB o crea un pequeño ejemplo binario. Este bloque
se niega a reemplazar un report.bin existente:
(
set -eu
test ! -e report.bin
printf 'September report\000\377\n' > report.bin
)
Reemplaza your-bucket-name por tu bucket. Establece AWS_PROFILE en el entorno si tu
inicio de sesión en AWS CLI usa un perfil con nombre. Mantén la clave del objeto entre comillas y
pásala tal como está escrita originalmente, sin codificarla de antemano:
AWS_REGION=us-east-1 S3_BUCKET=your-bucket-name \
bash upload.sh report.bin 'reports/September café %.bin'
El programa que llama al módulo imprime Uploaded solo después de que S3 devuelve HTTP 200.
Subir un archivo a una clave existente reemplaza su objeto actual; un bucket con control de
versiones conserva otra versión. Usa una clave nueva para tu primera prueba.
Comprobar los bytes remotos
Guarda esta comprobación de descarga independiente como verify.sh. Usa la misma fuente
de credenciales de AWS CLI, descarga la clave exacta, compara cada byte con el archivo local y
elimina su copia temporal tanto si tiene éxito como si falla:
#!/usr/bin/env bash
set -eu
: "${AWS_REGION:?Set AWS_REGION to the bucket region}"
: "${S3_BUCKET:?Set S3_BUCKET to the bucket name}"
test "$#" -eq 2
test -f "$1"
download=$(mktemp)
trap 'rm -f "$download"' EXIT
aws s3api get-object --bucket "$S3_BUCKET" --key "$2" --region "$AWS_REGION" \
"$download" >/dev/null
cmp -- "$1" "$download"
printf 'Verified identical bytes\n'
AWS_REGION=us-east-1 S3_BUCKET=your-bucket-name \
bash verify.sh report.bin 'reports/September café %.bin'
El resultado esperado es Verified identical bytes. Si el objeto no existe, se deniega la descarga o los bytes son distintos, el proceso termina con un código de salida distinto de cero. Mantén el archivo de origen sin cambios entre la subida y la verificación. Una operación PutObject exitosa almacena el cuerpo completo de la solicitud, pero esta comparación también comprueba que proporcionaste el archivo y la clave previstos.
Gestión de errores y reintentos
Solo HTTP 200 confirma esta
operación PutObject.
Las redirecciones, los fallos de autenticación y otros errores HTTP permanentes detienen la
operación de inmediato. Ante determinados errores transitorios de transporte, limitaciones de
solicitudes y errores del servicio, se realizan como máximo cuatro intentos, cada uno con los
límites de tiempo indicados arriba. Los fallos de validación de certificados no se reintentan;
repara el paquete de certificados CA o el endpoint en lugar de deshabilitar la validación.
Si falta el archivo de entrada o este es demasiado grande, la operación falla antes de cualquier
subida. La pausa usa
socket.sleep() con fracciones de segundo; nunca construye un comando de shell ni formatea una
fracción con un marcador de posición entero.
Cada reintento firma una nueva solicitud con los mismos bytes y la misma clave. La pérdida de una
respuesta aun así puede crear varias versiones en un bucket con control de versiones o repetir
notificaciones. Reconcilia los resultados ambiguos cuando esos efectos sean relevantes. Este
ejemplo con límites definidos no implementa la limitación adaptativa de solicitudes, el manejo de
Retry-After, la renovación automática de credenciales ni la entrega exactamente una vez.
Gestión del tipo de contenido
El programa que llama al módulo usa application/octet-stream. Para almacenar el tipo de contenido de
un PDF, reemplaza esa cadena en upload.lua por application/pdf; los argumentos del
archivo y de la clave se mantienen iguales. Esto establece los metadatos del objeto y no valida
el contenido del archivo. El módulo rechaza los caracteres de control en los valores de las
cabeceras y nunca devuelve el cuerpo de la respuesta del servicio como error.
Conclusión
Ejecuta la subida y la comparación de bytes con una clave nueva en tu bucket de pruebas autorizado antes de integrar el módulo. Para archivos que superen el límite de memoria de este ejemplo, elige un cliente de streaming o de carga multiparte y verifica por separado su comportamiento al completar la operación; aumentar el límite seguiría almacenando en memoria todo el archivo en Lua.
