Stream object transfers with shared byte throttling

This commit is contained in:
ms2k 2026-07-18 20:48:26 +09:00
commit 4b044b1456
11 changed files with 1228 additions and 105 deletions

View file

@ -1,4 +1,4 @@
.PHONY: test check-dependencies check-fixture-lib check-kind-matrix check-storage-foundation check-auth check-app app-up app-down check-frontend build-frontend .PHONY: test check-dependencies check-fixture-lib check-kind-matrix check-storage-foundation check-auth check-app check-streaming app-up app-down check-frontend build-frontend
test: test:
cabal test --offline nuthatch-unit cabal test --offline nuthatch-unit
@ -21,6 +21,9 @@ check-auth:
check-app: check-app:
./tests/kind/wp05-run.sh ./tests/kind/wp05-run.sh
check-streaming:
./tests/kind/wp07-run.sh
app-up: app-up:
NUTHATCH_APP_INTERACTIVE=1 ./tests/kind/wp05-run.sh NUTHATCH_APP_INTERACTIVE=1 ./tests/kind/wp05-run.sh

View file

@ -107,6 +107,7 @@ library
Nuthatch.Redis Nuthatch.Redis
Nuthatch.S3 Nuthatch.S3
Nuthatch.Session Nuthatch.Session
Nuthatch.Streaming
Nuthatch.Types Nuthatch.Types
Nuthatch.Ui Nuthatch.Ui

View file

@ -431,6 +431,7 @@ type DownloadSuccess =
Headers Headers
'[ Header "Content-Disposition" Text '[ Header "Content-Disposition" Text
, Header "X-Content-Type-Options" Text , Header "X-Content-Type-Options" Text
, Header "Content-Length" Integer
] ]
(SourceIO BS.ByteString) (SourceIO BS.ByteString)

View file

