Stream object transfers with shared byte throttling
This commit is contained in:
parent
a4e615096c
commit
4b044b1456
11 changed files with 1228 additions and 105 deletions
5
Makefile
5
Makefile
|
|
@ -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:
|
||||
cabal test --offline nuthatch-unit
|
||||
|
|
@ -21,6 +21,9 @@ check-auth:
|
|||
check-app:
|
||||
./tests/kind/wp05-run.sh
|
||||
|
||||
check-streaming:
|
||||
./tests/kind/wp07-run.sh
|
||||
|
||||
app-up:
|
||||
NUTHATCH_APP_INTERACTIVE=1 ./tests/kind/wp05-run.sh
|
||||
|
||||
|
|
|
|||
|
|
@ -107,6 +107,7 @@ library
|
|||
Nuthatch.Redis
|
||||
Nuthatch.S3
|
||||
Nuthatch.Session
|
||||
Nuthatch.Streaming
|
||||
Nuthatch.Types
|
||||
Nuthatch.Ui
|
||||
|
||||
|
|
|
|||
|
|
@ -431,6 +431,7 @@ type DownloadSuccess =
|
|||
Headers
|
||||
'[ Header "Content-Disposition" Text
|
||||
, Header "X-Content-Type-Options" Text
|
||||
, Header "Content-Length" Integer
|
||||
]
|
||||
(SourceIO BS.ByteString)
|
||||
|
||||
|
|
|
|||
|
|
@ -9,15 +9,11 @@ module Nuthatch.App
|
|||
) where
|
||||
|
||||
import Control.Concurrent (threadDelay)
|
||||
import Control.Exception (SomeException, try)
|
||||
import Control.Monad (forever, unless, void, when)
|
||||
import Control.Exception (SomeException, throwIO, try)
|
||||
import Control.Monad (forever, void, when)
|
||||
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.ByteArray as BA
|
||||
import qualified Data.ByteString as BS
|
||||
import qualified Data.ByteString.Base16 as B16
|
||||
import qualified Data.ByteString.Lazy as BL
|
||||
import Data.Either (isRight)
|
||||
import Data.Maybe (fromMaybe, listToMaybe)
|
||||
|
|
@ -43,19 +39,33 @@ import qualified Nuthatch.Db as Db
|
|||
import Nuthatch.Oidc
|
||||
import Nuthatch.Paths
|
||||
import Nuthatch.Protocol
|
||||
import Nuthatch.Redis (Redis, cacheGet, cacheInvalidatePrefixes, cacheSet, throttleTake)
|
||||
import Nuthatch.Redis
|
||||
( Redis
|
||||
, cacheGet
|
||||
, cacheInvalidatePrefixes
|
||||
, cacheSet
|
||||
, throttleCheck
|
||||
, throttleTake
|
||||
)
|
||||
import Nuthatch.S3
|
||||
( ListPage (..)
|
||||
, ObjectInfo (..)
|
||||
, PutObjectError (..)
|
||||
, S3
|
||||
, S3Error (..)
|
||||
, deleteObject
|
||||
, getObjectBytes
|
||||
, getObjectSource
|
||||
, headObject
|
||||
, listObjects
|
||||
, putObject
|
||||
, observeObjectFully
|
||||
, putObjectStream
|
||||
, renderS3Error
|
||||
)
|
||||
import Nuthatch.Session
|
||||
import Nuthatch.Streaming
|
||||
( BandwidthError (..)
|
||||
, UploadStreamError (..)
|
||||
)
|
||||
import Nuthatch.Types
|
||||
import Nuthatch.Ui (browsePage, browserScript)
|
||||
import Servant
|
||||
|
|
@ -63,7 +73,7 @@ import Servant.Server.Experimental.Auth
|
|||
( AuthHandler
|
||||
, mkAuthHandler
|
||||
)
|
||||
import Servant.Types.SourceT (runSourceT, source)
|
||||
import Servant.Types.SourceT (SourceT)
|
||||
import Web.Cookie
|
||||
( defaultSetCookie
|
||||
, parseCookies
|
||||
|
|
@ -306,17 +316,9 @@ handleDownloadRequest env session root path = do
|
|||
(DownloadObjectStoreFailed (renderS3Error message))
|
||||
Right Nothing -> throwOperationError err404 DownloadNotFound
|
||||
Right (Just value) -> pure value
|
||||
throttle <- liftIO $
|
||||
throttleTake (envRedis env) (envConfig env) (suUserId user) (oiSize info)
|
||||
case throttle of
|
||||
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
|
||||
requireDownloadBandwidth env user (oiSize info)
|
||||
let inspect = inspectBandwidth env user
|
||||
body = getObjectSource (envS3 env) key (transferPieceSize env) inspect
|
||||
liftIO $ audit "download" "ok" (Just (unUserId (suUserId user)))
|
||||
[ ("root", A.toJSON (rootLabel root))
|
||||
, ("bytes", A.toJSON (oiSize info))
|
||||
|
|
@ -324,7 +326,8 @@ handleDownloadRequest env session root path = do
|
|||
let filename = sanitizeFilename (basename path)
|
||||
pure $
|
||||
addHeader ("attachment; filename=\"" <> filename <> "\"") $
|
||||
addHeader "nosniff" (source (BL.toChunks body))
|
||||
addHeader "nosniff" $
|
||||
addHeader (oiSize info) body
|
||||
|
||||
handleUploadRequest
|
||||
:: Env
|
||||
|
|
@ -339,31 +342,16 @@ handleUploadRequest
|
|||
-> Handler UploadResponse
|
||||
handleUploadRequest env session root path operation declaredSize contentIdentity providedCsrf bodySource = do
|
||||
requireCsrf UploadCsrfRejected session providedCsrf
|
||||
chunksResult <- liftIO (runExceptT (runSourceT bodySource))
|
||||
chunks <- either
|
||||
(throwOperationError err400 . UploadInvalidBodyStream . T.pack)
|
||||
pure
|
||||
chunksResult
|
||||
let user = sessionUser session
|
||||
body = BL.fromChunks chunks
|
||||
size = unDeclaredSize declaredSize
|
||||
actualSize = toInteger (BL.length body)
|
||||
configuredMaximum = cfgMaxObjectBytes (envConfig env)
|
||||
unless (actualSize == size) $
|
||||
throwOperationError err400 UploadSizeMismatch
|
||||
when (size > configuredMaximum) $
|
||||
throwOperationError err413 UploadTooLarge
|
||||
unless (contentSha256 body == contentIdentity) $
|
||||
throwOperationError err400 UploadContentIdentityMismatch
|
||||
throttle <- liftIO $
|
||||
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)
|
||||
requireUploadBandwidth env user size
|
||||
outcome <- liftIO $
|
||||
runPut env user root path operation size contentIdentity bodySource
|
||||
result <- either
|
||||
(throwOperationError err409 . UploadMutationRejected)
|
||||
throwPutFailure
|
||||
pure
|
||||
outcome
|
||||
liftIO $ audit "put" (mutationAuditResult result) (Just (unUserId (suUserId user)))
|
||||
|
|
@ -376,6 +364,48 @@ handleUploadRequest env session root path operation declaredSize contentIdentity
|
|||
, 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
|
||||
:: Env
|
||||
-> SessionContext
|
||||
|
|
@ -495,8 +525,8 @@ runPut
|
|||
-> OperationId
|
||||
-> Integer
|
||||
-> ContentSha256
|
||||
-> BL.ByteString
|
||||
-> IO (Either Text MutationResult)
|
||||
-> SourceT IO BS.ByteString
|
||||
-> IO (Either PutFailure MutationResult)
|
||||
runPut env user root rel op size sha body = do
|
||||
ensurePersonalPool (envDb env) (envConfig env) (suUserId user)
|
||||
let uid = suUserId user
|
||||
|
|
@ -509,12 +539,57 @@ runPut env user root rel op size sha body = do
|
|||
(cfgMaxObjectBytes (envConfig env))
|
||||
case outcome of
|
||||
AdmitConflict -> pure (Right MutationConflict)
|
||||
AdmitRejected reason _ -> pure (Left reason)
|
||||
AdmitRejected reason _ -> pure (Left (PutRejected reason))
|
||||
AdmitReplay MutationPending _ -> pure (Right MutationPending)
|
||||
AdmitReplay result _ -> pure (Right result)
|
||||
AdmitPrepared rec -> dispatchAndFinalize env user root rec $ do
|
||||
_ <- putObject (envS3 env) key size (Just sha) body
|
||||
pure ()
|
||||
AdmitPrepared rec ->
|
||||
dispatchAndFinalize
|
||||
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
|
||||
:: Env
|
||||
|
|
@ -538,9 +613,15 @@ runDelete env user root rel op = do
|
|||
AdmitRejected reason _ -> pure (Left reason)
|
||||
AdmitReplay MutationPending _ -> pure (Right MutationPending)
|
||||
AdmitReplay result _ -> pure (Right result)
|
||||
AdmitPrepared rec -> dispatchAndFinalize env user root rec $ do
|
||||
_ <- deleteObject (envS3 env) key
|
||||
pure ()
|
||||
AdmitPrepared rec ->
|
||||
dispatchAndFinalize
|
||||
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 user root prefix = do
|
||||
|
|
@ -596,9 +677,10 @@ dispatchAndFinalize
|
|||
-> SessionUser
|
||||
-> Root
|
||||
-> MutationRecord
|
||||
-> IO ()
|
||||
-> IO (Either Text MutationResult)
|
||||
dispatchAndFinalize env user root rec dispatch = do
|
||||
-> failure
|
||||
-> IO (Either failure ())
|
||||
-> IO (Either failure MutationResult)
|
||||
dispatchAndFinalize env user root rec settlementFailure dispatch = do
|
||||
prepared <- withTransaction (envDb env) $ \conn -> do
|
||||
moved <- markApplying conn rec
|
||||
if not moved
|
||||
|
|
@ -607,7 +689,7 @@ dispatchAndFinalize env user root rec dispatch = do
|
|||
if not prepared
|
||||
then pure (Right MutationPending)
|
||||
else do
|
||||
_ <- try dispatch :: IO (Either SomeException ())
|
||||
dispatchResult <- dispatch
|
||||
observed <- observeObject (envS3 env) (mrObjectKey rec)
|
||||
finalized <- withTransaction (envDb env) $ \conn -> do
|
||||
settled <- markExternalSettled conn rec
|
||||
|
|
@ -620,23 +702,17 @@ dispatchAndFinalize env user root rec dispatch = do
|
|||
(listCacheAncestorPrefixes (mrRelativePath rec))
|
||||
pure (Right MutationSucceeded)
|
||||
Just MutationFailed ->
|
||||
pure (Left "mutation failed after store settlement")
|
||||
pure (Left (either id (const settlementFailure) dispatchResult))
|
||||
Just other -> pure (Right other)
|
||||
Nothing -> pure (Right MutationPending)
|
||||
|
||||
observeObject :: S3 -> ObjectKey -> IO ObjectObservation
|
||||
observeObject s3 key = do
|
||||
result <- headObject s3 key
|
||||
result <- observeObjectFully s3 key
|
||||
case result of
|
||||
Right (Just info@ObjectInfo { oiSha256 = Just _ }) ->
|
||||
Right info ->
|
||||
pure (ObjectPresent (oiSize info) (oiSha256 info))
|
||||
Right (Just _) -> do
|
||||
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 S3NotFound -> pure ObjectAbsent
|
||||
Left err -> pure (ObjectUnavailable (renderS3Error err))
|
||||
|
||||
listEntries :: Env -> SessionUser -> Root -> Text -> IO (Either Text [ListEntry])
|
||||
|
|
@ -781,10 +857,6 @@ recoverOne env rec =
|
|||
pure ()
|
||||
_ -> pure ()
|
||||
|
||||
contentSha256 :: BL.ByteString -> ContentSha256
|
||||
contentSha256 body =
|
||||
ContentSha256 . TE.decodeUtf8 . B16.encode . BA.convert $
|
||||
(hashlazy body :: Digest SHA256)
|
||||
|
||||
lastSeg :: Text -> Text
|
||||
lastSeg t =
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import Data.Maybe (fromMaybe)
|
|||
import Data.Text (Text)
|
||||
import qualified Data.Text as T
|
||||
import qualified Data.Text.Encoding as TE
|
||||
import Nuthatch.Streaming (maximumMultipartBytes)
|
||||
import System.Environment (lookupEnv)
|
||||
import Text.Read (readMaybe)
|
||||
|
||||
|
|
@ -102,6 +103,12 @@ loadConfig = do
|
|||
maxObject <- envInteger "NUTHATCH_MAX_OBJECT_BYTES" (100 * 1024 * 1024)
|
||||
throttleRate <- envInteger "NUTHATCH_THROTTLE_BYTES_PER_SEC" (10 * 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
|
||||
searchMax <- fromInteger <$> envInteger "NUTHATCH_SEARCH_MAX_KEYS" 500
|
||||
recoveryGrace <- envInteger "NUTHATCH_RECOVERY_GRACE_SEC" 90
|
||||
|
|
@ -143,6 +150,9 @@ loadConfig = do
|
|||
, cfgRecoveryGraceSec = recoveryGrace
|
||||
}
|
||||
|
||||
luaExactIntegerMax :: Integer
|
||||
luaExactIntegerMax = 9007199254740991
|
||||
|
||||
splitHostPort :: String -> (String, String)
|
||||
splitHostPort s =
|
||||
case break (== ':') s of
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ module Nuthatch.Redis
|
|||
, cacheGet
|
||||
, cacheSet
|
||||
, cacheInvalidatePrefixes
|
||||
, throttleCheck
|
||||
, throttleTake
|
||||
) where
|
||||
|
||||
|
|
@ -13,7 +14,6 @@ import qualified Data.ByteString.Char8 as BS8
|
|||
import Data.Text (Text)
|
||||
import qualified Data.Text as T
|
||||
import qualified Data.Text.Encoding as TE
|
||||
import Data.Time.Clock.POSIX (getPOSIXTime)
|
||||
import Database.Redis
|
||||
( ConnectInfo (..)
|
||||
, Connection
|
||||
|
|
@ -69,9 +69,21 @@ cacheInvalidatePrefixes conn uid root prefixes = do
|
|||
:: IO (Either SomeException (Either Reply Integer))
|
||||
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 conn cfg uid bytes = do
|
||||
now <- floor <$> getPOSIXTime
|
||||
throttleTake conn cfg uid bytes = throttleRequest conn cfg uid bytes True
|
||||
|
||||
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)
|
||||
rate = cfgThrottleBytesPerSec cfg
|
||||
burst = cfgThrottleBurstBytes cfg
|
||||
|
|
@ -80,8 +92,11 @@ throttleTake conn cfg uid bytes = do
|
|||
[ "local key = KEYS[1]"
|
||||
, "local rate = tonumber(ARGV[1])"
|
||||
, "local burst = tonumber(ARGV[2])"
|
||||
, "local now = tonumber(ARGV[3])"
|
||||
, "local need = tonumber(ARGV[4])"
|
||||
, "local need = tonumber(ARGV[3])"
|
||||
, "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 tokens = tonumber(data[1])"
|
||||
, "local ts = tonumber(data[2])"
|
||||
|
|
@ -91,20 +106,20 @@ throttleTake conn cfg uid bytes = do
|
|||
, "tokens = math.min(burst, tokens + delta * rate)"
|
||||
, "if tokens < need then"
|
||||
, " redis.call('HMSET', key, 'tokens', tokens, 'ts', now)"
|
||||
, " redis.call('EXPIRE', key, 3600)"
|
||||
, " redis.call('EXPIRE', key, ttl)"
|
||||
, " return 0"
|
||||
, "end"
|
||||
, "tokens = tokens - need"
|
||||
, "if consume == 1 then tokens = tokens - need end"
|
||||
, "redis.call('HMSET', key, 'tokens', tokens, 'ts', now)"
|
||||
, "redis.call('EXPIRE', key, 3600)"
|
||||
, "redis.call('EXPIRE', key, ttl)"
|
||||
, "return 1"
|
||||
]
|
||||
result <- try (runRedis conn $
|
||||
R.eval script [key]
|
||||
[ BS8.pack (show rate)
|
||||
, BS8.pack (show burst)
|
||||
, BS8.pack (show (now :: Integer))
|
||||
, BS8.pack (show bytes)
|
||||
, if consume then "1" else "0"
|
||||
]) :: IO (Either SomeException (Either Reply Integer))
|
||||
pure $ case result of
|
||||
Left _ -> Left "redis unavailable"
|
||||
|
|
|
|||
|
|
@ -1,12 +1,16 @@
|
|||
module Nuthatch.S3
|
||||
( S3
|
||||
, S3Error (..)
|
||||
, PutObjectError (..)
|
||||
, ObjectInfo (..)
|
||||
, ListPage (..)
|
||||
, newS3
|
||||
, putObject
|
||||
, putObjectStream
|
||||
, getObjectBytes
|
||||
, getObjectSource
|
||||
, headObject
|
||||
, observeObjectFully
|
||||
, deleteObject
|
||||
, listObjects
|
||||
, renderS3Error
|
||||
|
|
@ -16,8 +20,24 @@ import qualified Aws
|
|||
import qualified Aws.Aws as AwsRun
|
||||
import qualified Aws.Core as AwsCore
|
||||
import qualified Aws.S3 as S3
|
||||
import Control.Concurrent.Async (withAsync)
|
||||
import Control.Concurrent.STM
|
||||
( atomically
|
||||
, newTBQueueIO
|
||||
, readTBQueue
|
||||
, writeTBQueue
|
||||
)
|
||||
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 Crypto.Hash
|
||||
( Context
|
||||
|
|
@ -32,6 +52,7 @@ import qualified Data.ByteString.Char8 as BS8
|
|||
import qualified Data.ByteString.Lazy as BL
|
||||
import qualified Data.Conduit as C
|
||||
import qualified Data.Conduit.List as CL
|
||||
import Data.IORef
|
||||
import Data.Text (Text)
|
||||
import qualified Data.Text as T
|
||||
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.URI (URI (..), URIAuth (..), parseURI)
|
||||
import Nuthatch.Config (Config (..))
|
||||
import Nuthatch.Streaming
|
||||
( BandwidthError (..)
|
||||
, UploadStreamError (..)
|
||||
, consumeUploadParts
|
||||
, maximumMultipartBytes
|
||||
, multipartPartBytes
|
||||
)
|
||||
import Nuthatch.Types
|
||||
import Servant.Types.SourceT
|
||||
( SourceT (..)
|
||||
, StepT (..)
|
||||
)
|
||||
import Text.Read (readMaybe)
|
||||
|
||||
data S3 = S3
|
||||
|
|
@ -57,6 +89,12 @@ data S3Error
|
|||
| S3ResponseError
|
||||
deriving stock (Eq, Show)
|
||||
|
||||
data PutObjectError
|
||||
= PutObjectStoreError S3Error
|
||||
| PutObjectStreamError UploadStreamError
|
||||
| PutObjectBandwidthError BandwidthError
|
||||
deriving stock (Eq, Show)
|
||||
|
||||
data ObjectInfo = ObjectInfo
|
||||
{ oiSize :: Integer
|
||||
, oiSha256 :: Maybe ContentSha256
|
||||
|
|
@ -127,6 +165,110 @@ newS3 cfg = do
|
|||
, 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
|
||||
:: S3
|
||||
-> ObjectKey
|
||||
|
|
@ -154,6 +296,61 @@ getObjectBytes store (ObjectKey key) = do
|
|||
result <- getObjectMemory store key
|
||||
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 store (ObjectKey key) = do
|
||||
result <- try (runMemory store (S3.headObject (s3Bucket store) key))
|
||||
|
|
@ -162,9 +359,37 @@ headObject store (ObjectKey key) = do
|
|||
Left err -> pure (Left (classifyException err))
|
||||
Right (S3.HeadObjectMemoryResponse Nothing) -> pure (Right Nothing)
|
||||
Right (S3.HeadObjectMemoryResponse (Just _)) -> do
|
||||
object <- observeObjectStream store key
|
||||
object <- getObjectHeaders store key
|
||||
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 store (ObjectKey key) = do
|
||||
result <- try (runMemory store (S3.DeleteObject key (s3Bucket store)))
|
||||
|
|
@ -259,30 +484,18 @@ observeObjectStream store key = do
|
|||
(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
|
||||
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)
|
||||
}
|
||||
let httpResponse = S3.gorResponse response
|
||||
(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 =
|
||||
(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 =
|
||||
|
|
@ -311,6 +524,15 @@ classifyException exception =
|
|||
Just (HTTP.InvalidUrlException _ _) -> 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 [] = Nothing
|
||||
lastMaybe values = Just (last values)
|
||||
|
|
|
|||
124
src/Nuthatch/Streaming.hs
Normal file
124
src/Nuthatch/Streaming.hs
Normal 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
|
||||
70
test/Spec.hs
70
test/Spec.hs
|
|
@ -1,5 +1,6 @@
|
|||
module Main (main) where
|
||||
|
||||
import Control.Exception (try)
|
||||
import Control.Monad.IO.Class (liftIO)
|
||||
import Control.Lens ((&), (?~))
|
||||
import Crypto.JOSE (runJOSE)
|
||||
|
|
@ -12,6 +13,7 @@ import Crypto.JOSE.JWK
|
|||
, genJWK
|
||||
)
|
||||
import Crypto.JOSE.JWS (newJWSHeaderProtected)
|
||||
import Crypto.Hash (Digest, SHA256, hash)
|
||||
import Crypto.JWT
|
||||
( Audience (..)
|
||||
, ClaimsSet
|
||||
|
|
@ -23,7 +25,9 @@ import Crypto.JWT
|
|||
)
|
||||
import qualified Data.Aeson as A
|
||||
import qualified Data.Aeson.KeyMap as KM
|
||||
import qualified Data.ByteArray as BA
|
||||
import qualified Data.ByteString as BS
|
||||
import qualified Data.ByteString.Base16 as B16
|
||||
import qualified Data.ByteString.Lazy as BL
|
||||
import Data.Either (isLeft, isRight)
|
||||
import Data.IORef (IORef, modifyIORef', newIORef, readIORef, writeIORef)
|
||||
|
|
@ -87,10 +91,21 @@ import Nuthatch.Protocol
|
|||
)
|
||||
import Nuthatch.Types
|
||||
import Nuthatch.Session (mintSession)
|
||||
import Nuthatch.Streaming
|
||||
( UploadStreamError (..)
|
||||
, consumeUploadParts
|
||||
, maximumMultipartBytes
|
||||
, multipartPartBytes
|
||||
)
|
||||
import Nuthatch.Ui (browsePage, browserScript)
|
||||
import Servant
|
||||
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.HUnit ((@?=), assertBool, testCase)
|
||||
import Test.Tasty.Hedgehog (testProperty)
|
||||
|
|
@ -103,10 +118,61 @@ tests = testGroup "nuthatch-unit"
|
|||
[ pathTests
|
||||
, identityTests
|
||||
, protocolTests
|
||||
, streamingTests
|
||||
, oidcTests
|
||||
, 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 = testGroup "canonical paths"
|
||||
[ testProperty "valid paths parse and render without rewriting" propPathRoundTrip
|
||||
|
|
@ -604,7 +670,7 @@ typedTestServer calls =
|
|||
}))
|
||||
:<|> (\_ _ _ -> counted
|
||||
(addHeader "attachment; filename=\"file.txt\""
|
||||
(addHeader "nosniff" (source ["body"]))))
|
||||
(addHeader "nosniff" (addHeader 4 (source ["body"])))))
|
||||
:<|> (\_ _ _ operation _ _ _ _ -> counted UploadResponse
|
||||
{ uploadResponseOperationId = operation
|
||||
, uploadResponseResult = MutationSucceeded
|
||||
|
|
|
|||
|
|
@ -362,6 +362,374 @@ spec:
|
|||
exit 1
|
||||
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() {
|
||||
root="$1"
|
||||
suffix="$2"
|
||||
|
|
@ -563,6 +931,30 @@ spec:
|
|||
configMapKeyRef:
|
||||
name: @@PROFILE_NAME@@
|
||||
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
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
|
|
|
|||
217
tests/kind/wp07-run.sh
Executable file
217
tests/kind/wp07-run.sh
Executable 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'
|
||||
Loading…
Reference in a new issue