Exporter des fichiers vers Amazon S3 en Lua avec LuaSocket
Pour exporter un fichier vers Amazon S3 depuis Lua, laissez libcurl signer le téléversement et vérifier HTTPS, puis téléchargez l’objet pour vérifier ses octets. Cet exemple appelle libcurl via la FFI de LuaJIT et utilise LuaSocket pour les délais entre les nouvelles tentatives. Il gère les fichiers binaires et vides, les informations d’accès AWS temporaires et les clés contenant des espaces, des caractères Unicode et des signes de pourcentage littéraux.
Installer et configurer
La configuration testée utilise Linux avec LuaJIT 2.1.1788460057,
LuaRocks 3.13.0, LuaSocket 3.1.0-1,
libcurl 8.22.0 et AWS CLI v2 2.37.10.
Ce sont les versions testées, pas des versions minimales intrinsèquement requises.
Utilisez une compilation actuelle de LuaJIT 2.1 et une
version corrigée de libcurl avec HTTPS et AWS SigV4 activés.
Les anciennes options de libcurl ne suffisent pas à établir la compatibilité avec son mécanisme
de signature S3.
Installez LuaJIT et ses en-têtes de développement, LuaRocks, un compilateur C, Make,
pkg-config, libcurl, un ensemble de certificats d’autorités de certification
système, AWS CLI v2, cmp
et mktemp via le gestionnaire de paquets de confiance de votre système.
Le paquet de développement LuaJIT doit fournir l’entrée pkg-config luajit.
Travaillez dans un répertoire vide en dehors de tout projet Lua existant et installez le
paquet rock LuaSocket dans sa propre arborescence :
(
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 l’installation échoue, corrigez le prérequis signalé et réexécutez ce bloc dans le même
répertoire ; il n’écrase pas vos fichiers sources Lua. Le script d’exécution ci-dessous charge
LuaSocket depuis ./rocks au lieu de s’appuyer sur les chemins de recherche
Lua d’un projet englobant. Définissez LIBCURL_PATH comme un chemin absolu de
confiance vers une bibliothèque partagée uniquement si libcurl se trouve en dehors du chemin de
recherche normal du chargeur. OpenResty et LuaSec ne sont pas nécessaires.
Le module établit une connexion HTTPS directe et désactive les proxys et les redirections ; votre
réseau doit autoriser l’accès direct à S3.
Configurer les informations d’accès AWS
Utilisez un bucket à usage général existant et des informations d’accès autorisées pour
s3:PutObject et s3:GetObject sur le préfixe choisi.
Le script d’exécution utilise la
résolution des informations d’accès par AWS CLI
pour transmettre les informations d’accès à Lua dans la mémoire du processus. Connectez-vous
d’abord en suivant la procédure AWS CLI existante de votre organisation, ou fournissez les
informations d’accès via le gestionnaire de secrets de votre déploiement. Les informations d’accès
temporaires nécessitent AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY
et AWS_SESSION_TOKEN ; celles de longue durée omettent le jeton. Ne collez pas
les informations d’accès dans le programme et n’activez pas le traçage du shell.
Définissez AWS_REGION et S3_BUCKET avec la région
et le nom réels du bucket. Le point de terminaison par défaut ci-dessous prend en charge les
buckets AWS classiques des régions commerciales dont les noms contiennent des lettres minuscules,
des chiffres et des traits d’union. Les autres partitions, les noms contenant des points, les
points d’accès et les buckets de répertoire nécessitent leur propre configuration vérifiée du
point de terminaison. Cet exemple ne configure pas IAM et ne crée pas de bucket.
Utiliser un transport de signature vérifié
CURLOPT_AWS_SIGV4 de libcurl signe la requête.
Le jeton de session est envoyé sous la forme x-amz-security-token et inclus dans
les en-têtes signés. Les clés sont encodées en octets UTF-8 tout en conservant les barres obliques
comme séparateurs. Le module rejette les composants de chemin isolés
. et .. pour éviter que la
normalisation de l’URL ne modifie la clé de l’objet. Le mécanisme de signature calcule le hachage
des octets exacts de POSTFIELDS, même si CUSTOMREQUEST
sélectionne la méthode HTTP PUT. Cela évite de maintenir une
implémentation cryptographique distincte dans le tutoriel.
Téléverser depuis LuaJIT
Enregistrez ce module sous le nom s3_upload.lua. Il limite délibérément chaque
objet à 16 MiB en mémoire ; les téléversements plus volumineux
nécessitent une implémentation en flux ou multipart vérifiée séparément. Chaque tentative est
soumise à une limite de 10 secondes pour la connexion et de 60 secondes pour le transfert complet.
Ainsi, même un fichier plus petit peut dépasser le délai sur une connexion lente.
endpoint et cafile sont des paramètres de
déploiement facultatifs et de confiance, utiles pour les tests avec un dispositif de test HTTPS.
Ne les acceptez jamais depuis une requête non authentifiée.
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
Enregistrez ce programme appelant sous le nom 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")
Enregistrez le script suivant sous le nom upload.sh. Exécutez-le depuis
le répertoire contenant les deux fichiers Lua. L’export des informations d’accès provient de
l’exécutable AWS CLI de confiance ; le script supprime tout jeton de session obsolète avant de
charger les informations d’accès résolues. Ses paramètres ne s’appliquent qu’au processus enfant.
#!/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 "$@"
Utilisez un fichier ordinaire existant de 16 MiB au maximum, ou créez un petit exemple binaire.
Ce bloc refuse de remplacer un report.bin existant :
(
set -eu
test ! -e report.bin
printf 'September report\000\377\n' > report.bin
)
Remplacez your-bucket-name par votre bucket. Définissez
AWS_PROFILE dans l’environnement si votre connexion AWS CLI utilise un
profil nommé. Gardez la clé de l’objet entre guillemets et transmettez-la telle qu’elle est écrite
à l’origine, sans l’encoder au préalable :
AWS_REGION=us-east-1 S3_BUCKET=your-bucket-name \
bash upload.sh report.bin 'reports/September café %.bin'
Le programme appelant affiche Uploaded
uniquement après que S3 a renvoyé le code HTTP 200.
Un téléversement vers une clé existante remplace son objet actuel ; un bucket avec gestion des
versions conserve une version supplémentaire. Utilisez une nouvelle clé pour votre premier test.
Vérifier les octets distants
Enregistrez cette vérification indépendante par téléchargement sous le nom
verify.sh. Elle utilise la même source d’informations d’accès AWS CLI,
télécharge la clé exacte, compare chaque octet avec le fichier local et supprime sa copie
temporaire en cas de réussite comme d’échec :
#!/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'
Le résultat attendu est Verified identical bytes. Un objet manquant, un téléchargement refusé ou des octets différents entraînent au contraire un code de sortie non nul. Gardez le fichier source inchangé entre le téléversement et la vérification. Une opération PutObject réussie stocke le corps complet de la requête, mais cette comparaison vérifie également que vous avez fourni le fichier et la clé voulus.
Gestion des erreurs et nouvelles tentatives
Seul le code HTTP 200 confirme cette
opération PutObject.
Les redirections, les échecs d’authentification et les autres erreurs HTTP permanentes entraînent
un arrêt immédiat. Certaines erreurs de transport transitoires, les limitations de débit et les
erreurs du service donnent lieu à un maximum de quatre tentatives au total par téléversement,
première requête comprise, chacune soumise aux limites de temps indiquées ci-dessus. Les échecs
de validation des certificats ne donnent lieu à aucune nouvelle tentative ; corrigez l’ensemble
de certificats d’autorités de certification ou le point de terminaison plutôt que de désactiver
la validation. Une entrée manquante ou un fichier trop volumineux provoque un échec avant tout
téléversement. Le délai utilise
socket.sleep() avec des fractions de seconde ; il ne construit jamais de
commande shell et ne formate jamais une fraction avec un espace réservé à un entier.
Chaque nouvelle tentative signe une nouvelle requête portant sur les mêmes octets et la même
clé. Une réponse perdue peut tout de même créer plusieurs versions dans un bucket avec gestion
des versions, ou répéter les notifications. Vérifiez et réconciliez l’état distant lorsque le
résultat est ambigu et que ces effets ont de l’importance. Cet exemple aux tentatives limitées
n’implémente pas de limitation de débit adaptative, de gestion de Retry-After,
de renouvellement automatique des informations d’accès ni de livraison exactement une fois.
Gestion du type de contenu
Le programme appelant utilise application/octet-stream. Pour stocker le type de contenu
d’un PDF, remplacez cette chaîne dans upload.lua par
application/pdf ; les arguments du fichier et de la clé restent les mêmes.
Cela définit les métadonnées de l’objet et ne valide pas le contenu du fichier. Le module rejette
les caractères de contrôle dans les valeurs des en-têtes et ne renvoie jamais le corps de la
réponse du service comme erreur.
Conclusion
Exécutez le téléversement et la comparaison des octets avec une nouvelle clé dans votre bucket de test autorisé avant d’intégrer le module. Pour les fichiers dépassant la limite de mémoire de cet exemple, choisissez un client en flux ou multipart et vérifiez séparément son comportement à l’achèvement du transfert ; augmenter la limite mettrait toujours le fichier entier en mémoire tampon dans Lua.