@ -9,15 +9,11 @@ module Nuthatch.App
) where ) where
import Control.Concurrent (threadDelay) import Control.Concurrent (threadDelay)
import Control.Exception (SomeException, try) import Control.Exception (SomeException, throwIO, try)
import Control.Monad (forever, unless, void, when) import Control.Monad (forever, void, when)
import Control.Monad.IO.Class (liftIO) import Control.Monad.IO.Class (liftIO)
import Control.Monad.Trans.Except (runExceptT)
import Crypto.Hash (Digest, SHA256, hashlazy)
import qualified Data.Aeson as A import qualified Data.Aeson as A
import qualified Data.ByteArray as BA
import qualified Data.ByteString as BS import qualified Data.ByteString as BS
import qualified Data.ByteString.Base16 as B16
import qualified Data.ByteString.Lazy as BL import qualified Data.ByteString.Lazy as BL
import Data.Either (isRight) import Data.Either (isRight)
import Data.Maybe (fromMaybe, listToMaybe) import Data.Maybe (fromMaybe, listToMaybe)
@ -43,19 +39,33 @@ import qualified Nuthatch.Db as Db
import Nuthatch.Oidc import Nuthatch.Oidc
import Nuthatch.Paths import Nuthatch.Paths
import Nuthatch.Protocol import Nuthatch.Protocol
import Nuthatch.Redis (Redis, cacheGet, cacheInvalidatePrefixes, cacheSet, throttleTake) import Nuthatch.Redis
( Redis
, cacheGet
, cacheInvalidatePrefixes
, cacheSet
, throttleCheck
, throttleTake
)
import Nuthatch.S3 import Nuthatch.S3
( ListPage (..) ( ListPage (..)
, ObjectInfo (..) , ObjectInfo (..)
, PutObjectError (..)
, S3 , S3
, S3Error (..)
, deleteObject , deleteObject
, getObjectBytes , getObjectSource
, headObject , headObject
, listObjects , listObjects
, putObject , observeObjectFully
, putObjectStream
, renderS3Error , renderS3Error
) )
import Nuthatch.Session import Nuthatch.Session
import Nuthatch.Streaming
( BandwidthError (..)
, UploadStreamError (..)
)
import Nuthatch.Types import Nuthatch.Types
import Nuthatch.Ui (browsePage, browserScript) import Nuthatch.Ui (browsePage, browserScript)
import Servant import Servant
@ -63,7 +73,7 @@ import Servant.Server.Experimental.Auth
( AuthHandler ( AuthHandler
, mkAuthHandler , mkAuthHandler
) )
import Servant.Types.SourceT (runSourceT, source) import Servant.Types.SourceT (SourceT)
import Web.Cookie import Web.Cookie
( defaultSetCookie ( defaultSetCookie
, parseCookies , parseCookies
@ -306,17 +316,9 @@ handleDownloadRequest env session root path = do
(DownloadObjectStoreFailed (renderS3Error message)) (DownloadObjectStoreFailed (renderS3Error message))
Right Nothing -> throwOperationError err404 DownloadNotFound Right Nothing -> throwOperationError err404 DownloadNotFound
Right (Just value) -> pure value Right (Just value) -> pure value
throttle <- liftIO $ requireDownloadBandwidth env user (oiSize info)
throttleTake (envRedis env) (envConfig env) (suUserId user) (oiSize info) let inspect = inspectBandwidth env user
case throttle of body = getObjectSource (envS3 env) key (transferPieceSize env) inspect
Left _ -> throwOperationError err503 DownloadThrottleUnavailable
Right False -> throwOperationError err429 DownloadBandwidthExceeded
Right True -> pure ()
result <- liftIO (getObjectBytes (envS3 env) key)
body <- either
(throwOperationError status502Error . DownloadObjectStoreFailed . renderS3Error)
pure
result
liftIO $ audit "download" "ok" (Just (unUserId (suUserId user))) liftIO $ audit "download" "ok" (Just (unUserId (suUserId user)))
[ ("root", A.toJSON (rootLabel root)) [ ("root", A.toJSON (rootLabel root))
, ("bytes", A.toJSON (oiSize info)) , ("bytes", A.toJSON (oiSize info))
@ -324,7 +326,8 @@ handleDownloadRequest env session root path = do
let filename = sanitizeFilename (basename path) let filename = sanitizeFilename (basename path)
pure $ pure $
addHeader ("attachment; filename=\"" <> filename <> "\"") $ addHeader ("attachment; filename=\"" <> filename <> "\"") $
addHeader "nosniff" (source (BL.toChunks body)) addHeader "nosniff" $
addHeader (oiSize info) body
handleUploadRequest handleUploadRequest
:: Env :: Env
@ -339,31 +342,16 @@ handleUploadRequest
-> Handler UploadResponse -> Handler UploadResponse
handleUploadRequest env session root path operation declaredSize contentIdentity providedCsrf bodySource = do handleUploadRequest env session root path operation declaredSize contentIdentity providedCsrf bodySource = do
requireCsrf UploadCsrfRejected session providedCsrf requireCsrf UploadCsrfRejected session providedCsrf
chunksResult <- liftIO (runExceptT (runSourceT bodySource))
chunks <- either
(throwOperationError err400 . UploadInvalidBodyStream . T.pack)
pure
chunksResult
let user = sessionUser session let user = sessionUser session
body = BL.fromChunks chunks
size = unDeclaredSize declaredSize size = unDeclaredSize declaredSize
actualSize = toInteger (BL.length body)
configuredMaximum = cfgMaxObjectBytes (envConfig env) configuredMaximum = cfgMaxObjectBytes (envConfig env)
unless (actualSize == size) $
throwOperationError err400 UploadSizeMismatch
when (size > configuredMaximum) $ when (size > configuredMaximum) $
throwOperationError err413 UploadTooLarge throwOperationError err413 UploadTooLarge
unless (contentSha256 body == contentIdentity) $ requireUploadBandwidth env user size
throwOperationError err400 UploadContentIdentityMismatch outcome <- liftIO $
throttle <- liftIO $ runPut env user root path operation size contentIdentity bodySource
throttleTake (envRedis env) (envConfig env) (suUserId user) size
case throttle of
Left _ -> throwOperationError err503 UploadThrottleUnavailable
Right False -> throwOperationError err429 UploadBandwidthExceeded
Right True -> pure ()
outcome <- liftIO (runPut env user root path operation size contentIdentity body)
result <- either result <- either
(throwOperationError err409 . UploadMutationRejected) throwPutFailure
pure pure
outcome outcome
liftIO $ audit "put" (mutationAuditResult result) (Just (unUserId (suUserId user))) liftIO $ audit "put" (mutationAuditResult result) (Just (unUserId (suUserId user)))
@ -376,6 +364,48 @@ handleUploadRequest env session root path operation declaredSize contentIdentity
, uploadResponseResult = result , uploadResponseResult = result
} }
requireDownloadBandwidth :: Env -> SessionUser -> Integer -> Handler ()
requireDownloadBandwidth env user size = do
available <- liftIO $
throttleCheck
(envRedis env)
(envConfig env)
(suUserId user)
(min size (toInteger (transferPieceSize env)))
case available of
Left _ -> throwOperationError err503 DownloadThrottleUnavailable
Right False -> throwOperationError err429 DownloadBandwidthExceeded
Right True -> pure ()
requireUploadBandwidth :: Env -> SessionUser -> Integer -> Handler ()
requireUploadBandwidth env user size = do
available <- liftIO $
throttleCheck
(envRedis env)
(envConfig env)
(suUserId user)
(min size (toInteger (transferPieceSize env)))
case available of
Left _ -> throwOperationError err503 UploadThrottleUnavailable
Right False -> throwOperationError err429 UploadBandwidthExceeded
Right True -> pure ()
inspectBandwidth :: Env -> SessionUser -> BS.ByteString -> IO ()
inspectBandwidth env user chunk = do
result <- throttleTake
(envRedis env)
(envConfig env)
(suUserId user)
(toInteger (BS.length chunk))
case result of
Left _ -> throwIO BandwidthUnavailable
Right False -> throwIO BandwidthExceeded
Right True -> pure ()
transferPieceSize :: Env -> Int
transferPieceSize env =
fromInteger (max 1 (min 65536 (cfgThrottleBurstBytes (envConfig env))))
handleDeleteRequest handleDeleteRequest
:: Env :: Env
-> SessionContext -> SessionContext
@ -495,8 +525,8 @@ runPut
-> OperationId -> OperationId
-> Integer -> Integer
-> ContentSha256 -> ContentSha256
-> BL.ByteString -> SourceT IO BS.ByteString
-> IO (Either Text MutationResult) -> IO (Either PutFailure MutationResult)
runPut env user root rel op size sha body = do runPut env user root rel op size sha body = do
ensurePersonalPool (envDb env) (envConfig env) (suUserId user) ensurePersonalPool (envDb env) (envConfig env) (suUserId user)
let uid = suUserId user let uid = suUserId user
@ -509,12 +539,57 @@ runPut env user root rel op size sha body = do
(cfgMaxObjectBytes (envConfig env)) (cfgMaxObjectBytes (envConfig env))
case outcome of case outcome of
AdmitConflict -> pure (Right MutationConflict) AdmitConflict -> pure (Right MutationConflict)
AdmitRejected reason _ -> pure (Left reason) AdmitRejected reason _ -> pure (Left (PutRejected reason))
AdmitReplay MutationPending _ -> pure (Right MutationPending) AdmitReplay MutationPending _ -> pure (Right MutationPending)
AdmitReplay result _ -> pure (Right result) AdmitReplay result _ -> pure (Right result)
AdmitPrepared rec -> dispatchAndFinalize env user root rec $ do AdmitPrepared rec ->
_ <- putObject (envS3 env) key size (Just sha) body dispatchAndFinalize
pure () env
user
root
rec
PutSettlementFailed
(putObjectStream
(envS3 env)
key
size
(Just sha)
(transferPieceSize env)
(inspectBandwidth env user)
body
>>= pure . either (Left . PutDispatchFailed) Right)
data PutFailure
= PutRejected Text
| PutDispatchFailed PutObjectError
| PutSettlementFailed
deriving stock (Eq, Show)
throwPutFailure :: PutFailure -> Handler a
throwPutFailure failure =
case failure of
PutRejected reason ->
throwOperationError err409 (UploadMutationRejected reason)
PutDispatchFailed (PutObjectStreamError streamError) ->
case streamError of
UploadStreamInvalid message ->
throwOperationError err400 (UploadInvalidBodyStream (T.pack message))
UploadStreamSizeMismatch ->
throwOperationError err400 UploadSizeMismatch
UploadStreamIdentityMismatch ->
throwOperationError err400 UploadContentIdentityMismatch
PutDispatchFailed (PutObjectBandwidthError bandwidthError) ->
case bandwidthError of
BandwidthUnavailable ->
throwOperationError err503 UploadThrottleUnavailable
BandwidthExceeded ->
throwOperationError err429 UploadBandwidthExceeded
PutDispatchFailed (PutObjectStoreError err) ->
throwOperationError err409
(UploadMutationRejected (renderS3Error err))
PutSettlementFailed ->
throwOperationError err409
(UploadMutationRejected "mutation failed after store settlement")
runDelete runDelete
:: Env :: Env
@ -538,9 +613,15 @@ runDelete env user root rel op = do
AdmitRejected reason _ -> pure (Left reason) AdmitRejected reason _ -> pure (Left reason)
AdmitReplay MutationPending _ -> pure (Right MutationPending) AdmitReplay MutationPending _ -> pure (Right MutationPending)
AdmitReplay result _ -> pure (Right result) AdmitReplay result _ -> pure (Right result)
AdmitPrepared rec -> dispatchAndFinalize env user root rec $ do AdmitPrepared rec ->
_ <- deleteObject (envS3 env) key dispatchAndFinalize
pure () env
user
root
rec
"mutation failed after store settlement"
(deleteObject (envS3 env) key
>>= pure . either (Left . renderS3Error) Right)
deletePrefix :: Env -> SessionUser -> Root -> Text -> IO (Either Text Int) deletePrefix :: Env -> SessionUser -> Root -> Text -> IO (Either Text Int)
deletePrefix env user root prefix = do deletePrefix env user root prefix = do
@ -596,9 +677,10 @@ dispatchAndFinalize
-> SessionUser -> SessionUser
-> Root -> Root
-> MutationRecord -> MutationRecord
-> IO () -> failure
-> IO (Either Text MutationResult) -> IO (Either failure ())
dispatchAndFinalize env user root rec dispatch = do -> IO (Either failure MutationResult)
dispatchAndFinalize env user root rec settlementFailure dispatch = do
prepared <- withTransaction (envDb env) $ \conn -> do prepared <- withTransaction (envDb env) $ \conn -> do
moved <- markApplying conn rec moved <- markApplying conn rec
if not moved if not moved
@ -607,7 +689,7 @@ dispatchAndFinalize env user root rec dispatch = do
if not prepared if not prepared
then pure (Right MutationPending) then pure (Right MutationPending)
else do else do
_ <- try dispatch :: IO (Either SomeException ()) dispatchResult <- dispatch
observed <- observeObject (envS3 env) (mrObjectKey rec) observed <- observeObject (envS3 env) (mrObjectKey rec)
finalized <- withTransaction (envDb env) $ \conn -> do finalized <- withTransaction (envDb env) $ \conn -> do
settled <- markExternalSettled conn rec settled <- markExternalSettled conn rec
@ -620,23 +702,17 @@ dispatchAndFinalize env user root rec dispatch = do
(listCacheAncestorPrefixes (mrRelativePath rec)) (listCacheAncestorPrefixes (mrRelativePath rec))
pure (Right MutationSucceeded) pure (Right MutationSucceeded)
Just MutationFailed -> Just MutationFailed ->
pure (Left "mutation failed after store settlement") pure (Left (either id (const settlementFailure) dispatchResult))
Just other -> pure (Right other) Just other -> pure (Right other)
Nothing -> pure (Right MutationPending) Nothing -> pure (Right MutationPending)
observeObject :: S3 -> ObjectKey -> IO ObjectObservation observeObject :: S3 -> ObjectKey -> IO ObjectObservation
observeObject s3 key = do observeObject s3 key = do
result <- headObject s3 key result <- observeObjectFully s3 key
case result of case result of
Right (Just info@ObjectInfo { oiSha256 = Just _ }) -> Right info ->
pure (ObjectPresent (oiSize info) (oiSha256 info)) pure (ObjectPresent (oiSize info) (oiSha256 info))
Right (Just _) -> do Left S3NotFound -> pure ObjectAbsent
body <- getObjectBytes s3 key
pure $ case body of
Right bytes ->
ObjectPresent (toInteger (BL.length bytes)) (Just (contentSha256 bytes))
Left err -> ObjectUnavailable (renderS3Error err)
Right Nothing -> pure ObjectAbsent
Left err -> pure (ObjectUnavailable (renderS3Error err)) Left err -> pure (ObjectUnavailable (renderS3Error err))
listEntries :: Env -> SessionUser -> Root -> Text -> IO (Either Text [ListEntry]) listEntries :: Env -> SessionUser -> Root -> Text -> IO (Either Text [ListEntry])
@ -781,10 +857,6 @@ recoverOne env rec =
pure () pure ()
_ -> pure () _ -> pure ()
contentSha256 :: BL.ByteString -> ContentSha256
contentSha256 body =
ContentSha256 . TE.decodeUtf8 . B16.encode . BA.convert $
(hashlazy body :: Digest SHA256)
lastSeg :: Text -> Text lastSeg :: Text -> Text
lastSeg t = lastSeg t =

View file

@ -10,6 +10,7 @@ import Data.Maybe (fromMaybe)
import Data.Text (Text) import Data.Text (Text)
import qualified Data.Text as T import qualified Data.Text as T
import qualified Data.Text.Encoding as TE import qualified Data.Text.Encoding as TE
import Nuthatch.Streaming (maximumMultipartBytes)
import System.Environment (lookupEnv) import System.Environment (lookupEnv)
import Text.Read (readMaybe) import Text.Read (readMaybe)
@ -102,6 +103,12 @@ loadConfig = do
maxObject <- envInteger "NUTHATCH_MAX_OBJECT_BYTES" (100 * 1024 * 1024) maxObject <- envInteger "NUTHATCH_MAX_OBJECT_BYTES" (100 * 1024 * 1024)
throttleRate <- envInteger "NUTHATCH_THROTTLE_BYTES_PER_SEC" (10 * 1024 * 1024) throttleRate <- envInteger "NUTHATCH_THROTTLE_BYTES_PER_SEC" (10 * 1024 * 1024)
throttleBurst <- envInteger "NUTHATCH_THROTTLE_BURST_BYTES" (20 * 1024 * 1024) throttleBurst <- envInteger "NUTHATCH_THROTTLE_BURST_BYTES" (20 * 1024 * 1024)
when (maxObject <= 0 || maxObject > maximumMultipartBytes) $
fail "NUTHATCH_MAX_OBJECT_BYTES exceeds the supported streaming range"
when (throttleRate <= 0 || throttleRate > luaExactIntegerMax) $
fail "NUTHATCH_THROTTLE_BYTES_PER_SEC exceeds the exact throttle range"
when (throttleBurst <= 0 || throttleBurst > luaExactIntegerMax) $
fail "NUTHATCH_THROTTLE_BURST_BYTES exceeds the exact throttle range"
listTtl <- envInteger "NUTHATCH_LIST_CACHE_TTL_SEC" 30 listTtl <- envInteger "NUTHATCH_LIST_CACHE_TTL_SEC" 30
searchMax <- fromInteger <$> envInteger "NUTHATCH_SEARCH_MAX_KEYS" 500 searchMax <- fromInteger <$> envInteger "NUTHATCH_SEARCH_MAX_KEYS" 500
recoveryGrace <- envInteger "NUTHATCH_RECOVERY_GRACE_SEC" 90 recoveryGrace <- envInteger "NUTHATCH_RECOVERY_GRACE_SEC" 90
@ -143,6 +150,9 @@ loadConfig = do
, cfgRecoveryGraceSec = recoveryGrace , cfgRecoveryGraceSec = recoveryGrace
} }
luaExactIntegerMax :: Integer
luaExactIntegerMax = 9007199254740991
splitHostPort :: String -> (String, String) splitHostPort :: String -> (String, String)
splitHostPort s = splitHostPort s =
case break (== ':') s of case break (== ':') s of

View file

@ -4,6 +4,7 @@ module Nuthatch.Redis
, cacheGet , cacheGet
, cacheSet , cacheSet
, cacheInvalidatePrefixes , cacheInvalidatePrefixes
, throttleCheck
, throttleTake , throttleTake
) where ) where
@ -13,7 +14,6 @@ import qualified Data.ByteString.Char8 as BS8
import Data.Text (Text) import Data.Text (Text)
import qualified Data.Text as T import qualified Data.Text as T
import qualified Data.Text.Encoding as TE import qualified Data.Text.Encoding as TE
import Data.Time.Clock.POSIX (getPOSIXTime)
import Database.Redis import Database.Redis
( ConnectInfo (..) ( ConnectInfo (..)
, Connection , Connection
@ -69,9 +69,21 @@ cacheInvalidatePrefixes conn uid root prefixes = do
:: IO (Either SomeException (Either Reply Integer)) :: IO (Either SomeException (Either Reply Integer))
pure () pure ()
throttleCheck :: Redis -> Config -> UserId -> Integer -> IO (Either Text Bool)
throttleCheck conn cfg uid bytes =
throttleRequest conn cfg uid bytes False
throttleTake :: Redis -> Config -> UserId -> Integer -> IO (Either Text Bool) throttleTake :: Redis -> Config -> UserId -> Integer -> IO (Either Text Bool)
throttleTake conn cfg uid bytes = do throttleTake conn cfg uid bytes = throttleRequest conn cfg uid bytes True
now <- floor <$> getPOSIXTime
throttleRequest
:: Redis
-> Config
-> UserId
-> Integer
-> Bool
-> IO (Either Text Bool)
throttleRequest conn cfg uid bytes consume = do
let key = TE.encodeUtf8 ("nuthatch:throttle:" <> unUserId uid) let key = TE.encodeUtf8 ("nuthatch:throttle:" <> unUserId uid)
rate = cfgThrottleBytesPerSec cfg rate = cfgThrottleBytesPerSec cfg
burst = cfgThrottleBurstBytes cfg burst = cfgThrottleBurstBytes cfg
@ -80,8 +92,11 @@ throttleTake conn cfg uid bytes = do
[ "local key = KEYS[1]" [ "local key = KEYS[1]"
, "local rate = tonumber(ARGV[1])" , "local rate = tonumber(ARGV[1])"
, "local burst = tonumber(ARGV[2])" , "local burst = tonumber(ARGV[2])"
, "local now = tonumber(ARGV[3])" , "local need = tonumber(ARGV[3])"
, "local need = tonumber(ARGV[4])" , "local consume = tonumber(ARGV[4])"
, "local clock = redis.call('TIME')"
, "local now = tonumber(clock[1]) + tonumber(clock[2]) / 1000000"
, "local ttl = math.max(1, math.ceil(burst / rate))"
, "local data = redis.call('HMGET', key, 'tokens', 'ts')" , "local data = redis.call('HMGET', key, 'tokens', 'ts')"
, "local tokens = tonumber(data[1])" , "local tokens = tonumber(data[1])"
, "local ts = tonumber(data[2])" , "local ts = tonumber(data[2])"
@ -91,20 +106,20 @@ throttleTake conn cfg uid bytes = do
, "tokens = math.min(burst, tokens + delta * rate)" , "tokens = math.min(burst, tokens + delta * rate)"
, "if tokens < need then" , "if tokens < need then"
, " redis.call('HMSET', key, 'tokens', tokens, 'ts', now)" , " redis.call('HMSET', key, 'tokens', tokens, 'ts', now)"
, " redis.call('EXPIRE', key, 3600)" , " redis.call('EXPIRE', key, ttl)"
, " return 0" , " return 0"
, "end" , "end"
, "tokens = tokens - need" , "if consume == 1 then tokens = tokens - need end"
, "redis.call('HMSET', key, 'tokens', tokens, 'ts', now)" , "redis.call('HMSET', key, 'tokens', tokens, 'ts', now)"
, "redis.call('EXPIRE', key, 3600)" , "redis.call('EXPIRE', key, ttl)"
, "return 1" , "return 1"
] ]
result <- try (runRedis conn $ result <- try (runRedis conn $
R.eval script [key] R.eval script [key]
[ BS8.pack (show rate) [ BS8.pack (show rate)
, BS8.pack (show burst) , BS8.pack (show burst)
, BS8.pack (show (now :: Integer))
, BS8.pack (show bytes) , BS8.pack (show bytes)
, if consume then "1" else "0"
]) :: IO (Either SomeException (Either Reply Integer)) ]) :: IO (Either SomeException (Either Reply Integer))
pure $ case result of pure $ case result of
Left _ -> Left "redis unavailable" Left _ -> Left "redis unavailable"

View file

@ -1,12 +1,16 @@
module Nuthatch.S3 module Nuthatch.S3
( S3 ( S3
, S3Error (..) , S3Error (..)
, PutObjectError (..)
, ObjectInfo (..) , ObjectInfo (..)
, ListPage (..) , ListPage (..)
, newS3 , newS3
, putObject , putObject
, putObjectStream
, getObjectBytes , getObjectBytes
, getObjectSource
, headObject , headObject
, observeObjectFully
, deleteObject , deleteObject
, listObjects , listObjects
, renderS3Error , renderS3Error
@ -16,8 +20,24 @@ import qualified Aws
import qualified Aws.Aws as AwsRun import qualified Aws.Aws as AwsRun
import qualified Aws.Core as AwsCore import qualified Aws.Core as AwsCore
import qualified Aws.S3 as S3 import qualified Aws.S3 as S3
import Control.Concurrent.Async (withAsync)
import Control.Concurrent.STM
( atomically
, newTBQueueIO
, readTBQueue
, writeTBQueue
)
import Control.Applicative ((<|>)) import Control.Applicative ((<|>))
import Control.Exception (SomeException, fromException, try) import Control.Exception
( SomeAsyncException
, SomeException
, fromException
, onException
, throwIO
, try
)
import Control.Monad (void)
import Control.Monad.IO.Class (liftIO)
import Control.Monad.Trans.Resource (runResourceT) import Control.Monad.Trans.Resource (runResourceT)
import Crypto.Hash import Crypto.Hash
( Context ( Context
@ -32,6 +52,7 @@ import qualified Data.ByteString.Char8 as BS8
import qualified Data.ByteString.Lazy as BL import qualified Data.ByteString.Lazy as BL
import qualified Data.Conduit as C import qualified Data.Conduit as C
import qualified Data.Conduit.List as CL import qualified Data.Conduit.List as CL
import Data.IORef
import Data.Text (Text) import Data.Text (Text)
import qualified Data.Text as T import qualified Data.Text as T
import qualified Data.Text.Encoding as TE import qualified Data.Text.Encoding as TE
@ -40,7 +61,18 @@ import Network.HTTP.Client.TLS (tlsManagerSettings)
import Network.HTTP.Types.Status (statusCode) import Network.HTTP.Types.Status (statusCode)
import Network.URI (URI (..), URIAuth (..), parseURI) import Network.URI (URI (..), URIAuth (..), parseURI)
import Nuthatch.Config (Config (..)) import Nuthatch.Config (Config (..))
import Nuthatch.Streaming
( BandwidthError (..)
, UploadStreamError (..)
, consumeUploadParts
, maximumMultipartBytes
, multipartPartBytes
)
import Nuthatch.Types import Nuthatch.Types
import Servant.Types.SourceT
( SourceT (..)
, StepT (..)
)
import Text.Read (readMaybe) import Text.Read (readMaybe)
data S3 = S3 data S3 = S3
@ -57,6 +89,12 @@ data S3Error
| S3ResponseError | S3ResponseError
deriving stock (Eq, Show) deriving stock (Eq, Show)
data PutObjectError
= PutObjectStoreError S3Error
| PutObjectStreamError UploadStreamError
| PutObjectBandwidthError BandwidthError
deriving stock (Eq, Show)
data ObjectInfo = ObjectInfo data ObjectInfo = ObjectInfo
{ oiSize :: Integer { oiSize :: Integer
, oiSha256 :: Maybe ContentSha256 , oiSha256 :: Maybe ContentSha256
@ -127,6 +165,110 @@ newS3 cfg = do
, s3Bucket = cfgS3Bucket cfg , s3Bucket = cfgS3Bucket cfg
} }
putObjectStream
:: S3
-> ObjectKey
-> Integer
-> Maybe ContentSha256
-> Int
-> (BS8.ByteString -> IO ())
-> SourceT IO BS8.ByteString
-> IO (Either PutObjectError ())
putObjectStream store (ObjectKey key) size sha pieceSize inspect body =
if size > maximumMultipartBytes
then pure (Left (PutObjectStoreError S3ResponseError))
else case sha of
Nothing -> pure (Left (PutObjectStoreError S3ResponseError))
Just identity
| size == 0 -> do
streamed <- trySynchronous
(consumeUploadParts
size identity pieceSize multipartPartBytes inspect body
(const (throwIO UploadStreamSizeMismatch)))
case streamed of
Left exception -> pure (Left (classifyPutException exception))
Right () -> putEmptyObject store key sha
| otherwise -> do
initiated <- trySynchronous $ runMemory store $
(S3.postInitiateMultipartUpload (s3Bucket store) key)
{ S3.imuContentType = Just "application/octet-stream"
, S3.imuMetadata =
[("nuthatch-sha256", unContentSha256 identity)]
}
case initiated of
Left exception ->
pure (Left (PutObjectStoreError (classifyException exception)))
Right response -> uploadMultipart
store key size identity pieceSize inspect body
(S3.imurUploadId response)
putEmptyObject
:: S3
-> Text
-> Maybe ContentSha256
-> IO (Either PutObjectError ())
putEmptyObject store key sha = do
result <- trySynchronous $ runMemory store $
(S3.putObject (s3Bucket store) key (HTTP.RequestBodyBS BS8.empty))
{ S3.poContentType = Just "application/octet-stream"
, S3.poMetadata = maybe []
(\identity ->
[("nuthatch-sha256", unContentSha256 identity)])
sha
}
pure $ either
(Left . PutObjectStoreError . classifyException)
(const (Right ()))
result
uploadMultipart
:: S3
-> Text
-> Integer
-> ContentSha256
-> Int
-> (BS8.ByteString -> IO ())
-> SourceT IO BS8.ByteString
-> Text
-> IO (Either PutObjectError ())
uploadMultipart store key size identity pieceSize inspect body uploadId = do
nextPartRef <- newIORef (1 :: Integer)
etagsRef <- newIORef []
let abort = void (try (runMemory store $
S3.postAbortMultipartUpload (s3Bucket store) key uploadId)
:: IO (Either SomeException S3.AbortMultipartUploadResponse))
upload = do
consumeUploadParts
size identity pieceSize multipartPartBytes inspect body $ \part -> do
partNumber <- readIORef nextPartRef
response <- runMemory store $
S3.uploadPart
(s3Bucket store)
key
partNumber
uploadId
(HTTP.RequestBodyBS part)
modifyIORef' etagsRef ((partNumber, S3.uprETag response) :)
writeIORef nextPartRef (partNumber + 1)
etags <- reverse <$> readIORef etagsRef
void $ runMemory store $
S3.postCompleteMultipartUpload
(s3Bucket store)
key
uploadId
etags
result <- trySynchronous (upload `onException` abort)
case result of
Right () -> pure (Right ())
Left exception -> pure (Left (classifyPutException exception))
classifyPutException :: SomeException -> PutObjectError
classifyPutException exception =
case (fromException exception, fromException exception) of
(Just streamError, _) -> PutObjectStreamError streamError
(_, Just bandwidthError) -> PutObjectBandwidthError bandwidthError
_ -> PutObjectStoreError (classifyException exception)
putObject putObject
:: S3 :: S3
-> ObjectKey -> ObjectKey
@ -154,6 +296,61 @@ getObjectBytes store (ObjectKey key) = do
result <- getObjectMemory store key result <- getObjectMemory store key
pure $ result >>= \(_, body) -> Right body pure $ result >>= \(_, body) -> Right body
data DownloadEvent
= DownloadChunk BS8.ByteString
| DownloadFailed S3Error
| DownloadFinished
getObjectSource
:: S3
-> ObjectKey
-> Int
-> (BS8.ByteString -> IO ())
-> SourceT IO BS8.ByteString
getObjectSource store (ObjectKey key) pieceSize inspect =
SourceT $ \consume -> do
queue <- newTBQueueIO 2
withAsync (produce queue) $ \_ ->
consume (consumeQueue queue)
where
produce queue = do
result <- trySynchronous (runResourceT $ do
response <- AwsRun.pureAws
(s3Configuration store)
(s3Service store)
(s3Manager store)
(S3.getObject (s3Bucket store) key)
C.runConduit $
HTTP.responseBody (S3.gorResponse response)
C..| C.awaitForever
(\chunk -> mapM_
(liftIO . atomically . writeTBQueue queue . DownloadChunk)
(splitPieces chunk)))
:: IO (Either SomeException ())
atomically $ writeTBQueue queue $ case result of
Left exception -> DownloadFailed (classifyException exception)
Right () -> DownloadFinished
consumeQueue queue = Effect $ atomically (readTBQueue queue) >>= \case
DownloadChunk chunk -> trySynchronous (inspect chunk) >>= \case
Left exception ->
pure (Error (T.unpack (renderDownloadException exception)))
Right () -> pure (Yield chunk (consumeQueue queue))
DownloadFailed err -> pure (Error (T.unpack (renderS3Error err)))
DownloadFinished -> pure Stop
splitPieces chunk
| BS8.null chunk = []
| otherwise =
let (piece, rest) = BS8.splitAt pieceSize chunk
in piece : splitPieces rest
renderDownloadException exception =
case fromException exception of
Just BandwidthUnavailable -> "bandwidth throttle unavailable"
Just BandwidthExceeded -> "bandwidth limit exceeded"
Nothing -> renderS3Error (classifyException exception)
headObject :: S3 -> ObjectKey -> IO (Either S3Error (Maybe ObjectInfo)) headObject :: S3 -> ObjectKey -> IO (Either S3Error (Maybe ObjectInfo))
headObject store (ObjectKey key) = do headObject store (ObjectKey key) = do
result <- try (runMemory store (S3.headObject (s3Bucket store) key)) result <- try (runMemory store (S3.headObject (s3Bucket store) key))
@ -162,9 +359,37 @@ headObject store (ObjectKey key) = do
Left err -> pure (Left (classifyException err)) Left err -> pure (Left (classifyException err))
Right (S3.HeadObjectMemoryResponse Nothing) -> pure (Right Nothing) Right (S3.HeadObjectMemoryResponse Nothing) -> pure (Right Nothing)
Right (S3.HeadObjectMemoryResponse (Just _)) -> do Right (S3.HeadObjectMemoryResponse (Just _)) -> do
object <- observeObjectStream store key object <- getObjectHeaders store key
pure (Just <$> object) pure (Just <$> object)
getObjectHeaders :: S3 -> Text -> IO (Either S3Error ObjectInfo)
getObjectHeaders store key = do
result <- try (runResourceT $ do
response <- AwsRun.pureAws
(s3Configuration store)
(s3Service store)
(s3Manager store)
(S3.getObject (s3Bucket store) key)
let metadata = S3.gorMetadata response
httpResponse = S3.gorResponse response
knownSize =
lookup "Content-Length" (HTTP.responseHeaders httpResponse)
>>= readMaybe . BS8.unpack
knownIdentity =
lookup "nuthatch-sha256" (S3.omUserMetadata metadata)
>>= either (const Nothing) Just . validateContentSha256
maybe (fail "missing object content length")
(\size -> pure ObjectInfo
{ oiSize = size
, oiSha256 = knownIdentity
})
knownSize)
:: IO (Either SomeException ObjectInfo)
pure (either (Left . classifyException) Right result)
observeObjectFully :: S3 -> ObjectKey -> IO (Either S3Error ObjectInfo)
observeObjectFully store (ObjectKey key) = observeObjectStream store key
deleteObject :: S3 -> ObjectKey -> IO (Either S3Error ()) deleteObject :: S3 -> ObjectKey -> IO (Either S3Error ())
deleteObject store (ObjectKey key) = do deleteObject store (ObjectKey key) = do
result <- try (runMemory store (S3.DeleteObject key (s3Bucket store))) result <- try (runMemory store (S3.DeleteObject key (s3Bucket store)))
@ -259,30 +484,18 @@ observeObjectStream store key = do
(s3Service store) (s3Service store)
(s3Manager store) (s3Manager store)
(S3.getObject (s3Bucket store) key) (S3.getObject (s3Bucket store) key)
let metadata = S3.gorMetadata response let httpResponse = S3.gorResponse response
httpResponse = S3.gorResponse response (size, context) <- C.runConduit $
knownSize = HTTP.responseBody httpResponse
lookup "Content-Length" (HTTP.responseHeaders httpResponse) C..| CL.fold updateDigest (0, hashInit)
>>= readMaybe . BS8.unpack pure ObjectInfo
knownIdentity = { oiSize = size
lookup "nuthatch-sha256" (S3.omUserMetadata metadata) , oiSha256 = Just (contextIdentity context)
>>= either (const Nothing) Just . validateContentSha256 }
case (knownSize, knownIdentity) of
(Just size, Just identity) ->
pure ObjectInfo
{ oiSize = size
, oiSha256 = Just identity
}
_ -> do
(size, context) <- C.runConduit $
HTTP.responseBody httpResponse
C..| CL.fold updateDigest (0, hashInit)
pure ObjectInfo
{ oiSize = size
, oiSha256 = Just (contextIdentity context)
}
updateDigest (size, context) chunk = updateDigest (size, context) chunk =
(size + toInteger (BS8.length chunk), hashUpdate context chunk) let nextSize = size + toInteger (BS8.length chunk)
nextContext = hashUpdate context chunk
in nextSize `seq` nextContext `seq` (nextSize, nextContext)
contextIdentity :: Context SHA256 -> ContentSha256 contextIdentity :: Context SHA256 -> ContentSha256
contextIdentity = contextIdentity =
@ -311,6 +524,15 @@ classifyException exception =
Just (HTTP.InvalidUrlException _ _) -> S3ResponseError Just (HTTP.InvalidUrlException _ _) -> S3ResponseError
Nothing -> S3ResponseError Nothing -> S3ResponseError
trySynchronous :: IO a -> IO (Either SomeException a)
trySynchronous action = do
result <- try action
case result of
Left exception
| Just (_ :: SomeAsyncException) <- fromException exception ->
throwIO exception
_ -> pure result
lastMaybe :: [a] -> Maybe a lastMaybe :: [a] -> Maybe a
lastMaybe [] = Nothing lastMaybe [] = Nothing
lastMaybe values = Just (last values) lastMaybe values = Just (last values)

124
src/Nuthatch/Streaming.hs Normal file
View file

@ -0,0 +1,124 @@
module Nuthatch.Streaming
( UploadStreamError (..)
, BandwidthError (..)
, multipartPartBytes
, maximumMultipartBytes
, consumeUploadParts
) where
import Control.Exception (Exception, throwIO)
import Crypto.Hash (Context, SHA256, hashFinalize, hashInit, hashUpdate)
import qualified Data.ByteArray as BA
import qualified Data.ByteString as BS
import qualified Data.ByteString.Base16 as B16
import Data.IORef
import qualified Data.Text.Encoding as TE
import Nuthatch.Types
import Servant.Types.SourceT
( SourceT
, foreach
)
data UploadStreamError
= UploadStreamInvalid String
| UploadStreamSizeMismatch
| UploadStreamIdentityMismatch
deriving stock (Eq, Show)
instance Exception UploadStreamError
data BandwidthError
= BandwidthUnavailable
| BandwidthExceeded
deriving stock (Eq, Show)
instance Exception BandwidthError
multipartPartBytes :: Int
multipartPartBytes = 5 * 1024 * 1024
maximumMultipartBytes :: Integer
maximumMultipartBytes = toInteger multipartPartBytes * 10000
data UploadState = UploadState
{ usPending :: [BS.ByteString]
, usPendingSize :: Int
, usSize :: Integer
, usHash :: Context SHA256
}
consumeUploadParts
:: Integer
-> ContentSha256
-> Int
-> Int
-> (BS.ByteString -> IO ())
-> SourceT IO BS.ByteString
-> (BS.ByteString -> IO ())
-> IO ()
consumeUploadParts
expectedSize
expectedIdentity
pieceSize
partSize
inspect
body
emitPart = do
stateRef <- newIORef UploadState
{ usPending = []
, usPendingSize = 0
, usSize = 0
, usHash = hashInit
}
foreach
(throwIO . UploadStreamInvalid)
(mapM_ (acceptPiece stateRef) . splitPieces)
body
state <- readIORef stateRef
if usSize state /= expectedSize
then throwIO UploadStreamSizeMismatch
else if digestIdentity (usHash state) /= expectedIdentity
then throwIO UploadStreamIdentityMismatch
else if usPendingSize state == 0
then pure ()
else emitPart (BS.concat (reverse (usPending state)))
where
splitPieces bytes
| BS.null bytes = []
| otherwise =
let (piece, rest) = BS.splitAt pieceSize bytes
in piece : splitPieces rest
acceptPiece stateRef piece = do
inspect piece
state <- readIORef stateRef
let nextSize = usSize state + toInteger (BS.length piece)
nextHash = hashUpdate (usHash state) piece
nextHash `seq` pure ()
if nextSize > expectedSize
then throwIO UploadStreamSizeMismatch
else fillPart stateRef state
{ usPending = piece : usPending state
, usPendingSize = usPendingSize state + BS.length piece
, usSize = nextSize
, usHash = nextHash
}
fillPart stateRef state
| usPendingSize state < partSize = writeIORef stateRef state
| otherwise = do
let bytes = BS.concat (reverse (usPending state))
(part, remainder) = BS.splitAt partSize bytes
emitPart part
writeIORef stateRef state
{ usPending = [remainder | not (BS.null remainder)]
, usPendingSize = BS.length remainder
}
digestIdentity :: Context SHA256 -> ContentSha256
digestIdentity =
ContentSha256
. TE.decodeUtf8
. B16.encode
. BA.convert
. hashFinalize

View file

@ -1,5 +1,6 @@
module Main (main) where module Main (main) where
import Control.Exception (try)
import Control.Monad.IO.Class (liftIO) import Control.Monad.IO.Class (liftIO)
import Control.Lens ((&), (?~)) import Control.Lens ((&), (?~))
import Crypto.JOSE (runJOSE) import Crypto.JOSE (runJOSE)
@ -12,6 +13,7 @@ import Crypto.JOSE.JWK
, genJWK , genJWK
) )
import Crypto.JOSE.JWS (newJWSHeaderProtected) import Crypto.JOSE.JWS (newJWSHeaderProtected)
import Crypto.Hash (Digest, SHA256, hash)
import Crypto.JWT import Crypto.JWT
( Audience (..) ( Audience (..)
, ClaimsSet , ClaimsSet
@ -23,7 +25,9 @@ import Crypto.JWT
) )
import qualified Data.Aeson as A import qualified Data.Aeson as A
import qualified Data.Aeson.KeyMap as KM import qualified Data.Aeson.KeyMap as KM
import qualified Data.ByteArray as BA
import qualified Data.ByteString as BS import qualified Data.ByteString as BS
import qualified Data.ByteString.Base16 as B16
import qualified Data.ByteString.Lazy as BL import qualified Data.ByteString.Lazy as BL
import Data.Either (isLeft, isRight) import Data.Either (isLeft, isRight)
import Data.IORef (IORef, modifyIORef', newIORef, readIORef, writeIORef) import Data.IORef (IORef, modifyIORef', newIORef, readIORef, writeIORef)
@ -87,10 +91,21 @@ import Nuthatch.Protocol
) )
import Nuthatch.Types import Nuthatch.Types
import Nuthatch.Session (mintSession) import Nuthatch.Session (mintSession)
import Nuthatch.Streaming
( UploadStreamError (..)
, consumeUploadParts
, maximumMultipartBytes
, multipartPartBytes
)
import Nuthatch.Ui (browsePage, browserScript) import Nuthatch.Ui (browsePage, browserScript)
import Servant import Servant
import Servant.Server.Experimental.Auth (AuthHandler, mkAuthHandler) import Servant.Server.Experimental.Auth (AuthHandler, mkAuthHandler)
import Servant.Types.SourceT (source) import Servant.Types.SourceT
( SourceT
, StepT (..)
, fromStepT
, source
)
import Test.Tasty (TestTree, defaultMain, testGroup) import Test.Tasty (TestTree, defaultMain, testGroup)
import Test.Tasty.HUnit ((@?=), assertBool, testCase) import Test.Tasty.HUnit ((@?=), assertBool, testCase)
import Test.Tasty.Hedgehog (testProperty) import Test.Tasty.Hedgehog (testProperty)
@ -103,10 +118,61 @@ tests = testGroup "nuthatch-unit"
[ pathTests [ pathTests
, identityTests , identityTests
, protocolTests , protocolTests
, streamingTests
, oidcTests , oidcTests
, apiTests , apiTests
] ]
streamingTests :: TestTree
streamingTests = testGroup "streaming"
[ testCase "upload bodies are inspected and emitted in bounded parts" $ do
inspected <- newIORef []
emitted <- newIORef []
consumeUploadParts
6
(strictSha256 "abcdef")
2
4
(\piece -> modifyIORef' inspected (<> [piece]))
(source ["abcdef"])
(\part -> modifyIORef' emitted (<> [part]))
readIORef inspected >>= (@?= ["ab", "cd", "ef"])
readIORef emitted >>= (@?= ["abcd", "ef"])
, testCase "multipart buffering has a fixed part-count bound" $
maximumMultipartBytes @?= toInteger multipartPartBytes * 10000
, testCase "upload rejects early end, surplus bytes, and identity mismatch" $ do
short <- uploadFailure 4 (strictSha256 "body") (source ["bod"])
short @?= UploadStreamSizeMismatch
long <- uploadFailure 4 (strictSha256 "body") (source ["body!"])
long @?= UploadStreamSizeMismatch
identity <- uploadFailure 4 (strictSha256 "else") (source ["body"])
identity @?= UploadStreamIdentityMismatch
, testCase "upload preserves source errors" $ do
failure <- uploadFailure
4
(strictSha256 "body")
(fromStepT (Error "request failed"))
failure @?= UploadStreamInvalid "request failed"
]
uploadFailure
:: Integer
-> ContentSha256
-> SourceT IO BS.ByteString
-> IO UploadStreamError
uploadFailure size identity body = do
result <- try $
consumeUploadParts
size identity 2 4 (const (pure ())) body (const (pure ()))
case result of
Left failure -> pure failure
Right _ -> fail "expected upload stream rejection"
strictSha256 :: BS.ByteString -> ContentSha256
strictSha256 bytes =
ContentSha256 . TE.decodeUtf8 . B16.encode . BA.convert $
(hash bytes :: Digest SHA256)
pathTests :: TestTree pathTests :: TestTree
pathTests = testGroup "canonical paths" pathTests = testGroup "canonical paths"
[ testProperty "valid paths parse and render without rewriting" propPathRoundTrip [ testProperty "valid paths parse and render without rewriting" propPathRoundTrip
@ -604,7 +670,7 @@ typedTestServer calls =
})) }))
:<|> (\_ _ _ -> counted :<|> (\_ _ _ -> counted
(addHeader "attachment; filename=\"file.txt\"" (addHeader "attachment; filename=\"file.txt\""
(addHeader "nosniff" (source ["body"])))) (addHeader "nosniff" (addHeader 4 (source ["body"])))))
:<|> (\_ _ _ operation _ _ _ _ -> counted UploadResponse :<|> (\_ _ _ operation _ _ _ _ -> counted UploadResponse
{ uploadResponseOperationId = operation { uploadResponseOperationId = operation
, uploadResponseResult = MutationSucceeded , uploadResponseResult = MutationSucceeded

View file

@ -362,6 +362,374 @@ spec:
exit 1 exit 1
fi fi
if [ "${NUTHATCH_STREAMING_MODE:-}" = large ]; then
large_bytes="${NUTHATCH_STREAMING_LARGE_BYTES:?}"
printf 'Preparing %s-byte streaming object\n' "$large_bytes"
large_digest="$(
dd if=/dev/zero bs=1048576 \
count="$((large_bytes / 1048576))" status=none |
sha256sum |
cut -d' ' -f1
)"
printf 'Uploading constrained-memory streaming object\n'
status="$(
dd if=/dev/zero bs=1048576 \
count="$((large_bytes / 1048576))" status=none |
curl --silent --show-error \
--output /tmp/large-upload.json \
--write-out '%{http_code}' \
--cookie "$cookies" \
--header 'Content-Type: application/octet-stream' \
--header "X-Declared-Size: $large_bytes" \
--header "X-Content-SHA256: $large_digest" \
--header "X-CSRF-Token: $csrf" \
--request POST \
--upload-file - \
"$base/upload?root=personal&path=streaming-large.bin&operation_id=70000000-0000-4000-8000-000000000001"
)"
printf 'Large upload status=%s\n' "$status"
test "$status" = 200
grep -F '"result":"succeeded"' /tmp/large-upload.json
printf 'Downloading constrained-memory streaming object\n'
download_digest="$(
curl --fail --silent --show-error \
--cookie "$cookies" \
"$base/download?root=personal&path=streaming-large.bin" |
sha256sum |
cut -d' ' -f1
)"
printf 'Large download digest expected=%s actual=%s\n' \
"$large_digest" "$download_digest"
test "$download_digest" = "$large_digest"
printf x >/tmp/tiny
tiny_digest="$(sha256sum /tmp/tiny | cut -d' ' -f1)"
for attempt in first replay; do
status="$(
curl --silent --show-error \
--output "/tmp/prior-desired-$attempt.json" \
--write-out '%{http_code}' \
--cookie "$cookies" \
--header 'Content-Type: application/octet-stream' \
--header "X-Declared-Size: $large_bytes" \
--header "X-Content-SHA256: $large_digest" \
--header "X-CSRF-Token: $csrf" \
--data-binary @/tmp/tiny \
"$base/upload?root=personal&path=streaming-large.bin&operation_id=70000000-0000-4000-8000-000000000007"
)"
test "$status" = 200
grep -F '"result":"succeeded"' \
"/tmp/prior-desired-$attempt.json"
done
status="$(
curl --silent --show-error \
--output /tmp/size-mismatch.json \
--write-out '%{http_code}' \
--cookie "$cookies" \
--header 'Content-Type: application/octet-stream' \
--header 'X-Declared-Size: 2' \
--header "X-Content-SHA256: $tiny_digest" \
--header "X-CSRF-Token: $csrf" \
--data-binary @/tmp/tiny \
"$base/upload?root=personal&path=size-mismatch.bin&operation_id=70000000-0000-4000-8000-000000000002"
)"
printf 'Size mismatch status=%s body=' "$status"
cat /tmp/size-mismatch.json
printf '\n'
test "$status" = 400
grep -F '"error":"size_mismatch"' /tmp/size-mismatch.json
status="$(
curl --silent --show-error \
--output /tmp/identity-mismatch.json \
--write-out '%{http_code}' \
--cookie "$cookies" \
--header 'Content-Type: application/octet-stream' \
--header 'X-Declared-Size: 1' \
--header "X-Content-SHA256: $(printf '%064d' 0)" \
--header "X-CSRF-Token: $csrf" \
--data-binary @/tmp/tiny \
"$base/upload?root=personal&path=identity-mismatch.bin&operation_id=70000000-0000-4000-8000-000000000003"
)"
printf 'Identity mismatch status=%s body=' "$status"
cat /tmp/identity-mismatch.json
printf '\n'
test "$status" = 400
grep -F '"error":"content_identity_mismatch"' \
/tmp/identity-mismatch.json
status="$(
set +o pipefail
dd if=/dev/zero bs=1048576 count=160 status=none |
curl --silent --show-error \
--max-time 5 \
--limit-rate 64k \
--output /tmp/too-large.json \
--write-out '%{http_code}' \
--cookie "$cookies" \
--header 'Content-Type: application/octet-stream' \
--header "X-Declared-Size: $NUTHATCH_STREAMING_OVER_LIMIT_BYTES" \
--header "X-Content-SHA256: $tiny_digest" \
--header "X-CSRF-Token: $csrf" \
--request POST \
--upload-file - \
"$base/upload?root=personal&path=too-large.bin&operation_id=70000000-0000-4000-8000-000000000004"
)"
printf 'Over-limit status=%s body=' "$status"
cat /tmp/too-large.json
printf '\n'
test "$status" = 413
grep -F '"error":"object_too_large"' /tmp/too-large.json
if dd if=/dev/zero bs=1048576 \
count="$((large_bytes / 1048576))" status=none |
curl --silent --show-error \
--max-time 1 \
--limit-rate 64k \
--cookie "$cookies" \
--header 'Content-Type: application/octet-stream' \
--header "X-Declared-Size: $large_bytes" \
--header "X-Content-SHA256: $large_digest" \
--header "X-CSRF-Token: $csrf" \
--request POST \
--upload-file - \
"$base/upload?root=personal&path=aborted-upload.bin&operation_id=70000000-0000-4000-8000-000000000005" \
>/tmp/aborted-upload.json; then
exit 1
fi
aborted_upload_status="$(
curl --silent --output /dev/null \
--write-out '%{http_code}' \
--cookie "$cookies" \
"$base/download?root=personal&path=aborted-upload.bin"
)"
printf 'Aborted upload lookup status=%s\n' \
"$aborted_upload_status"
test "$aborted_upload_status" = 404
for retry in $(seq 1 20); do
status="$(
curl --silent --show-error \
--output /tmp/aborted-upload-replay.json \
--write-out '%{http_code}' \
--cookie "$cookies" \
--header 'Content-Type: application/octet-stream' \
--header "X-Declared-Size: $large_bytes" \
--header "X-Content-SHA256: $large_digest" \
--header "X-CSRF-Token: $csrf" \
--data-binary @/tmp/tiny \
"$base/upload?root=personal&path=aborted-upload.bin&operation_id=70000000-0000-4000-8000-000000000005"
)"
test "$status" = 200
if grep -F '"result":"failed"' \
/tmp/aborted-upload-replay.json >/dev/null; then
break
fi
sleep 0.25
done
grep -F '"result":"failed"' /tmp/aborted-upload-replay.json
if dd if=/dev/zero bs=1048576 \
count="$((large_bytes / 1048576))" status=none |
curl --silent --show-error \
--max-time 1 \
--limit-rate 64k \
--cookie "$cookies" \
--header 'Content-Type: application/octet-stream' \
--header "X-Declared-Size: $large_bytes" \
--header "X-Content-SHA256: $(printf '%064d' 0)" \
--header "X-CSRF-Token: $csrf" \
--request POST \
--upload-file - \
"$base/upload?root=personal&path=streaming-large.bin&operation_id=70000000-0000-4000-8000-000000000006" \
>/tmp/aborted-overwrite.json; then
exit 1
fi
preserved_digest="$(
curl --fail --silent --show-error \
--cookie "$cookies" \
"$base/download?root=personal&path=streaming-large.bin" |
sha256sum |
cut -d' ' -f1
)"
test "$preserved_digest" = "$large_digest"
if curl --silent --show-error \
--max-time 1 \
--limit-rate 64k \
--cookie "$cookies" \
"$base/download?root=personal&path=streaming-large.bin" \
>/tmp/aborted-download; then
exit 1
fi
aborted_bytes="$(wc -c </tmp/aborted-download | tr -d ' ')"
test "$aborted_bytes" -gt 0
test "$aborted_bytes" -lt "$large_bytes"
curl --fail --silent --show-error "$base/readyz" |
grep -F '"ready":true'
printf 'Large streaming and abort probes passed\n'
exit 0
fi
if [ "${NUTHATCH_STREAMING_MODE:-}" = throttle ]; then
csrf_b="$(login "$NUTHATCH_OIDC_USER_B" b)"
dd if=/dev/zero of=/tmp/joint-upload bs=65536 \
count=1 status=none
joint_digest="$(
sha256sum /tmp/joint-upload | cut -d' ' -f1
)"
status="$(
curl --silent --show-error \
--output /tmp/joint-upload.json \
--write-out '%{http_code}' \
--cookie "$cookies" \
--header 'Content-Type: application/octet-stream' \
--header 'X-Declared-Size: 65536' \
--header "X-Content-SHA256: $joint_digest" \
--header "X-CSRF-Token: $csrf" \
--data-binary @/tmp/joint-upload \
"$base/upload?root=personal&path=joint-throttle.bin&operation_id=70000000-0000-4000-8000-000000000013"
)"
test "$status" = 200
grep -F '"result":"succeeded"' /tmp/joint-upload.json
dd if=/dev/zero of=/tmp/throttle-upload bs=1048576 \
count=1 status=none
upload_digest="$(
sha256sum /tmp/throttle-upload | cut -d' ' -f1
)"
status="$(
curl --silent --show-error \
--output /tmp/throttle-upload.json \
--write-out '%{http_code}' \
--cookie /tmp/cookies-b \
--header 'Content-Type: application/octet-stream' \
--header 'X-Declared-Size: 1048576' \
--header "X-Content-SHA256: $upload_digest" \
--header "X-CSRF-Token: $csrf_b" \
--data-binary @/tmp/throttle-upload \
"$base/upload?root=personal&path=throttled-upload.bin&operation_id=71000000-0000-4000-8000-000000000001"
)"
test "$status" = 429
grep -F '"error":"bandwidth_exceeded"' /tmp/throttle-upload.json
printf x >/tmp/tiny
test "$(
curl --silent --output /tmp/prestart-upload.json \
--write-out '%{http_code}' \
--cookie /tmp/cookies-b \
--header 'Content-Type: application/octet-stream' \
--header 'X-Declared-Size: 1' \
--header "X-Content-SHA256: $upload_digest" \
--header "X-CSRF-Token: $csrf_b" \
--data-binary @/tmp/tiny \
"$base/upload?root=personal&path=prestart.bin&operation_id=71000000-0000-4000-8000-000000000002"
)" = 429
curl --silent --show-error --cookie "$cookies" \
"$base/download?root=personal&path=streaming-large.bin" \
>/tmp/parallel-a &
first_pid=$!
curl --silent --show-error --cookie "$cookies" \
"$base/download?root=personal&path=streaming-large.bin" \
>/tmp/parallel-b &
second_pid=$!
first_ok=true
second_ok=true
wait "$first_pid" || first_ok=false
wait "$second_pid" || second_ok=false
test "$first_ok" = false
test "$second_ok" = false
parallel_bytes="$(
wc -c /tmp/parallel-a /tmp/parallel-b |
awk 'END {print $1}'
)"
printf 'Parallel downloads moved %s bytes with burst %s\n' \
"$parallel_bytes" \
"$NUTHATCH_STREAMING_THROTTLE_BURST_BYTES"
test "$parallel_bytes" -gt 0
joint_limit="$(
expr "$NUTHATCH_STREAMING_THROTTLE_BURST_BYTES" - 65536 + 4096
)"
test "$parallel_bytes" -le "$joint_limit"
prestart_status="$(
curl --silent --output /tmp/prestart-download.json \
--write-out '%{http_code}' \
--cookie "$cookies" \
"$base/download?root=personal&path=streaming-large.bin"
)"
printf 'Post-exhaustion download status=%s\n' "$prestart_status"
test "$prestart_status" = 429
printf 'Shared incremental throttle probes passed\n'
exit 0
fi
if [ "${NUTHATCH_STREAMING_MODE:-}" = refill ]; then
dd if=/dev/zero of=/tmp/refill-upload bs=65536 \
count=1 status=none
refill_digest="$(
sha256sum /tmp/refill-upload | cut -d' ' -f1
)"
for suffix in initial refilled; do
if [ "$suffix" = refilled ]; then
sleep 2
refill_operation=72000000-0000-4000-8000-000000000002
else
refill_operation=72000000-0000-4000-8000-000000000001
fi
status="$(
curl --silent --show-error \
--output "/tmp/refill-$suffix.json" \
--write-out '%{http_code}' \
--cookie "$cookies" \
--header 'Content-Type: application/octet-stream' \
--header 'X-Declared-Size: 65536' \
--header "X-Content-SHA256: $refill_digest" \
--header "X-CSRF-Token: $csrf" \
--data-binary @/tmp/refill-upload \
"$base/upload?root=personal&path=refill-$suffix.bin&operation_id=$refill_operation"
)"
test "$status" = 200
grep -F '"result":"succeeded"' "/tmp/refill-$suffix.json"
if [ "$suffix" = initial ]; then
test "$(
curl --silent --output /tmp/refill-exhausted.json \
--write-out '%{http_code}' \
--cookie "$cookies" \
--header 'Content-Type: application/octet-stream' \
--header 'X-Declared-Size: 65536' \
--header "X-Content-SHA256: $refill_digest" \
--header "X-CSRF-Token: $csrf" \
--data-binary @/tmp/refill-upload \
"$base/upload?root=personal&path=refill-exhausted.bin&operation_id=72000000-0000-4000-8000-000000000003"
)" = 429
fi
done
printf 'Token-bucket exhaustion and refill probes passed\n'
exit 0
fi
if [ "${NUTHATCH_STREAMING_MODE:-}" = unavailable ]; then
printf x >/tmp/tiny
tiny_digest="$(sha256sum /tmp/tiny | cut -d' ' -f1)"
test "$(
curl --silent --output /tmp/unavailable-upload.json \
--write-out '%{http_code}' \
--cookie "$cookies" \
--header 'Content-Type: application/octet-stream' \
--header 'X-Declared-Size: 1' \
--header "X-Content-SHA256: $tiny_digest" \
--header "X-CSRF-Token: $csrf" \
--data-binary @/tmp/tiny \
"$base/upload?root=personal&path=unavailable.bin&operation_id=72000000-0000-4000-8000-000000000001"
)" = 503
grep -F '"error":"throttle_unavailable"' \
/tmp/unavailable-upload.json
test "$(
curl --silent --output /tmp/unavailable-download.json \
--write-out '%{http_code}' \
--cookie "$cookies" \
"$base/download?root=personal&path=streaming-large.bin"
)" = 503
grep -F '"error":"throttle_unavailable"' \
/tmp/unavailable-download.json
printf 'Unavailable coordination probes passed\n'
exit 0
fi
exercise_root() { exercise_root() {
root="$1" root="$1"
suffix="$2" suffix="$2"
@ -563,6 +931,30 @@ spec:
configMapKeyRef: configMapKeyRef:
name: @@PROFILE_NAME@@ name: @@PROFILE_NAME@@
key: NUTHATCH_OIDC_REDIRECT_URI key: NUTHATCH_OIDC_REDIRECT_URI
- name: NUTHATCH_STREAMING_MODE
valueFrom:
configMapKeyRef:
name: @@PROFILE_NAME@@
key: NUTHATCH_STREAMING_MODE
optional: true
- name: NUTHATCH_STREAMING_LARGE_BYTES
valueFrom:
configMapKeyRef:
name: @@PROFILE_NAME@@
key: NUTHATCH_STREAMING_LARGE_BYTES
optional: true
- name: NUTHATCH_STREAMING_OVER_LIMIT_BYTES
valueFrom:
configMapKeyRef:
name: @@PROFILE_NAME@@
key: NUTHATCH_STREAMING_OVER_LIMIT_BYTES
optional: true
- name: NUTHATCH_STREAMING_THROTTLE_BURST_BYTES
valueFrom:
configMapKeyRef:
name: @@PROFILE_NAME@@
key: NUTHATCH_STREAMING_THROTTLE_BURST_BYTES
optional: true
- name: NUTHATCH_OIDC_CLIENT_SECRET - name: NUTHATCH_OIDC_CLIENT_SECRET
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:

217
tests/kind/wp07-run.sh Executable file
View file

@ -0,0 +1,217 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
source "$ROOT/tests/kind/lib.sh"
USER_KUBECONFIG_BEFORE="$(user_kubeconfig_digest)"
SUCCEEDED=false
OWNS_FIXTURE=false
APP_IMAGE=nuthatch-wp07:local
cleanup() {
local status=$?
trap - EXIT INT TERM
if [[ "${NUTHATCH_TESTBED_RETAIN:-0}" = 1 ]] && [[ "$SUCCEEDED" != true ]]; then
printf 'retained kubeconfig: %s\n' "$KUBECONFIG_PATH"
printf 'retained context: %s\n' "$CONTEXT"
exit "$status"
fi
if [[ "$OWNS_FIXTURE" = true ]]; then
destroy_fixture || status=1
fi
if [[ "$(user_kubeconfig_digest)" != "$USER_KUBECONFIG_BEFORE" ]]; then
printf 'user kubeconfig changed unexpectedly\n' >&2
status=1
fi
exit "$status"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
render_application() {
local lane="$1"
local profile_file="$2"
local rate="$3"
local burst="$4"
local pg_conn
local redis_url
local oidc_issuer
local oidc_client_id
local oidc_additional_audiences
local oidc_client_secret
local s3_endpoint
local s3_region
local s3_bucket
local s3_access_key
local s3_secret_key
pg_conn="$(sed -n 's/^NUTHATCH_PG_CONN=//p' "$profile_file")"
redis_url="$(sed -n 's/^NUTHATCH_REDIS_URL=//p' "$profile_file")"
oidc_issuer="$(sed -n 's/^NUTHATCH_OIDC_ISSUER=//p' "$profile_file")"
oidc_client_id="$(sed -n 's/^NUTHATCH_OIDC_CLIENT_ID=//p' "$profile_file")"
oidc_additional_audiences="$(
sed -n 's/^NUTHATCH_OIDC_ADDITIONAL_AUDIENCES=//p' "$profile_file"
)"
oidc_client_secret="$(<"$profile_file.oidc-secret")"
s3_endpoint="$(sed -n 's/^NUTHATCH_S3_ENDPOINT=//p' "$profile_file")"
s3_region="$(sed -n 's/^NUTHATCH_S3_REGION=//p' "$profile_file")"
s3_bucket="$(sed -n 's/^NUTHATCH_S3_BUCKET=//p' "$profile_file")"
s3_access_key="$(sed -n 's/^NUTHATCH_S3_ACCESS_KEY=//p' "$profile_file")"
s3_secret_key="$(sed -n 's/^NUTHATCH_S3_SECRET_KEY=//p' "$profile_file")"
sed \
-e "s|@@LANE@@|$lane|g" \
-e "s|@@APP_IMAGE@@|$APP_IMAGE|g" \
-e "s|@@PG_CONN@@|$pg_conn|g" \
-e "s|@@REDIS_URL@@|$redis_url|g" \
-e "s|@@OIDC_ISSUER@@|$oidc_issuer|g" \
-e "s|@@OIDC_CLIENT_ID@@|$oidc_client_id|g" \
-e "s|@@OIDC_ADDITIONAL_AUDIENCES@@|$oidc_additional_audiences|g" \
-e "s|@@OIDC_CLIENT_SECRET@@|$oidc_client_secret|g" \
-e "s|@@S3_ENDPOINT@@|$s3_endpoint|g" \
-e "s|@@S3_REGION@@|$s3_region|g" \
-e "s|@@S3_BUCKET@@|$s3_bucket|g" \
-e "s|@@S3_ACCESS_KEY@@|$s3_access_key|g" \
-e "s|@@S3_SECRET_KEY@@|$s3_secret_key|g" \
"$ROOT/deploy/kubernetes/nuthatch.yaml.in" > "$RUNTIME/wp07-app-$lane.yaml"
sed -i \
"/NUTHATCH_S3_PATH_STYLE/a\\ NUTHATCH_MAX_OBJECT_BYTES: \"201326592\"\\n NUTHATCH_THROTTLE_BYTES_PER_SEC: \"$rate\"\\n NUTHATCH_THROTTLE_BURST_BYTES: \"$burst\"" \
"$RUNTIME/wp07-app-$lane.yaml"
sed -i 's/ replicas: 1/ replicas: 2/' "$RUNTIME/wp07-app-$lane.yaml"
sed -i \
's/limits: {cpu: "2", memory: 512Mi}/limits: {cpu: "2", memory: 128Mi}/' \
"$RUNTIME/wp07-app-$lane.yaml"
sed -i \
'/ envFrom:/i\ env:\n - {name: GHCRTS, value: "-N2 -A1m -M96m"}' \
"$RUNTIME/wp07-app-$lane.yaml"
}
cleanup_application() {
local lane="$1"
kube --namespace "$FIXTURE_NAMESPACE" delete \
"deployment/nuthatch-$lane" \
"service/nuthatch-$lane" \
"configmap/nuthatch-$lane" \
"secret/nuthatch-$lane" \
--ignore-not-found >/dev/null
rm -f "$RUNTIME/wp07-app-$lane.yaml"
}
set_streaming_profile() {
local profile="$1"
local mode="$2"
local throttle_burst=262144
local profile_file="$RUNTIME/$profile.env"
if [[ "$mode" = refill ]]; then
throttle_burst=65536
fi
sed -i '/^NUTHATCH_STREAMING_/d' "$profile_file"
{
printf 'NUTHATCH_STREAMING_MODE=%s\n' "$mode"
printf 'NUTHATCH_STREAMING_LARGE_BYTES=167772160\n'
printf 'NUTHATCH_STREAMING_OVER_LIMIT_BYTES=201326593\n'
printf 'NUTHATCH_STREAMING_THROTTLE_BURST_BYTES=%s\n' "$throttle_burst"
} >> "$profile_file"
kube create configmap "$profile" \
--namespace "$FIXTURE_NAMESPACE" \
--from-env-file="$profile_file" \
--dry-run=client \
--output yaml |
kube apply --filename - >/dev/null
}
run_streaming_job() {
local lane="$1"
local profile="$2"
local mode="$3"
local job="wp07-$mode-$lane"
set_streaming_profile "$profile" "$mode"
render_lane_job \
"$job" \
"$TESTBED/wp05-job.yaml.in" \
"$CURL_LOCAL_IMAGE" \
"$profile"
sed -i "s|@@LANE@@|$lane|g" "$RUNTIME/$job.yaml"
run_job_manifest "$job" "$RUNTIME/$job.yaml"
}
scale_coordination() {
local provider="$1"
local replicas="$2"
kube --namespace "$FIXTURE_NAMESPACE" scale \
"deployment/$provider" \
--replicas="$replicas" >/dev/null
if [[ "$replicas" = 0 ]]; then
kube --namespace "$FIXTURE_NAMESPACE" wait \
--for=delete \
"pod" \
--selector="app=$provider" \
--timeout=120s >/dev/null
else
kube --namespace "$FIXTURE_NAMESPACE" rollout status \
"deployment/$provider" \
--timeout=180s >/dev/null
fi
}
deploy_application() {
local lane="$1"
local profile="$2"
local rate="$3"
local burst="$4"
cleanup_application "$lane"
render_application "$lane" "$RUNTIME/$profile.env" "$rate" "$burst"
kube apply --filename "$RUNTIME/wp07-app-$lane.yaml" >/dev/null
kube --namespace "$FIXTURE_NAMESPACE" rollout status \
"deployment/nuthatch-$lane" --timeout=180s
}
acquire_fixture_lock
OWNS_FIXTURE=true
setup_fixture durable coordination oidc target
source "$RUNTIME/fixture.env"
"$NUTHATCH_REAL_NERDCTL" --namespace "$NERDCTL_NAMESPACE" build \
--file "$ROOT/Dockerfile" \
--tag "$APP_IMAGE" \
"$ROOT"
load_local_image "$APP_IMAGE" "$NERDCTL_NAMESPACE"
while IFS=$'\t' read -r lane coordination oidc target; do
if [[ -n "${NUTHATCH_WP07_LANE:-}" ]] &&
[[ "$lane" != "$NUTHATCH_WP07_LANE" ]]; then
continue
fi
profile="$(write_lane_profile "$lane" "$coordination" "$oidc" "$target")"
printf 'Running WP07 lane %s: coordination=%s oidc=%s target=%s\n' \
"$lane" "$coordination" "$oidc" "$target"
deploy_application "$lane" "$profile" 536870912 536870912
run_streaming_job "$lane" "$profile" large
cleanup_application "$lane"
scale_coordination "$coordination" 0
scale_coordination "$coordination" 1
deploy_application "$lane" "$profile" 1 262144
run_streaming_job "$lane" "$profile" throttle
deploy_application "$lane" "$profile" 65536 65536
run_streaming_job "$lane" "$profile" refill
deploy_application "$lane" "$profile" 536870912 536870912
scale_coordination "$coordination" 0
run_streaming_job "$lane" "$profile" unavailable
cleanup_application "$lane"
scale_coordination "$coordination" 1
cleanup_lane_state "$lane" "$coordination" "$oidc" "$target"
for mode in large throttle refill unavailable; do
cleanup_lane_resources "wp07-$mode-$lane" "$profile"
done
if [[ "${NUTHATCH_WP07_FIRST_LANE_ONLY:-0}" = 1 ]]; then
break
fi
done < <(expand_fixture_matrix coordination oidc target)
SUCCEEDED=true
printf 'Nuthatch WP-07 streaming gate passed\n'