Harden mutation admission and storage observation
This commit is contained in:
parent
435d4fea4c
commit
1b1d8b06c7
13 changed files with 969 additions and 461 deletions
17
Dockerfile
17
Dockerfile
|
|
@ -19,11 +19,12 @@ COPY app ./app
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
COPY test ./test
|
COPY test ./test
|
||||||
|
|
||||||
RUN cabal build exe:nuthatch \
|
RUN cabal build exe:nuthatch exe:nuthatch-wp04-integration \
|
||||||
&& mkdir -p /out \
|
&& mkdir -p /out \
|
||||||
&& cp "$(cabal list-bin exe:nuthatch)" /out/nuthatch
|
&& cp "$(cabal list-bin exe:nuthatch)" /out/nuthatch \
|
||||||
|
&& cp "$(cabal list-bin exe:nuthatch-wp04-integration)" /out/nuthatch-wp04-integration
|
||||||
|
|
||||||
FROM debian:bookworm-slim AS runtime
|
FROM debian:bookworm-slim AS runtime-base
|
||||||
|
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends \
|
&& apt-get install -y --no-install-recommends \
|
||||||
|
|
@ -33,7 +34,6 @@ RUN apt-get update \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=build /out/nuthatch /usr/local/bin/nuthatch
|
|
||||||
COPY sql ./sql
|
COPY sql ./sql
|
||||||
|
|
||||||
ENV NUTHATCH_LISTEN=0.0.0.0:8080 \
|
ENV NUTHATCH_LISTEN=0.0.0.0:8080 \
|
||||||
|
|
@ -49,5 +49,14 @@ ENV NUTHATCH_LISTEN=0.0.0.0:8080 \
|
||||||
NUTHATCH_S3_PATH_STYLE=true \
|
NUTHATCH_S3_PATH_STYLE=true \
|
||||||
NUTHATCH_AUTH_MODE=dev
|
NUTHATCH_AUTH_MODE=dev
|
||||||
|
|
||||||
|
FROM runtime-base AS test-runtime
|
||||||
|
|
||||||
|
COPY --from=build /out/nuthatch /usr/local/bin/nuthatch
|
||||||
|
COPY --from=build /out/nuthatch-wp04-integration /usr/local/bin/nuthatch-wp04-integration
|
||||||
|
|
||||||
|
FROM runtime-base AS runtime
|
||||||
|
|
||||||
|
COPY --from=build /out/nuthatch /usr/local/bin/nuthatch
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
CMD ["nuthatch"]
|
CMD ["nuthatch"]
|
||||||
|
|
|
||||||
5
Makefile
5
Makefile
|
|
@ -1,4 +1,4 @@
|
||||||
.PHONY: test check-dependencies check-fixture-lib check-kind-matrix check-frontend build-frontend mvp-up mvp-down mvp-build mvp-logs mvp-ps
|
.PHONY: test check-dependencies check-fixture-lib check-kind-matrix check-storage-foundation check-frontend build-frontend mvp-up mvp-down mvp-build mvp-logs mvp-ps
|
||||||
|
|
||||||
COMPOSE := nerdctl compose -f deploy/docker-compose.yml
|
COMPOSE := nerdctl compose -f deploy/docker-compose.yml
|
||||||
|
|
||||||
|
|
@ -14,6 +14,9 @@ check-fixture-lib:
|
||||||
check-kind-matrix:
|
check-kind-matrix:
|
||||||
./tests/kind/matrix-run.sh
|
./tests/kind/matrix-run.sh
|
||||||
|
|
||||||
|
check-storage-foundation:
|
||||||
|
./tests/kind/wp04-run.sh
|
||||||
|
|
||||||
check-frontend:
|
check-frontend:
|
||||||
npm --prefix web run check
|
npm --prefix web run check
|
||||||
|
|
||||||
|
|
|
||||||
12
app/Main.hs
12
app/Main.hs
|
|
@ -15,7 +15,7 @@ import Nuthatch
|
||||||
import Nuthatch.Db (migrate, query_, withDb)
|
import Nuthatch.Db (migrate, query_, withDb)
|
||||||
import Nuthatch.Redis (withRedis)
|
import Nuthatch.Redis (withRedis)
|
||||||
import Nuthatch.S3 (newS3)
|
import Nuthatch.S3 (newS3)
|
||||||
import System.Directory (doesFileExist)
|
import System.Directory (doesDirectoryExist)
|
||||||
import System.FilePath ((</>))
|
import System.FilePath ((</>))
|
||||||
import System.IO (BufferMode (LineBuffering), hSetBuffering, stdout)
|
import System.IO (BufferMode (LineBuffering), hSetBuffering, stdout)
|
||||||
|
|
||||||
|
|
@ -62,14 +62,14 @@ waitForPostgres cfg = go (60 :: Int)
|
||||||
findSchema :: IO FilePath
|
findSchema :: IO FilePath
|
||||||
findSchema = do
|
findSchema = do
|
||||||
let candidates =
|
let candidates =
|
||||||
[ "sql" </> "001_schema.sql"
|
[ "sql"
|
||||||
, ".." </> "sql" </> "001_schema.sql"
|
, ".." </> "sql"
|
||||||
, "/app/sql" </> "001_schema.sql"
|
, "/app/sql"
|
||||||
]
|
]
|
||||||
existing <- mapM (\p -> doesFileExist p >>= \e -> pure (e, p)) candidates
|
existing <- mapM (\p -> doesDirectoryExist p >>= \e -> pure (e, p)) candidates
|
||||||
pure $ case [p | (True, p) <- existing] of
|
pure $ case [p | (True, p) <- existing] of
|
||||||
(p : _) -> p
|
(p : _) -> p
|
||||||
[] -> "sql/001_schema.sql"
|
[] -> "sql"
|
||||||
|
|
||||||
readHost :: String -> HostPreference
|
readHost :: String -> HostPreference
|
||||||
readHost = fromString
|
readHost = fromString
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ category: Network
|
||||||
build-type: Simple
|
build-type: Simple
|
||||||
extra-source-files:
|
extra-source-files:
|
||||||
sql/001_schema.sql
|
sql/001_schema.sql
|
||||||
|
sql/002_pool_limit_guard.sql
|
||||||
|
|
||||||
common shared
|
common shared
|
||||||
default-language: Haskell2010
|
default-language: Haskell2010
|
||||||
|
|
@ -43,12 +44,14 @@ common shared
|
||||||
build-depends:
|
build-depends:
|
||||||
, aeson >= 2.1 && < 2.3
|
, aeson >= 2.1 && < 2.3
|
||||||
, async >= 2.2 && < 2.3
|
, async >= 2.2 && < 2.3
|
||||||
|
, aws >= 0.25.2 && < 0.26
|
||||||
, base >= 4.18 && < 5
|
, base >= 4.18 && < 5
|
||||||
, base16-bytestring >= 1.0 && < 1.1
|
, base16-bytestring >= 1.0 && < 1.1
|
||||||
, base64-bytestring >= 1.2 && < 1.3
|
, base64-bytestring >= 1.2 && < 1.3
|
||||||
, bytestring >= 0.11 && < 0.13
|
, bytestring >= 0.11 && < 0.13
|
||||||
, case-insensitive >= 1.2 && < 1.3
|
, case-insensitive >= 1.2 && < 1.3
|
||||||
, containers >= 0.6 && < 0.8
|
, containers >= 0.6 && < 0.8
|
||||||
|
, conduit >= 1.3 && < 1.4
|
||||||
, crypton >= 0.34 && < 1.1
|
, crypton >= 0.34 && < 1.1
|
||||||
, directory >= 1.3 && < 1.4
|
, directory >= 1.3 && < 1.4
|
||||||
, entropy >= 0.4 && < 0.5
|
, entropy >= 0.4 && < 0.5
|
||||||
|
|
@ -65,7 +68,9 @@ common shared
|
||||||
, mtl >= 2.3 && < 2.4
|
, mtl >= 2.3 && < 2.4
|
||||||
, network-uri >= 2.6 && < 2.8
|
, network-uri >= 2.6 && < 2.8
|
||||||
, postgresql-simple >= 0.7 && < 0.8
|
, postgresql-simple >= 0.7 && < 0.8
|
||||||
|
, postgresql-migration >= 0.2.1.8 && < 0.3
|
||||||
, resource-pool >= 0.4 && < 0.5
|
, resource-pool >= 0.4 && < 0.5
|
||||||
|
, resourcet >= 1.3 && < 1.4
|
||||||
, scientific >= 0.3 && < 0.4
|
, scientific >= 0.3 && < 0.4
|
||||||
, servant >= 0.20 && < 0.21
|
, servant >= 0.20 && < 0.21
|
||||||
, servant-server >= 0.20 && < 0.21
|
, servant-server >= 0.20 && < 0.21
|
||||||
|
|
@ -112,6 +117,21 @@ executable nuthatch
|
||||||
-rtsopts
|
-rtsopts
|
||||||
-with-rtsopts=-N
|
-with-rtsopts=-N
|
||||||
|
|
||||||
|
executable nuthatch-wp04-integration
|
||||||
|
import: shared
|
||||||
|
main-is: Integration.hs
|
||||||
|
hs-source-dirs: test
|
||||||
|
build-depends:
|
||||||
|
, nuthatch
|
||||||
|
, base
|
||||||
|
, async
|
||||||
|
, bytestring
|
||||||
|
, text
|
||||||
|
ghc-options:
|
||||||
|
-threaded
|
||||||
|
-rtsopts
|
||||||
|
-with-rtsopts=-N
|
||||||
|
|
||||||
test-suite nuthatch-unit
|
test-suite nuthatch-unit
|
||||||
import: shared
|
import: shared
|
||||||
type: exitcode-stdio-1.0
|
type: exitcode-stdio-1.0
|
||||||
|
|
|
||||||
56
sql/002_pool_limit_guard.sql
Normal file
56
sql/002_pool_limit_guard.sql
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
ALTER TABLE mutations
|
||||||
|
DROP CONSTRAINT IF EXISTS mutations_kind_check,
|
||||||
|
DROP CONSTRAINT IF EXISTS mutations_declared_size_check,
|
||||||
|
DROP CONSTRAINT IF EXISTS mutations_prior_size_check,
|
||||||
|
DROP CONSTRAINT IF EXISTS mutations_growth_bytes_check,
|
||||||
|
DROP CONSTRAINT IF EXISTS mutations_state_check,
|
||||||
|
DROP CONSTRAINT IF EXISTS mutations_external_state_check;
|
||||||
|
|
||||||
|
ALTER TABLE mutations
|
||||||
|
ADD CONSTRAINT mutations_kind_check
|
||||||
|
CHECK (kind IN ('put', 'delete')),
|
||||||
|
ADD CONSTRAINT mutations_declared_size_check
|
||||||
|
CHECK (declared_size >= 0),
|
||||||
|
ADD CONSTRAINT mutations_prior_size_check
|
||||||
|
CHECK (prior_size >= 0),
|
||||||
|
ADD CONSTRAINT mutations_growth_bytes_check
|
||||||
|
CHECK (growth_bytes >= 0),
|
||||||
|
ADD CONSTRAINT mutations_state_check
|
||||||
|
CHECK (state IN ('prepared', 'applying', 'succeeded', 'failed', 'rejected')),
|
||||||
|
ADD CONSTRAINT mutations_external_state_check
|
||||||
|
CHECK (external_state IN ('idle', 'pending', 'settled'));
|
||||||
|
|
||||||
|
ALTER TABLE key_locks
|
||||||
|
ADD COLUMN IF NOT EXISTS acquired_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
DROP CONSTRAINT IF EXISTS key_locks_user_id_kind_operation_id_fkey;
|
||||||
|
|
||||||
|
ALTER TABLE key_locks
|
||||||
|
ADD CONSTRAINT key_locks_user_id_kind_operation_id_fkey
|
||||||
|
FOREIGN KEY (user_id, kind, operation_id)
|
||||||
|
REFERENCES mutations (user_id, kind, operation_id);
|
||||||
|
|
||||||
|
ALTER TABLE objects_meta
|
||||||
|
DROP CONSTRAINT IF EXISTS objects_meta_size_bytes_check;
|
||||||
|
|
||||||
|
ALTER TABLE objects_meta
|
||||||
|
ADD CONSTRAINT objects_meta_size_bytes_check
|
||||||
|
CHECK (size_bytes >= 0);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS mutations_active_key_idx
|
||||||
|
ON mutations (object_key)
|
||||||
|
WHERE state IN ('prepared', 'applying');
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS mutations_active_idx
|
||||||
|
ON mutations (state)
|
||||||
|
WHERE state IN ('prepared', 'applying');
|
||||||
|
|
||||||
|
UPDATE pools
|
||||||
|
SET limit_bytes = used_bytes + reserved_bytes
|
||||||
|
WHERE used_bytes + reserved_bytes > limit_bytes;
|
||||||
|
|
||||||
|
ALTER TABLE pools
|
||||||
|
DROP CONSTRAINT IF EXISTS pools_live_bytes_within_limit;
|
||||||
|
|
||||||
|
ALTER TABLE pools
|
||||||
|
ADD CONSTRAINT pools_live_bytes_within_limit
|
||||||
|
CHECK (used_bytes + reserved_bytes <= limit_bytes);
|
||||||
|
|
@ -27,7 +27,7 @@ import qualified Data.Text as T
|
||||||
import qualified Data.Text.Encoding as TE
|
import qualified Data.Text.Encoding as TE
|
||||||
import Data.UUID (toText)
|
import Data.UUID (toText)
|
||||||
import Data.UUID.V4 (nextRandom)
|
import Data.UUID.V4 (nextRandom)
|
||||||
import Database.PostgreSQL.Simple (Connection, Only (..))
|
import Database.PostgreSQL.Simple (Only (..))
|
||||||
import Network.HTTP.Types
|
import Network.HTTP.Types
|
||||||
( hContentType
|
( hContentType
|
||||||
, hLocation
|
, hLocation
|
||||||
|
|
@ -53,6 +53,7 @@ import Nuthatch.S3
|
||||||
, headObject
|
, headObject
|
||||||
, listObjects
|
, listObjects
|
||||||
, putObject
|
, putObject
|
||||||
|
, renderS3Error
|
||||||
)
|
)
|
||||||
import Nuthatch.Session
|
import Nuthatch.Session
|
||||||
import Nuthatch.Types
|
import Nuthatch.Types
|
||||||
|
|
@ -252,7 +253,7 @@ handleDownloadRequest env session root path = do
|
||||||
metadata <- liftIO (headObject (envS3 env) key)
|
metadata <- liftIO (headObject (envS3 env) key)
|
||||||
info <- case metadata of
|
info <- case metadata of
|
||||||
Left message -> throwOperationError status502Error
|
Left message -> throwOperationError status502Error
|
||||||
(DownloadObjectStoreFailed 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 $
|
throttle <- liftIO $
|
||||||
|
|
@ -263,7 +264,7 @@ handleDownloadRequest env session root path = do
|
||||||
Right True -> pure ()
|
Right True -> pure ()
|
||||||
result <- liftIO (getObjectBytes (envS3 env) key)
|
result <- liftIO (getObjectBytes (envS3 env) key)
|
||||||
body <- either
|
body <- either
|
||||||
(throwOperationError status502Error . DownloadObjectStoreFailed)
|
(throwOperationError status502Error . DownloadObjectStoreFailed . renderS3Error)
|
||||||
pure
|
pure
|
||||||
result
|
result
|
||||||
liftIO $ audit "download" "ok" (Just (unUserId (suUserId user)))
|
liftIO $ audit "download" "ok" (Just (unUserId (suUserId user)))
|
||||||
|
|
@ -445,7 +446,7 @@ runPut env user root rel op size sha body = do
|
||||||
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 -> dispatchAndFinalize env user root rec $ do
|
||||||
_ <- putObject (envS3 env) key size sha body
|
_ <- putObject (envS3 env) key size (Just sha) body
|
||||||
pure ()
|
pure ()
|
||||||
|
|
||||||
runDelete
|
runDelete
|
||||||
|
|
@ -516,7 +517,7 @@ collectKeys s3 prefix = go Nothing []
|
||||||
go continuation accumulated = do
|
go continuation accumulated = do
|
||||||
page <- listObjects s3 prefix Nothing continuation 1000
|
page <- listObjects s3 prefix Nothing continuation 1000
|
||||||
case page of
|
case page of
|
||||||
Left message -> pure (Left message)
|
Left message -> pure (Left (renderS3Error message))
|
||||||
Right result -> do
|
Right result -> do
|
||||||
let accumulated' = accumulated <> map fst (lpObjects result)
|
let accumulated' = accumulated <> map fst (lpObjects result)
|
||||||
if lpIsTruncated result
|
if lpIsTruncated result
|
||||||
|
|
@ -559,35 +560,17 @@ dispatchAndFinalize env user root rec dispatch = do
|
||||||
observeObject :: S3 -> ObjectKey -> IO ObjectObservation
|
observeObject :: S3 -> ObjectKey -> IO ObjectObservation
|
||||||
observeObject s3 key = do
|
observeObject s3 key = do
|
||||||
result <- headObject s3 key
|
result <- headObject s3 key
|
||||||
pure $ case result of
|
case result of
|
||||||
Right (Just info) -> ObjectPresent (oiSize info) (oiSha256 info)
|
Right (Just info@ObjectInfo { oiSha256 = Just _ }) ->
|
||||||
Right Nothing -> ObjectAbsent
|
pure (ObjectPresent (oiSize info) (oiSha256 info))
|
||||||
Left err -> ObjectUnavailable err
|
Right (Just _) -> do
|
||||||
|
body <- getObjectBytes s3 key
|
||||||
finalizeFromObservation
|
pure $ case body of
|
||||||
:: Connection
|
Right bytes ->
|
||||||
-> MutationRecord
|
ObjectPresent (toInteger (BL.length bytes)) (Just (contentSha256 bytes))
|
||||||
-> ObjectObservation
|
Left err -> ObjectUnavailable (renderS3Error err)
|
||||||
-> IO (Maybe MutationResult)
|
Right Nothing -> pure ObjectAbsent
|
||||||
finalizeFromObservation conn rec observed =
|
Left err -> pure (ObjectUnavailable (renderS3Error err))
|
||||||
case observed of
|
|
||||||
ObjectUnavailable _ -> pure Nothing
|
|
||||||
_ | desiredMatches rec observed -> do
|
|
||||||
let (finalSize, finalSha) =
|
|
||||||
case mrKind rec of
|
|
||||||
Put ->
|
|
||||||
( mrDeclaredSize rec
|
|
||||||
, mrContentSha256 rec
|
|
||||||
)
|
|
||||||
Delete -> (0, Nothing)
|
|
||||||
ok <- finalizeSuccess conn rec finalSize finalSha
|
|
||||||
pure $ if ok then Just MutationSucceeded else Nothing
|
|
||||||
_ | priorMatches rec observed -> do
|
|
||||||
ok <- finalizeFailure conn rec
|
|
||||||
pure $ if ok then Just MutationFailed else Nothing
|
|
||||||
_ -> do
|
|
||||||
ok <- finalizeFailure conn rec
|
|
||||||
pure $ if ok then Just MutationFailed else Nothing
|
|
||||||
|
|
||||||
listEntries :: Env -> SessionUser -> Root -> Text -> IO (Either Text [ListEntry])
|
listEntries :: Env -> SessionUser -> Root -> Text -> IO (Either Text [ListEntry])
|
||||||
listEntries env user root prefix = do
|
listEntries env user root prefix = do
|
||||||
|
|
@ -613,7 +596,7 @@ listEntries env user root prefix = do
|
||||||
Nothing -> do
|
Nothing -> do
|
||||||
page <- listObjects (envS3 env) s3Prefix (Just "/") Nothing 1000
|
page <- listObjects (envS3 env) s3Prefix (Just "/") Nothing 1000
|
||||||
case page of
|
case page of
|
||||||
Left err -> pure (Left err)
|
Left err -> pure (Left (renderS3Error err))
|
||||||
Right lp -> do
|
Right lp -> do
|
||||||
let rootPrefix =
|
let rootPrefix =
|
||||||
case root of
|
case root of
|
||||||
|
|
@ -680,7 +663,7 @@ collectKeysBounded s3 prefix maxKeys = go Nothing [] 0
|
||||||
| otherwise = do
|
| otherwise = do
|
||||||
page <- listObjects s3 prefix Nothing mTok (min 1000 (maxKeys - n))
|
page <- listObjects s3 prefix Nothing mTok (min 1000 (maxKeys - n))
|
||||||
case page of
|
case page of
|
||||||
Left err -> pure (Left err)
|
Left err -> pure (Left (renderS3Error err))
|
||||||
Right lp -> do
|
Right lp -> do
|
||||||
let acc' = acc <> lpObjects lp
|
let acc' = acc <> lpObjects lp
|
||||||
n' = length acc'
|
n' = length acc'
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ module Nuthatch.Db
|
||||||
, withDb
|
, withDb
|
||||||
, migrate
|
, migrate
|
||||||
, ensurePersonalPool
|
, ensurePersonalPool
|
||||||
|
, configurePoolLimit
|
||||||
, withTransaction
|
, withTransaction
|
||||||
, query
|
, query
|
||||||
, query_
|
, query_
|
||||||
|
|
@ -11,7 +12,7 @@ module Nuthatch.Db
|
||||||
, executeMany
|
, executeMany
|
||||||
) where
|
) where
|
||||||
|
|
||||||
import Control.Exception (bracket)
|
import Control.Exception (bracket, throwIO)
|
||||||
import qualified Data.ByteString.Char8 as BS8
|
import qualified Data.ByteString.Char8 as BS8
|
||||||
import Data.Pool
|
import Data.Pool
|
||||||
( Pool
|
( Pool
|
||||||
|
|
@ -23,11 +24,15 @@ import Data.Pool
|
||||||
)
|
)
|
||||||
import Database.PostgreSQL.Simple (Connection, close, connectPostgreSQL)
|
import Database.PostgreSQL.Simple (Connection, close, connectPostgreSQL)
|
||||||
import qualified Database.PostgreSQL.Simple as PG
|
import qualified Database.PostgreSQL.Simple as PG
|
||||||
|
import Database.PostgreSQL.Simple.Migration
|
||||||
|
( MigrationCommand (..)
|
||||||
|
, MigrationResult (..)
|
||||||
|
, defaultOptions
|
||||||
|
, runMigration
|
||||||
|
)
|
||||||
import qualified Database.PostgreSQL.Simple.Transaction as PGT
|
import qualified Database.PostgreSQL.Simple.Transaction as PGT
|
||||||
import Database.PostgreSQL.Simple.Types (Query (..))
|
|
||||||
import Nuthatch.Config (Config (..))
|
import Nuthatch.Config (Config (..))
|
||||||
import Nuthatch.Types
|
import Nuthatch.Types
|
||||||
import System.Directory (doesFileExist)
|
|
||||||
|
|
||||||
type Db = Pool Connection
|
type Db = Pool Connection
|
||||||
|
|
||||||
|
|
@ -40,43 +45,39 @@ withDb cfg action = do
|
||||||
bracket (newPool poolCfg) destroyAllResources action
|
bracket (newPool poolCfg) destroyAllResources action
|
||||||
|
|
||||||
migrate :: Db -> FilePath -> Config -> IO ()
|
migrate :: Db -> FilePath -> Config -> IO ()
|
||||||
migrate db schemaPath cfg = withResource db $ \conn -> do
|
migrate db migrationDirectory cfg = withResource db $ \conn -> do
|
||||||
exists <- doesFileExist schemaPath
|
result <- runMigration conn defaultOptions $
|
||||||
sql <- if exists then readFile schemaPath else pure builtinSchema
|
MigrationInitialization
|
||||||
mapM_ (runStatement conn) (splitStatements sql)
|
<> MigrationDirectory migrationDirectory
|
||||||
_ <- PG.execute conn
|
<> MigrationValidation (MigrationDirectory migrationDirectory)
|
||||||
"INSERT INTO pools (pool_id, limit_bytes, used_bytes, reserved_bytes) \
|
case result of
|
||||||
\VALUES (?, ?, 0, 0) ON CONFLICT (pool_id) DO UPDATE SET limit_bytes = EXCLUDED.limit_bytes"
|
MigrationSuccess ->
|
||||||
(unPoolId sharedPool, cfgSharedQuotaBytes cfg)
|
PGT.withTransaction conn $
|
||||||
pure ()
|
configurePoolLimitConnection conn sharedPool (cfgSharedQuotaBytes cfg)
|
||||||
|
MigrationError name -> throwIO (userError ("migration failed: " <> name))
|
||||||
runStatement :: Connection -> String -> IO ()
|
|
||||||
runStatement conn stmt =
|
|
||||||
let trimmed = dropWhile (`elem` (" \t\r\n" :: String)) stmt
|
|
||||||
in if null trimmed
|
|
||||||
then pure ()
|
|
||||||
else do
|
|
||||||
_ <- PG.execute_ conn (Query (BS8.pack trimmed))
|
|
||||||
pure ()
|
|
||||||
|
|
||||||
splitStatements :: String -> [String]
|
|
||||||
splitStatements = filter (not . null . trim) . splitOn ';'
|
|
||||||
where
|
|
||||||
trim = dropWhile (`elem` (" \t\r\n" :: String))
|
|
||||||
splitOn _ [] = []
|
|
||||||
splitOn c xs =
|
|
||||||
let (h, t) = break (== c) xs
|
|
||||||
in h : case t of
|
|
||||||
[] -> []
|
|
||||||
(_:rest) -> splitOn c rest
|
|
||||||
|
|
||||||
ensurePersonalPool :: Db -> Config -> UserId -> IO ()
|
ensurePersonalPool :: Db -> Config -> UserId -> IO ()
|
||||||
ensurePersonalPool db cfg uid = withResource db $ \conn -> do
|
ensurePersonalPool db cfg uid =
|
||||||
|
configurePoolLimit db (personalPool uid) (cfgPersonalQuotaBytes cfg)
|
||||||
|
|
||||||
|
configurePoolLimit :: Db -> PoolId -> Integer -> IO ()
|
||||||
|
configurePoolLimit db pool limitBytes =
|
||||||
|
withResource db $ \conn ->
|
||||||
|
PGT.withTransaction conn (configurePoolLimitConnection conn pool limitBytes)
|
||||||
|
|
||||||
|
configurePoolLimitConnection :: Connection -> PoolId -> Integer -> IO ()
|
||||||
|
configurePoolLimitConnection conn pool limitBytes = do
|
||||||
_ <- PG.execute conn
|
_ <- PG.execute conn
|
||||||
"INSERT INTO pools (pool_id, limit_bytes, used_bytes, reserved_bytes) \
|
"INSERT INTO pools (pool_id, limit_bytes, used_bytes, reserved_bytes) \
|
||||||
\VALUES (?, ?, 0, 0) ON CONFLICT (pool_id) DO NOTHING"
|
\VALUES (?, ?, 0, 0) ON CONFLICT (pool_id) DO NOTHING"
|
||||||
(unPoolId (personalPool uid), cfgPersonalQuotaBytes cfg)
|
(unPoolId pool, limitBytes)
|
||||||
pure ()
|
changed <- PG.execute conn
|
||||||
|
"UPDATE pools SET limit_bytes = ? \
|
||||||
|
\WHERE pool_id = ? AND used_bytes + reserved_bytes <= ?"
|
||||||
|
(limitBytes, unPoolId pool, limitBytes)
|
||||||
|
if changed == 1
|
||||||
|
then pure ()
|
||||||
|
else throwIO (userError "pool limit is below live usage and reservations")
|
||||||
|
|
||||||
query :: (PG.ToRow q, PG.FromRow r) => Db -> PG.Query -> q -> IO [r]
|
query :: (PG.ToRow q, PG.FromRow r) => Db -> PG.Query -> q -> IO [r]
|
||||||
query db q params = withResource db $ \conn -> PG.query conn q params
|
query db q params = withResource db $ \conn -> PG.query conn q params
|
||||||
|
|
@ -96,45 +97,3 @@ executeMany db q rows = withResource db $ \conn -> fromIntegral <$> PG.executeMa
|
||||||
withTransaction :: Db -> (Connection -> IO a) -> IO a
|
withTransaction :: Db -> (Connection -> IO a) -> IO a
|
||||||
withTransaction db action =
|
withTransaction db action =
|
||||||
withResource db $ \conn -> PGT.withTransaction conn (action conn)
|
withResource db $ \conn -> PGT.withTransaction conn (action conn)
|
||||||
|
|
||||||
builtinSchema :: String
|
|
||||||
builtinSchema =
|
|
||||||
unlines
|
|
||||||
[ "CREATE TABLE IF NOT EXISTS pools ("
|
|
||||||
, " pool_id TEXT PRIMARY KEY,"
|
|
||||||
, " limit_bytes BIGINT NOT NULL CHECK (limit_bytes > 0),"
|
|
||||||
, " used_bytes BIGINT NOT NULL DEFAULT 0 CHECK (used_bytes >= 0),"
|
|
||||||
, " reserved_bytes BIGINT NOT NULL DEFAULT 0 CHECK (reserved_bytes >= 0)"
|
|
||||||
, ");"
|
|
||||||
, "CREATE TABLE IF NOT EXISTS mutations ("
|
|
||||||
, " user_id TEXT NOT NULL,"
|
|
||||||
, " kind TEXT NOT NULL,"
|
|
||||||
, " operation_id TEXT NOT NULL,"
|
|
||||||
, " pool_id TEXT NOT NULL REFERENCES pools (pool_id),"
|
|
||||||
, " object_key TEXT NOT NULL,"
|
|
||||||
, " relative_path TEXT NOT NULL,"
|
|
||||||
, " declared_size BIGINT NOT NULL,"
|
|
||||||
, " content_sha256 TEXT,"
|
|
||||||
, " prior_size BIGINT NOT NULL,"
|
|
||||||
, " prior_sha256 TEXT,"
|
|
||||||
, " growth_bytes BIGINT NOT NULL,"
|
|
||||||
, " state TEXT NOT NULL,"
|
|
||||||
, " external_state TEXT NOT NULL DEFAULT 'idle',"
|
|
||||||
, " created_at TIMESTAMPTZ NOT NULL DEFAULT now(),"
|
|
||||||
, " updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),"
|
|
||||||
, " PRIMARY KEY (user_id, kind, operation_id)"
|
|
||||||
, ");"
|
|
||||||
, "CREATE TABLE IF NOT EXISTS key_locks ("
|
|
||||||
, " object_key TEXT PRIMARY KEY,"
|
|
||||||
, " user_id TEXT NOT NULL,"
|
|
||||||
, " kind TEXT NOT NULL,"
|
|
||||||
, " operation_id TEXT NOT NULL"
|
|
||||||
, ");"
|
|
||||||
, "CREATE TABLE IF NOT EXISTS objects_meta ("
|
|
||||||
, " object_key TEXT PRIMARY KEY,"
|
|
||||||
, " pool_id TEXT NOT NULL REFERENCES pools (pool_id),"
|
|
||||||
, " size_bytes BIGINT NOT NULL,"
|
|
||||||
, " content_sha256 TEXT NOT NULL,"
|
|
||||||
, " updated_at TIMESTAMPTZ NOT NULL DEFAULT now()"
|
|
||||||
, ");"
|
|
||||||
]
|
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ module Nuthatch.Protocol
|
||||||
, recoveryEligible
|
, recoveryEligible
|
||||||
, desiredMatches
|
, desiredMatches
|
||||||
, priorMatches
|
, priorMatches
|
||||||
|
, finalizeFromObservation
|
||||||
) where
|
) where
|
||||||
|
|
||||||
import Data.Maybe (listToMaybe, mapMaybe)
|
import Data.Maybe (listToMaybe, mapMaybe)
|
||||||
|
|
@ -138,6 +139,11 @@ admitMutation
|
||||||
-> Integer
|
-> Integer
|
||||||
-> IO AdmitOutcome
|
-> IO AdmitOutcome
|
||||||
admitMutation conn uid kind op pool key rel size msha maxObject = do
|
admitMutation conn uid kind op pool key rel size msha maxObject = do
|
||||||
|
let identity =
|
||||||
|
unUserId uid <> "\NUL" <> kindLabel kind <> "\NUL" <> unOperationId op
|
||||||
|
_ <- query conn
|
||||||
|
"SELECT pg_advisory_xact_lock(hashtextextended(?, 0))"
|
||||||
|
(Only identity) :: IO [Only ()]
|
||||||
existing <- lookupMutation conn uid kind op
|
existing <- lookupMutation conn uid kind op
|
||||||
case existing of
|
case existing of
|
||||||
Just rec
|
Just rec
|
||||||
|
|
@ -169,11 +175,13 @@ admitMutation conn uid kind op pool key rel size msha maxObject = do
|
||||||
, mrExternal = ExtIdle
|
, mrExternal = ExtIdle
|
||||||
}
|
}
|
||||||
|
|
||||||
_ <- query conn
|
poolRows <- query conn
|
||||||
"SELECT 1 FROM pools WHERE pool_id = ? FOR UPDATE"
|
"SELECT 1 FROM pools WHERE pool_id = ? FOR UPDATE"
|
||||||
(Only (unPoolId pool)) :: IO [Only Int]
|
(Only (unPoolId pool)) :: IO [Only Int]
|
||||||
|
|
||||||
if kind == Put && (size <= 0 || size > maxObject)
|
if null poolRows
|
||||||
|
then pure AdmitConflict
|
||||||
|
else if kind == Put && (size <= 0 || size > maxObject)
|
||||||
then do
|
then do
|
||||||
rec <- insertTerminalRejected conn uid kind op pool key rel size msha priorSize priorSha
|
rec <- insertTerminalRejected conn uid kind op pool key rel size msha priorSize priorSha
|
||||||
pure (AdmitRejected "invalid object size" rec)
|
pure (AdmitRejected "invalid object size" rec)
|
||||||
|
|
@ -458,3 +466,26 @@ priorMatches rec obs =
|
||||||
(Just _, Nothing) -> False
|
(Just _, Nothing) -> False
|
||||||
(Nothing, Just _) -> mrPriorSize rec == sz
|
(Nothing, Just _) -> mrPriorSize rec == sz
|
||||||
)
|
)
|
||||||
|
|
||||||
|
finalizeFromObservation
|
||||||
|
:: Connection
|
||||||
|
-> MutationRecord
|
||||||
|
-> ObjectObservation
|
||||||
|
-> IO (Maybe MutationResult)
|
||||||
|
finalizeFromObservation conn rec observed =
|
||||||
|
case observed of
|
||||||
|
ObjectUnavailable _ -> pure Nothing
|
||||||
|
_ | desiredMatches rec observed -> do
|
||||||
|
let (finalSize, finalSha) =
|
||||||
|
case mrKind rec of
|
||||||
|
Put ->
|
||||||
|
( mrDeclaredSize rec
|
||||||
|
, mrContentSha256 rec
|
||||||
|
)
|
||||||
|
Delete -> (0, Nothing)
|
||||||
|
ok <- finalizeSuccess conn rec finalSize finalSha
|
||||||
|
pure $ if ok then Just MutationSucceeded else Nothing
|
||||||
|
_ | priorMatches rec observed -> do
|
||||||
|
ok <- finalizeFailure conn rec
|
||||||
|
pure $ if ok then Just MutationFailed else Nothing
|
||||||
|
_ -> pure Nothing
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
module Nuthatch.S3
|
module Nuthatch.S3
|
||||||
( S3
|
( S3
|
||||||
|
, S3Error (..)
|
||||||
, ObjectInfo (..)
|
, ObjectInfo (..)
|
||||||
, ListPage (..)
|
, ListPage (..)
|
||||||
, newS3
|
, newS3
|
||||||
|
|
@ -8,57 +9,59 @@ module Nuthatch.S3
|
||||||
, headObject
|
, headObject
|
||||||
, deleteObject
|
, deleteObject
|
||||||
, listObjects
|
, listObjects
|
||||||
|
, renderS3Error
|
||||||
) where
|
) where
|
||||||
|
|
||||||
import Crypto.Hash (Digest, SHA256, hash, hashlazy)
|
import qualified Aws
|
||||||
import Crypto.MAC.HMAC (HMAC, hmac)
|
import qualified Aws.Aws as AwsRun
|
||||||
import Data.ByteArray (convert)
|
import qualified Aws.Core as AwsCore
|
||||||
import qualified Data.ByteString as BS
|
import qualified Aws.S3 as S3
|
||||||
|
import Control.Applicative ((<|>))
|
||||||
|
import Control.Exception (SomeException, fromException, try)
|
||||||
|
import Control.Monad.Trans.Resource (runResourceT)
|
||||||
|
import Crypto.Hash
|
||||||
|
( Context
|
||||||
|
, SHA256
|
||||||
|
, hashFinalize
|
||||||
|
, hashInit
|
||||||
|
, hashUpdate
|
||||||
|
)
|
||||||
|
import qualified Data.ByteArray as BA
|
||||||
import qualified Data.ByteString.Base16 as B16
|
import qualified Data.ByteString.Base16 as B16
|
||||||
import qualified Data.ByteString.Char8 as BS8
|
import qualified Data.ByteString.Char8 as BS8
|
||||||
import qualified Data.ByteString.Lazy as BL
|
import qualified Data.ByteString.Lazy as BL
|
||||||
import Data.CaseInsensitive (mk, original)
|
import qualified Data.Conduit as C
|
||||||
import Data.Char (toLower)
|
import qualified Data.Conduit.List as CL
|
||||||
import Data.List (sort)
|
|
||||||
import Data.Maybe (fromMaybe, listToMaybe, mapMaybe)
|
|
||||||
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 (UTCTime, getCurrentTime)
|
import qualified Network.HTTP.Client as HTTP
|
||||||
import Data.Time.Format (defaultTimeLocale, formatTime)
|
|
||||||
import Network.HTTP.Client
|
|
||||||
( Manager
|
|
||||||
, Request (..)
|
|
||||||
, RequestBody (..)
|
|
||||||
, Response (..)
|
|
||||||
, httpLbs
|
|
||||||
, newManager
|
|
||||||
, parseRequest
|
|
||||||
, responseTimeoutNone
|
|
||||||
, setQueryString
|
|
||||||
)
|
|
||||||
import Network.HTTP.Client.TLS (tlsManagerSettings)
|
import Network.HTTP.Client.TLS (tlsManagerSettings)
|
||||||
import Network.HTTP.Types (Header, RequestHeaders, statusCode)
|
import Network.HTTP.Types.Status (statusCode)
|
||||||
import Network.HTTP.Types.URI (parseQuery)
|
import Network.URI (URI (..), URIAuth (..), parseURI)
|
||||||
import Nuthatch.Config (Config (..))
|
import Nuthatch.Config (Config (..))
|
||||||
import Nuthatch.Types
|
import Nuthatch.Types
|
||||||
import qualified Text.XML as XML
|
import Text.Read (readMaybe)
|
||||||
|
|
||||||
data S3 = S3
|
data S3 = S3
|
||||||
{ s3Manager :: Manager
|
{ s3Configuration :: Aws.Configuration
|
||||||
, s3Endpoint :: Text
|
, s3Service :: S3.S3Configuration Aws.NormalQuery
|
||||||
, s3Region :: Text
|
, s3Manager :: HTTP.Manager
|
||||||
, s3Bucket :: Text
|
, s3Bucket :: Text
|
||||||
, s3AccessKey :: Text
|
|
||||||
, s3SecretKey :: Text
|
|
||||||
, s3PathStyle :: Bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data S3Error
|
||||||
|
= S3NotFound
|
||||||
|
| S3ServiceError Int
|
||||||
|
| S3TransportError
|
||||||
|
| S3ResponseError
|
||||||
|
deriving stock (Eq, Show)
|
||||||
|
|
||||||
data ObjectInfo = ObjectInfo
|
data ObjectInfo = ObjectInfo
|
||||||
{ oiSize :: Integer
|
{ oiSize :: Integer
|
||||||
, oiSha256 :: Maybe ContentSha256
|
, oiSha256 :: Maybe ContentSha256
|
||||||
}
|
}
|
||||||
deriving stock (Show)
|
deriving stock (Eq, Show)
|
||||||
|
|
||||||
data ListPage = ListPage
|
data ListPage = ListPage
|
||||||
{ lpPrefixes :: [Text]
|
{ lpPrefixes :: [Text]
|
||||||
|
|
@ -66,86 +69,112 @@ data ListPage = ListPage
|
||||||
, lpContinuationToken :: Maybe Text
|
, lpContinuationToken :: Maybe Text
|
||||||
, lpIsTruncated :: Bool
|
, lpIsTruncated :: Bool
|
||||||
}
|
}
|
||||||
deriving stock (Show)
|
deriving stock (Eq, Show)
|
||||||
|
|
||||||
newS3 :: Config -> IO S3
|
newS3 :: Config -> IO S3
|
||||||
newS3 cfg = do
|
newS3 cfg = do
|
||||||
mgr <- newManager tlsManagerSettings
|
(protocol, endpointHost, endpointPort) <- parseEndpoint (cfgS3Endpoint cfg)
|
||||||
|
credentials <- Aws.makeCredentials
|
||||||
|
(TE.encodeUtf8 (cfgS3AccessKey cfg))
|
||||||
|
(TE.encodeUtf8 (cfgS3SecretKey cfg))
|
||||||
|
let defaultPort = case protocol of
|
||||||
|
AwsCore.HTTP -> 80
|
||||||
|
AwsCore.HTTPS -> 443
|
||||||
|
signedHost =
|
||||||
|
if endpointPort == defaultPort
|
||||||
|
then endpointHost
|
||||||
|
else endpointHost <> ":" <> BS8.pack (show endpointPort)
|
||||||
|
managerSettings =
|
||||||
|
tlsManagerSettings
|
||||||
|
{ HTTP.managerResponseTimeout =
|
||||||
|
HTTP.responseTimeoutMicro (30 * 1000000)
|
||||||
|
-- The SDK signs the authority verbatim; http-client resolves only its host.
|
||||||
|
, HTTP.managerModifyRequest = \request ->
|
||||||
|
let requestHost = HTTP.host request
|
||||||
|
portSuffix = ":" <> BS8.pack (show endpointPort)
|
||||||
|
(networkHost, authority) =
|
||||||
|
if endpointPort == defaultPort
|
||||||
|
then (requestHost, requestHost)
|
||||||
|
else case BS8.stripSuffix portSuffix requestHost of
|
||||||
|
Just hostWithoutPort -> (hostWithoutPort, requestHost)
|
||||||
|
Nothing -> (requestHost, requestHost <> portSuffix)
|
||||||
|
in pure request
|
||||||
|
{ HTTP.host = networkHost
|
||||||
|
, HTTP.requestHeaders =
|
||||||
|
("Host", authority)
|
||||||
|
: filter ((/= "Host") . fst) (HTTP.requestHeaders request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
manager <- HTTP.newManager managerSettings
|
||||||
|
let configuration = Aws.Configuration
|
||||||
|
{ Aws.timeInfo = Aws.Timestamp
|
||||||
|
, Aws.credentials = credentials
|
||||||
|
, Aws.logger = \_ _ -> pure ()
|
||||||
|
, Aws.proxy = Nothing
|
||||||
|
}
|
||||||
|
requestStyle =
|
||||||
|
if cfgS3PathStyle cfg then S3.PathStyle else S3.BucketStyle
|
||||||
|
service =
|
||||||
|
(S3.s3v4 protocol signedHost False S3.SignWithEffort)
|
||||||
|
{ S3.s3Region = Just (TE.encodeUtf8 (cfgS3Region cfg))
|
||||||
|
, S3.s3RequestStyle = requestStyle
|
||||||
|
, S3.s3Port = endpointPort
|
||||||
|
}
|
||||||
pure S3
|
pure S3
|
||||||
{ s3Manager = mgr
|
{ s3Configuration = configuration
|
||||||
, s3Endpoint = cfgS3Endpoint cfg
|
, s3Service = service
|
||||||
, s3Region = cfgS3Region cfg
|
, s3Manager = manager
|
||||||
, s3Bucket = cfgS3Bucket cfg
|
, s3Bucket = cfgS3Bucket cfg
|
||||||
, s3AccessKey = cfgS3AccessKey cfg
|
|
||||||
, s3SecretKey = cfgS3SecretKey cfg
|
|
||||||
, s3PathStyle = cfgS3PathStyle cfg
|
|
||||||
}
|
}
|
||||||
|
|
||||||
putObject :: S3 -> ObjectKey -> Integer -> ContentSha256 -> BL.ByteString -> IO (Either Text ())
|
putObject
|
||||||
putObject s3 key size sha body
|
:: S3
|
||||||
| toInteger (BL.length body) /= size = pure (Left "body size mismatch")
|
-> ObjectKey
|
||||||
| ContentSha256 (TE.decodeUtf8 (hexSha256Lazy body)) /= sha =
|
-> Integer
|
||||||
pure (Left "content sha256 mismatch")
|
-> Maybe ContentSha256
|
||||||
|
-> BL.ByteString
|
||||||
|
-> IO (Either S3Error ())
|
||||||
|
putObject store (ObjectKey key) size sha body
|
||||||
|
| toInteger (BL.length body) /= size = pure (Left S3ResponseError)
|
||||||
| otherwise = do
|
| otherwise = do
|
||||||
now <- getCurrentTime
|
let request =
|
||||||
req0 <- mkObjectRequest s3 "PUT" key [] now
|
(S3.putObject (s3Bucket store) key (HTTP.RequestBodyLBS body))
|
||||||
let payloadHash = hexSha256Lazy body
|
{ S3.poContentType = Just "application/octet-stream"
|
||||||
req = signRequest s3 now payloadHash req0
|
, S3.poMetadata = maybe []
|
||||||
{ requestBody = RequestBodyLBS body
|
(\identity ->
|
||||||
, requestHeaders =
|
[("nuthatch-sha256", unContentSha256 identity)])
|
||||||
[ (mk "content-length", BS8.pack (show size))
|
sha
|
||||||
, (mk "content-type", "application/octet-stream")
|
|
||||||
, (mk "x-amz-meta-nuthatch-sha256", TE.encodeUtf8 (unContentSha256 sha))
|
|
||||||
]
|
|
||||||
<> requestHeaders req0
|
|
||||||
}
|
}
|
||||||
resp <- httpLbs req (s3Manager s3)
|
result <- try (runMemory store request)
|
||||||
let code = statusCode (responseStatus resp)
|
:: IO (Either SomeException S3.PutObjectResponse)
|
||||||
pure $ if code >= 200 && code < 300
|
pure (either (Left . classifyException) (const (Right ())) result)
|
||||||
then Right ()
|
|
||||||
else Left ("s3 put failed: HTTP " <> T.pack (show code)
|
|
||||||
<> " " <> TE.decodeUtf8 (BL.toStrict (responseBody resp)))
|
|
||||||
|
|
||||||
getObjectBytes :: S3 -> ObjectKey -> IO (Either Text BL.ByteString)
|
getObjectBytes :: S3 -> ObjectKey -> IO (Either S3Error BL.ByteString)
|
||||||
getObjectBytes s3 key = do
|
getObjectBytes store (ObjectKey key) = do
|
||||||
now <- getCurrentTime
|
result <- getObjectMemory store key
|
||||||
req0 <- mkObjectRequest s3 "GET" key [] now
|
pure $ result >>= \(_, body) -> Right body
|
||||||
let req = signRequest s3 now (hexSha256Lazy "") req0
|
|
||||||
resp <- httpLbs req (s3Manager s3)
|
|
||||||
let code = statusCode (responseStatus resp)
|
|
||||||
pure $ if code == 200
|
|
||||||
then Right (responseBody resp)
|
|
||||||
else Left ("s3 get failed: HTTP " <> T.pack (show code))
|
|
||||||
|
|
||||||
headObject :: S3 -> ObjectKey -> IO (Either Text (Maybe ObjectInfo))
|
headObject :: S3 -> ObjectKey -> IO (Either S3Error (Maybe ObjectInfo))
|
||||||
headObject s3 key = do
|
headObject store (ObjectKey key) = do
|
||||||
now <- getCurrentTime
|
result <- try (runMemory store (S3.headObject (s3Bucket store) key))
|
||||||
req0 <- mkObjectRequest s3 "HEAD" key [] now
|
:: IO (Either SomeException S3.HeadObjectMemoryResponse)
|
||||||
let req = signRequest s3 now (hexSha256Lazy "") req0
|
case result of
|
||||||
resp <- httpLbs req (s3Manager s3)
|
Left err -> pure (Left (classifyException err))
|
||||||
let code = statusCode (responseStatus resp)
|
Right (S3.HeadObjectMemoryResponse Nothing) -> pure (Right Nothing)
|
||||||
pure $ case code of
|
Right (S3.HeadObjectMemoryResponse (Just _)) -> do
|
||||||
200 ->
|
object <- observeObjectStream store key
|
||||||
let headers = responseHeaders resp
|
pure (Just <$> object)
|
||||||
size = lookupHeader "content-length" headers >>= readInteger
|
|
||||||
sha = ContentSha256 . TE.decodeUtf8
|
|
||||||
<$> lookupHeader "x-amz-meta-nuthatch-sha256" headers
|
|
||||||
in case size of
|
|
||||||
Just sz -> Right (Just ObjectInfo { oiSize = sz, oiSha256 = sha })
|
|
||||||
Nothing -> Left "s3 head missing content-length"
|
|
||||||
404 -> Right Nothing
|
|
||||||
_ -> Left ("s3 head failed: HTTP " <> T.pack (show code))
|
|
||||||
|
|
||||||
deleteObject :: S3 -> ObjectKey -> IO (Either Text ())
|
deleteObject :: S3 -> ObjectKey -> IO (Either S3Error ())
|
||||||
deleteObject s3 key = do
|
deleteObject store (ObjectKey key) = do
|
||||||
now <- getCurrentTime
|
result <- try (runMemory store (S3.DeleteObject key (s3Bucket store)))
|
||||||
req0 <- mkObjectRequest s3 "DELETE" key [] now
|
:: IO (Either SomeException S3.DeleteObjectResponse)
|
||||||
let req = signRequest s3 now (hexSha256Lazy "") req0
|
pure $ case result of
|
||||||
resp <- httpLbs req (s3Manager s3)
|
Left err ->
|
||||||
let code = statusCode (responseStatus resp)
|
case classifyException err of
|
||||||
pure $ if code == 204 || code == 200 || code == 404
|
S3NotFound -> Right ()
|
||||||
then Right ()
|
other -> Left other
|
||||||
else Left ("s3 delete failed: HTTP " <> T.pack (show code))
|
Right _ -> Right ()
|
||||||
|
|
||||||
listObjects
|
listObjects
|
||||||
:: S3
|
:: S3
|
||||||
|
|
@ -153,249 +182,160 @@ listObjects
|
||||||
-> Maybe Text
|
-> Maybe Text
|
||||||
-> Maybe Text
|
-> Maybe Text
|
||||||
-> Int
|
-> Int
|
||||||
-> IO (Either Text ListPage)
|
-> IO (Either S3Error ListPage)
|
||||||
listObjects s3 prefix delimiter continuation maxKeys = do
|
listObjects store prefix delimiter continuation maxKeys = do
|
||||||
now <- getCurrentTime
|
let request =
|
||||||
let qs =
|
(S3.getBucket (s3Bucket store))
|
||||||
[ ("list-type", Just "2")
|
{ S3.gbPrefix = Just prefix
|
||||||
, ("prefix", Just (TE.encodeUtf8 prefix))
|
, S3.gbDelimiter = delimiter
|
||||||
, ("max-keys", Just (BS8.pack (show maxKeys)))
|
, S3.gbMarker = continuation
|
||||||
]
|
, S3.gbMaxKeys = Just maxKeys
|
||||||
<> maybe [] (\d -> [("delimiter", Just (TE.encodeUtf8 d))]) delimiter
|
}
|
||||||
<> maybe [] (\t -> [("continuation-token", Just (TE.encodeUtf8 t))]) continuation
|
result <- try (runMemory store request)
|
||||||
req0 <- mkBucketRequest s3 "GET" qs now
|
:: IO (Either SomeException S3.GetBucketResponse)
|
||||||
let req = signRequest s3 now (hexSha256Lazy "") req0
|
pure $ case result of
|
||||||
resp <- httpLbs req (s3Manager s3)
|
Left err -> Left (classifyException err)
|
||||||
let code = statusCode (responseStatus resp)
|
Right response ->
|
||||||
body = responseBody resp
|
Right ListPage
|
||||||
if code /= 200
|
{ lpPrefixes = S3.gbrCommonPrefixes response
|
||||||
then pure (Left ("s3 list failed: HTTP " <> T.pack (show code)
|
, lpObjects =
|
||||||
<> " " <> TE.decodeUtf8 (BL.toStrict body)))
|
map (\object -> (S3.objectKey object, S3.objectSize object))
|
||||||
else pure (parseListXml body)
|
(S3.gbrContents response)
|
||||||
|
, lpContinuationToken =
|
||||||
parseListXml :: BL.ByteString -> Either Text ListPage
|
S3.gbrNextMarker response
|
||||||
parseListXml body =
|
<|> fallbackMarker response
|
||||||
case XML.parseLBS XML.def body of
|
, lpIsTruncated = S3.gbrIsTruncated response
|
||||||
Left err -> Left (T.pack (show err))
|
|
||||||
Right (XML.Document _ root _) ->
|
|
||||||
let elems = descendantElements root
|
|
||||||
textUnder localName =
|
|
||||||
[ t
|
|
||||||
| e <- elems
|
|
||||||
, elementLocal e == localName
|
|
||||||
, t <- elementTexts e
|
|
||||||
]
|
|
||||||
objects =
|
|
||||||
mapMaybe objectFrom
|
|
||||||
[ e | e <- elems, elementLocal e == "Contents" ]
|
|
||||||
prefixes =
|
|
||||||
concatMap elementTexts
|
|
||||||
[ e | e <- elems, elementLocal e == "Prefix"
|
|
||||||
-- List Prefix also names the request prefix; common prefixes end with '/'.
|
|
||||||
, any (T.isSuffixOf "/") (elementTexts e)
|
|
||||||
]
|
|
||||||
truncated =
|
|
||||||
case textUnder "IsTruncated" of
|
|
||||||
(t : _) -> T.toLower t == "true"
|
|
||||||
_ -> False
|
|
||||||
token =
|
|
||||||
case textUnder "NextContinuationToken" of
|
|
||||||
(t : _) | not (T.null t) -> Just t
|
|
||||||
_ -> Nothing
|
|
||||||
in Right ListPage
|
|
||||||
{ lpPrefixes = prefixes
|
|
||||||
, lpObjects = objects
|
|
||||||
, lpContinuationToken = token
|
|
||||||
, lpIsTruncated = truncated
|
|
||||||
}
|
}
|
||||||
where
|
where
|
||||||
elementLocal e = XML.nameLocalName (XML.elementName e)
|
fallbackMarker response =
|
||||||
elementTexts e =
|
if S3.gbrIsTruncated response
|
||||||
[ t
|
then S3.objectKey <$> lastMaybe (S3.gbrContents response)
|
||||||
| XML.NodeContent t <- XML.elementNodes e
|
else Nothing
|
||||||
]
|
|
||||||
descendantElements e =
|
|
||||||
e :
|
|
||||||
concat
|
|
||||||
[ descendantElements child
|
|
||||||
| XML.NodeElement child <- XML.elementNodes e
|
|
||||||
]
|
|
||||||
objectFrom e = do
|
|
||||||
let children = [ c | XML.NodeElement c <- XML.elementNodes e ]
|
|
||||||
childText name =
|
|
||||||
listToMaybe
|
|
||||||
[ t
|
|
||||||
| c <- children
|
|
||||||
, elementLocal c == name
|
|
||||||
, t <- elementTexts c
|
|
||||||
]
|
|
||||||
key <- childText "Key"
|
|
||||||
sizeText <- childText "Size"
|
|
||||||
size <- case reads (T.unpack sizeText) of
|
|
||||||
[(n, "")] -> Just n
|
|
||||||
_ -> Nothing
|
|
||||||
pure (key, size)
|
|
||||||
|
|
||||||
mkObjectRequest :: S3 -> BS.ByteString -> ObjectKey -> [Header] -> UTCTime -> IO Request
|
renderS3Error :: S3Error -> Text
|
||||||
mkObjectRequest s3 method (ObjectKey key) headers _now = do
|
renderS3Error S3NotFound = "object not found"
|
||||||
let path = objectUrlPath s3 key
|
renderS3Error (S3ServiceError code) =
|
||||||
base <- parseRequest (T.unpack (s3Endpoint s3) <> path)
|
"object store returned HTTP " <> T.pack (show code)
|
||||||
pure base
|
renderS3Error S3TransportError = "object store transport failed"
|
||||||
{ method = method
|
renderS3Error S3ResponseError = "object store response was invalid"
|
||||||
, requestHeaders = headers
|
|
||||||
, responseTimeout = responseTimeoutNone
|
|
||||||
}
|
|
||||||
|
|
||||||
mkBucketRequest :: S3 -> BS.ByteString -> [(BS.ByteString, Maybe BS.ByteString)] -> UTCTime -> IO Request
|
runMemory
|
||||||
mkBucketRequest s3 method qs _now = do
|
:: ( Aws.Transaction request response
|
||||||
let path = bucketUrlPath s3
|
, Aws.AsMemoryResponse response
|
||||||
base <- parseRequest (T.unpack (s3Endpoint s3) <> path)
|
, Aws.MemoryResponse response ~ memoryResponse
|
||||||
pure $ setQueryString qs base
|
, Aws.ServiceConfiguration request Aws.NormalQuery
|
||||||
{ method = method
|
~ S3.S3Configuration Aws.NormalQuery
|
||||||
, responseTimeout = responseTimeoutNone
|
)
|
||||||
}
|
=> S3
|
||||||
|
-> request
|
||||||
|
-> IO memoryResponse
|
||||||
|
runMemory store =
|
||||||
|
AwsRun.memoryAws
|
||||||
|
(s3Configuration store)
|
||||||
|
(s3Service store)
|
||||||
|
(s3Manager store)
|
||||||
|
|
||||||
objectUrlPath :: S3 -> Text -> String
|
getObjectMemory
|
||||||
objectUrlPath s3 key =
|
:: S3
|
||||||
let bucket = T.unpack (s3Bucket s3)
|
-> Text
|
||||||
encodedKey = escapePath (T.unpack key)
|
-> IO (Either S3Error (S3.ObjectMetadata, BL.ByteString))
|
||||||
in if s3PathStyle s3
|
getObjectMemory store key = do
|
||||||
then "/" <> bucket <> "/" <> encodedKey
|
result <- try (runMemory store (S3.getObject (s3Bucket store) key))
|
||||||
else "/" <> encodedKey
|
:: IO (Either SomeException S3.GetObjectMemoryResponse)
|
||||||
|
pure $ case result of
|
||||||
|
Left err -> Left (classifyException err)
|
||||||
|
Right (S3.GetObjectMemoryResponse metadata response) ->
|
||||||
|
Right (metadata, HTTP.responseBody response)
|
||||||
|
|
||||||
bucketUrlPath :: S3 -> String
|
observeObjectStream :: S3 -> Text -> IO (Either S3Error ObjectInfo)
|
||||||
bucketUrlPath s3 =
|
observeObjectStream store key = do
|
||||||
let bucket = T.unpack (s3Bucket s3)
|
result <- try (runResourceT getInfo)
|
||||||
in if s3PathStyle s3 then "/" <> bucket else "/"
|
:: IO (Either SomeException ObjectInfo)
|
||||||
|
pure (either (Left . classifyException) Right result)
|
||||||
escapePath :: String -> String
|
|
||||||
escapePath = concatMap escapeChar
|
|
||||||
where
|
where
|
||||||
escapeChar c
|
getInfo = do
|
||||||
| c `elem` ("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~/" :: String) = [c]
|
response <- AwsRun.pureAws
|
||||||
| otherwise =
|
(s3Configuration store)
|
||||||
let (q, r) = fromEnum c `divMod` 16
|
(s3Service store)
|
||||||
hex = "0123456789ABCDEF"
|
(s3Manager store)
|
||||||
in ['%', hex !! q, hex !! r]
|
(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)
|
||||||
|
}
|
||||||
|
updateDigest (size, context) chunk =
|
||||||
|
(size + toInteger (BS8.length chunk), hashUpdate context chunk)
|
||||||
|
|
||||||
signRequest :: S3 -> UTCTime -> BS.ByteString -> Request -> Request
|
contextIdentity :: Context SHA256 -> ContentSha256
|
||||||
signRequest s3 now payloadHash req =
|
contextIdentity =
|
||||||
let amzDate = BS8.pack (formatTime defaultTimeLocale "%Y%m%dT%H%M%SZ" now)
|
ContentSha256
|
||||||
dateStamp = BS8.take 8 amzDate
|
. TE.decodeUtf8
|
||||||
region = TE.encodeUtf8 (s3Region s3)
|
. B16.encode
|
||||||
service = "s3" :: BS.ByteString
|
. BA.convert
|
||||||
credentialScope =
|
. hashFinalize
|
||||||
dateStamp <> "/" <> region <> "/" <> service <> "/aws4_request"
|
|
||||||
headers0 :: RequestHeaders
|
|
||||||
headers0 =
|
|
||||||
filter (\(n, _) -> n /= mk "authorization") (requestHeaders req)
|
|
||||||
<> [ (mk "x-amz-content-sha256", payloadHash)
|
|
||||||
, (mk "x-amz-date", amzDate)
|
|
||||||
, (mk "host", hostHeader req)
|
|
||||||
]
|
|
||||||
sortedHeaders = sortOnHeader headers0
|
|
||||||
signedHeaderNames = map (BS8.map toLower . original . fst) sortedHeaders
|
|
||||||
signedHeaders = BS8.intercalate ";" signedHeaderNames
|
|
||||||
canonicalHeaders =
|
|
||||||
BS8.concat
|
|
||||||
[ BS8.map toLower (original name) <> ":" <> BS8.dropWhile (== ' ') value <> "\n"
|
|
||||||
| (name, value) <- sortedHeaders
|
|
||||||
]
|
|
||||||
canonicalQuery = canonicalQueryString req
|
|
||||||
canonicalUri = BS8.pack (pathURL req)
|
|
||||||
canonicalRequest =
|
|
||||||
method req <> "\n"
|
|
||||||
<> canonicalUri <> "\n"
|
|
||||||
<> canonicalQuery <> "\n"
|
|
||||||
<> canonicalHeaders <> "\n"
|
|
||||||
<> signedHeaders <> "\n"
|
|
||||||
<> payloadHash
|
|
||||||
stringToSign =
|
|
||||||
"AWS4-HMAC-SHA256\n"
|
|
||||||
<> amzDate <> "\n"
|
|
||||||
<> credentialScope <> "\n"
|
|
||||||
<> hexSha256 canonicalRequest
|
|
||||||
signingKey =
|
|
||||||
hmacSHA256
|
|
||||||
(hmacSHA256
|
|
||||||
(hmacSHA256
|
|
||||||
(hmacSHA256 (TE.encodeUtf8 ("AWS4" <> s3SecretKey s3)) dateStamp)
|
|
||||||
region)
|
|
||||||
service)
|
|
||||||
"aws4_request"
|
|
||||||
signature = hexEncode (hmacSHA256 signingKey stringToSign)
|
|
||||||
auth =
|
|
||||||
"AWS4-HMAC-SHA256 Credential="
|
|
||||||
<> TE.encodeUtf8 (s3AccessKey s3)
|
|
||||||
<> "/"
|
|
||||||
<> credentialScope
|
|
||||||
<> ", SignedHeaders="
|
|
||||||
<> signedHeaders
|
|
||||||
<> ", Signature="
|
|
||||||
<> signature
|
|
||||||
in req { requestHeaders = (mk "authorization", auth) : headers0 }
|
|
||||||
|
|
||||||
sortOnHeader :: RequestHeaders -> RequestHeaders
|
classifyException :: SomeException -> S3Error
|
||||||
sortOnHeader = sortOn (\(n, _) -> BS8.map toLower (original n))
|
classifyException exception =
|
||||||
where
|
case fromException exception of
|
||||||
sortOn f = map snd . sort . map (\x -> (f x, x))
|
Just serviceError
|
||||||
|
| statusCode (S3.s3StatusCode serviceError) == 404 -> S3NotFound
|
||||||
hostHeader :: Request -> BS.ByteString
|
| otherwise ->
|
||||||
hostHeader req =
|
S3ServiceError (statusCode (S3.s3StatusCode serviceError))
|
||||||
case lookup (mk "Host") (requestHeaders req) of
|
|
||||||
Just h -> h
|
|
||||||
Nothing ->
|
Nothing ->
|
||||||
let h = host req
|
case fromException exception :: Maybe HTTP.HttpException of
|
||||||
p = port req
|
Just (HTTP.HttpExceptionRequest _ content) ->
|
||||||
in if p == 80 || p == 443
|
case content of
|
||||||
then h
|
HTTP.StatusCodeException response _
|
||||||
else h <> ":" <> BS8.pack (show p)
|
| statusCode (HTTP.responseStatus response) == 404 -> S3NotFound
|
||||||
|
| otherwise ->
|
||||||
|
S3ServiceError (statusCode (HTTP.responseStatus response))
|
||||||
|
_ -> S3TransportError
|
||||||
|
Just (HTTP.InvalidUrlException _ _) -> S3ResponseError
|
||||||
|
Nothing -> S3ResponseError
|
||||||
|
|
||||||
pathURL :: Request -> String
|
lastMaybe :: [a] -> Maybe a
|
||||||
pathURL req =
|
lastMaybe [] = Nothing
|
||||||
case BS8.unpack (path req) of
|
lastMaybe values = Just (last values)
|
||||||
"" -> "/"
|
|
||||||
p -> p
|
|
||||||
|
|
||||||
canonicalQueryString :: Request -> BS.ByteString
|
parseEndpoint :: Text -> IO (AwsCore.Protocol, BS8.ByteString, Int)
|
||||||
canonicalQueryString req =
|
parseEndpoint raw =
|
||||||
let pairs = parseQuery (queryString req)
|
case parseURI (T.unpack raw) of
|
||||||
enc = escapeQuery
|
Just URI
|
||||||
encoded =
|
{ uriScheme
|
||||||
sort
|
, uriAuthority = Just URIAuth
|
||||||
[ enc k <> "=" <> enc (fromMaybe "" v)
|
{ uriUserInfo = ""
|
||||||
| (k, v) <- pairs
|
, uriRegName
|
||||||
]
|
, uriPort
|
||||||
in BS8.intercalate "&" encoded
|
}
|
||||||
|
, uriPath = ""
|
||||||
escapeQuery :: BS.ByteString -> BS.ByteString
|
, uriQuery = ""
|
||||||
escapeQuery = BS8.concatMap escape1
|
, uriFragment = ""
|
||||||
where
|
}
|
||||||
escape1 c
|
| uriScheme == "http:" || uriScheme == "https:" -> do
|
||||||
| c `elem` ("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" :: String)
|
port <- case uriPort of
|
||||||
= BS8.singleton c
|
"" -> pure (if uriScheme == "https:" then 443 else 80)
|
||||||
| otherwise =
|
':' : value ->
|
||||||
let (q, r) = fromEnum c `divMod` 16
|
maybe (fail "invalid S3 endpoint port") pure (readMaybe value)
|
||||||
hex = "0123456789ABCDEF"
|
_ -> fail "invalid S3 endpoint port"
|
||||||
in BS8.pack ['%', hex !! q, hex !! r]
|
let protocol =
|
||||||
|
if uriScheme == "https:" then AwsCore.HTTPS else AwsCore.HTTP
|
||||||
hmacSHA256 :: BS.ByteString -> BS.ByteString -> BS.ByteString
|
pure (protocol, BS8.pack uriRegName, port)
|
||||||
hmacSHA256 key msg = convert (hmac key msg :: HMAC SHA256)
|
_ -> fail "NUTHATCH_S3_ENDPOINT must be an absolute HTTP(S) origin"
|
||||||
|
|
||||||
hexSha256 :: BS.ByteString -> BS.ByteString
|
|
||||||
hexSha256 = hexEncode . convert . (hash :: BS.ByteString -> Digest SHA256)
|
|
||||||
|
|
||||||
hexSha256Lazy :: BL.ByteString -> BS.ByteString
|
|
||||||
hexSha256Lazy = hexEncode . convert . (hashlazy :: BL.ByteString -> Digest SHA256)
|
|
||||||
|
|
||||||
hexEncode :: BS.ByteString -> BS.ByteString
|
|
||||||
hexEncode = B16.encode
|
|
||||||
|
|
||||||
lookupHeader :: BS.ByteString -> RequestHeaders -> Maybe BS.ByteString
|
|
||||||
lookupHeader name headers = lookup (mk name) headers
|
|
||||||
|
|
||||||
readInteger :: BS.ByteString -> Maybe Integer
|
|
||||||
readInteger bs =
|
|
||||||
case reads (BS8.unpack bs) of
|
|
||||||
[(n, "")] -> Just n
|
|
||||||
_ -> Nothing
|
|
||||||
|
|
|
||||||
416
test/Integration.hs
Normal file
416
test/Integration.hs
Normal file
|
|
@ -0,0 +1,416 @@
|
||||||
|
module Main (main) where
|
||||||
|
|
||||||
|
import Control.Concurrent.Async (concurrently, mapConcurrently)
|
||||||
|
import Control.Exception (SomeException, try)
|
||||||
|
import Control.Monad (unless)
|
||||||
|
import Crypto.Hash (Digest, SHA256, hashlazy)
|
||||||
|
import qualified Data.ByteArray as BA
|
||||||
|
import qualified Data.ByteString.Base16 as B16
|
||||||
|
import qualified Data.ByteString.Lazy as BL
|
||||||
|
import Data.List (sort)
|
||||||
|
import Data.Text (Text)
|
||||||
|
import qualified Data.Text.Encoding as TE
|
||||||
|
import Database.PostgreSQL.Simple (Only (..))
|
||||||
|
import Nuthatch.Config (Config (..), loadConfig)
|
||||||
|
import Nuthatch.Db
|
||||||
|
( Db
|
||||||
|
, configurePoolLimit
|
||||||
|
, ensurePersonalPool
|
||||||
|
, migrate
|
||||||
|
, withDb
|
||||||
|
, withTransaction
|
||||||
|
)
|
||||||
|
import qualified Nuthatch.Db as Db
|
||||||
|
import Nuthatch.Protocol
|
||||||
|
import Nuthatch.S3
|
||||||
|
import Nuthatch.Types
|
||||||
|
|
||||||
|
main :: IO ()
|
||||||
|
main = do
|
||||||
|
config <- loadConfig
|
||||||
|
withDb config $ \database -> do
|
||||||
|
migrate database "sql" config
|
||||||
|
migrate database "sql" config
|
||||||
|
emptyCatalog <- schemaCatalog database
|
||||||
|
resetSchema database
|
||||||
|
installLegacySchema database
|
||||||
|
migrate database "sql" config
|
||||||
|
migrate database "sql" config
|
||||||
|
adoptedCatalog <- schemaCatalog database
|
||||||
|
assertEqual "legacy and empty migration schema equivalence"
|
||||||
|
emptyCatalog adoptedCatalog
|
||||||
|
verifySchema database
|
||||||
|
verifyAdmission database config
|
||||||
|
store <- newS3 config
|
||||||
|
verifyStorage config store
|
||||||
|
putStrLn "WP-04 integration gate passed"
|
||||||
|
|
||||||
|
verifySchema :: Db -> IO ()
|
||||||
|
verifySchema database = do
|
||||||
|
tables <- Db.query_ database
|
||||||
|
"SELECT table_name FROM information_schema.tables \
|
||||||
|
\WHERE table_schema = 'public' \
|
||||||
|
\AND table_name IN ('pools','mutations','key_locks','objects_meta','schema_migrations') \
|
||||||
|
\ORDER BY table_name"
|
||||||
|
:: IO [Only Text]
|
||||||
|
assertEqual "authoritative schema tables"
|
||||||
|
["key_locks", "mutations", "objects_meta", "pools", "schema_migrations"]
|
||||||
|
(map fromOnly tables)
|
||||||
|
constraints <- Db.query_ database
|
||||||
|
"SELECT count(*) FROM pg_constraint \
|
||||||
|
\WHERE conname = 'pools_live_bytes_within_limit' \
|
||||||
|
\AND conrelid = 'pools'::regclass"
|
||||||
|
:: IO [Only Int]
|
||||||
|
assertEqual "pool limit constraint" [Only 1] constraints
|
||||||
|
migrations <- Db.query_ database
|
||||||
|
"SELECT filename FROM schema_migrations ORDER BY filename"
|
||||||
|
:: IO [Only Text]
|
||||||
|
assertEqual "migration history"
|
||||||
|
["001_schema.sql", "002_pool_limit_guard.sql"]
|
||||||
|
(map fromOnly migrations)
|
||||||
|
|
||||||
|
schemaCatalog :: Db -> IO [Text]
|
||||||
|
schemaCatalog database = do
|
||||||
|
rows <- Db.query_ database
|
||||||
|
"SELECT description FROM ( \
|
||||||
|
\SELECT 'column|' || table_name || '|' || ordinal_position::text || '|' \
|
||||||
|
\ || column_name || '|' || data_type || '|' || is_nullable || '|' \
|
||||||
|
\ || COALESCE(column_default, '') AS description \
|
||||||
|
\FROM information_schema.columns \
|
||||||
|
\WHERE table_schema = 'public' \
|
||||||
|
\UNION ALL \
|
||||||
|
\SELECT 'constraint|' || c.relname || '|' || con.conname || '|' \
|
||||||
|
\ || con.contype::text || '|' || pg_get_constraintdef(con.oid) \
|
||||||
|
\FROM pg_constraint con \
|
||||||
|
\JOIN pg_class c ON c.oid = con.conrelid \
|
||||||
|
\JOIN pg_namespace n ON n.oid = c.relnamespace \
|
||||||
|
\WHERE n.nspname = 'public' \
|
||||||
|
\UNION ALL \
|
||||||
|
\SELECT 'index|' || tablename || '|' || indexname || '|' || indexdef \
|
||||||
|
\FROM pg_indexes WHERE schemaname = 'public' \
|
||||||
|
\) catalog ORDER BY description"
|
||||||
|
:: IO [Only Text]
|
||||||
|
pure (map fromOnly rows)
|
||||||
|
|
||||||
|
resetSchema :: Db -> IO ()
|
||||||
|
resetSchema database = do
|
||||||
|
_ <- Db.execute_ database
|
||||||
|
"DROP TABLE IF EXISTS schema_migrations, key_locks, objects_meta, \
|
||||||
|
\mutations, pools CASCADE"
|
||||||
|
pure ()
|
||||||
|
|
||||||
|
installLegacySchema :: Db -> IO ()
|
||||||
|
installLegacySchema database = do
|
||||||
|
mapM_ (Db.execute_ database)
|
||||||
|
[ "CREATE TABLE pools ( \
|
||||||
|
\pool_id TEXT PRIMARY KEY, \
|
||||||
|
\limit_bytes BIGINT NOT NULL CHECK (limit_bytes > 0), \
|
||||||
|
\used_bytes BIGINT NOT NULL DEFAULT 0 CHECK (used_bytes >= 0), \
|
||||||
|
\reserved_bytes BIGINT NOT NULL DEFAULT 0 CHECK (reserved_bytes >= 0))"
|
||||||
|
, "CREATE TABLE mutations ( \
|
||||||
|
\user_id TEXT NOT NULL, kind TEXT NOT NULL, operation_id TEXT NOT NULL, \
|
||||||
|
\pool_id TEXT NOT NULL REFERENCES pools (pool_id), object_key TEXT NOT NULL, \
|
||||||
|
\relative_path TEXT NOT NULL, declared_size BIGINT NOT NULL, \
|
||||||
|
\content_sha256 TEXT, prior_size BIGINT NOT NULL, prior_sha256 TEXT, \
|
||||||
|
\growth_bytes BIGINT NOT NULL, state TEXT NOT NULL, \
|
||||||
|
\external_state TEXT NOT NULL DEFAULT 'idle', \
|
||||||
|
\created_at TIMESTAMPTZ NOT NULL DEFAULT now(), \
|
||||||
|
\updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), \
|
||||||
|
\PRIMARY KEY (user_id, kind, operation_id))"
|
||||||
|
, "CREATE TABLE key_locks ( \
|
||||||
|
\object_key TEXT PRIMARY KEY, user_id TEXT NOT NULL, kind TEXT NOT NULL, \
|
||||||
|
\operation_id TEXT NOT NULL)"
|
||||||
|
, "CREATE TABLE objects_meta ( \
|
||||||
|
\object_key TEXT PRIMARY KEY, \
|
||||||
|
\pool_id TEXT NOT NULL REFERENCES pools (pool_id), \
|
||||||
|
\size_bytes BIGINT NOT NULL, content_sha256 TEXT NOT NULL, \
|
||||||
|
\updated_at TIMESTAMPTZ NOT NULL DEFAULT now())"
|
||||||
|
]
|
||||||
|
pure ()
|
||||||
|
|
||||||
|
verifyAdmission :: Db -> Config -> IO ()
|
||||||
|
verifyAdmission database config = do
|
||||||
|
resetAuthority database 100
|
||||||
|
replayOutcomes <- mapConcurrently
|
||||||
|
(const (admit database "same-operation" "same-key" 10))
|
||||||
|
[1 .. 8 :: Int]
|
||||||
|
assertEqual "one first request" 1 (countPrepared replayOutcomes)
|
||||||
|
assertEqual "stable concurrent replay" 7 (countReplay replayOutcomes)
|
||||||
|
assertAuthority database 1 10
|
||||||
|
|
||||||
|
resetAuthority database 100
|
||||||
|
conflictOutcomes <- mapConcurrently
|
||||||
|
(\size -> admit database "conflicting-operation" "conflicting-key" size)
|
||||||
|
[10, 11]
|
||||||
|
assertEqual "one conflicting first request" 1 (countPrepared conflictOutcomes)
|
||||||
|
assertEqual "conflicting reuse" 1 (countConflict conflictOutcomes)
|
||||||
|
assertAuthority database 1 (mrGrowth (preparedRecord conflictOutcomes))
|
||||||
|
|
||||||
|
resetAuthority database 100
|
||||||
|
keyOutcomes <- mapConcurrently
|
||||||
|
(\operation -> admit database operation "locked-key" 10)
|
||||||
|
["key-operation-a", "key-operation-b"]
|
||||||
|
assertEqual "one same-key authority" 1 (countPrepared keyOutcomes)
|
||||||
|
assertEqual "one same-key rejection" 1 (countRejected keyOutcomes)
|
||||||
|
assertAuthority database 1 10
|
||||||
|
|
||||||
|
resetAuthority database 10
|
||||||
|
quotaOutcomes <- mapConcurrently
|
||||||
|
(\(operation, key) -> admit database operation key 8)
|
||||||
|
[("quota-operation-a", "quota-key-a"), ("quota-operation-b", "quota-key-b")]
|
||||||
|
assertEqual "one quota reservation" 1 (countPrepared quotaOutcomes)
|
||||||
|
assertEqual "one quota rejection" 1 (countRejected quotaOutcomes)
|
||||||
|
assertAuthority database 1 8
|
||||||
|
lower <- try (configurePoolLimit database sharedPool 7)
|
||||||
|
:: IO (Either SomeException ())
|
||||||
|
assert "unsafe pool limit reduction rejected" (isLeft lower)
|
||||||
|
configurePoolLimit database sharedPool 20
|
||||||
|
pool <- Db.query database
|
||||||
|
"SELECT limit_bytes, used_bytes, reserved_bytes FROM pools WHERE pool_id = ?"
|
||||||
|
(Only (unPoolId sharedPool))
|
||||||
|
:: IO [(Integer, Integer, Integer)]
|
||||||
|
assertEqual "safe pool limit reconfiguration" [(20, 0, 8)] pool
|
||||||
|
|
||||||
|
let personalUser = UserId "personal-limit-user"
|
||||||
|
personal = personalPool personalUser
|
||||||
|
ensurePersonalPool database
|
||||||
|
config { cfgPersonalQuotaBytes = 30 }
|
||||||
|
personalUser
|
||||||
|
ensurePersonalPool database
|
||||||
|
config { cfgPersonalQuotaBytes = 40 }
|
||||||
|
personalUser
|
||||||
|
_ <- Db.execute database
|
||||||
|
"UPDATE pools SET reserved_bytes = 35 WHERE pool_id = ?"
|
||||||
|
(Only (unPoolId personal))
|
||||||
|
personalLower <- try
|
||||||
|
(ensurePersonalPool database
|
||||||
|
config { cfgPersonalQuotaBytes = 34 }
|
||||||
|
personalUser)
|
||||||
|
:: IO (Either SomeException ())
|
||||||
|
assert "unsafe personal limit reduction rejected" (isLeft personalLower)
|
||||||
|
personalPoolRow <- Db.query database
|
||||||
|
"SELECT limit_bytes, used_bytes, reserved_bytes FROM pools WHERE pool_id = ?"
|
||||||
|
(Only (unPoolId personal))
|
||||||
|
:: IO [(Integer, Integer, Integer)]
|
||||||
|
assertEqual "personal limit reconfiguration" [(40, 0, 35)] personalPoolRow
|
||||||
|
|
||||||
|
resetAuthority database 100
|
||||||
|
(racingAdmission, racingLimit) <- concurrently
|
||||||
|
(admit database "limit-race-operation" "limit-race-key" 80)
|
||||||
|
(try (configurePoolLimit database sharedPool 50)
|
||||||
|
:: IO (Either SomeException ()))
|
||||||
|
racingPool <- Db.query database
|
||||||
|
"SELECT limit_bytes, used_bytes, reserved_bytes FROM pools WHERE pool_id = ?"
|
||||||
|
(Only (unPoolId sharedPool))
|
||||||
|
:: IO [(Integer, Integer, Integer)]
|
||||||
|
case (racingAdmission, racingLimit, racingPool) of
|
||||||
|
(AdmitPrepared _, Left _, [(100, 0, 80)]) ->
|
||||||
|
assertAuthority database 1 80
|
||||||
|
(AdmitRejected _ _, Right (), [(50, 0, 0)]) ->
|
||||||
|
assertAuthority database 0 0
|
||||||
|
other -> fail ("unsafe admission/limit race result: " <> show other)
|
||||||
|
|
||||||
|
resetAuthority database 100
|
||||||
|
observationOutcome <- admit database
|
||||||
|
"observation-operation"
|
||||||
|
"observation-key"
|
||||||
|
10
|
||||||
|
let observationRecord = preparedRecord [observationOutcome]
|
||||||
|
thirdState =
|
||||||
|
ObjectPresent 10
|
||||||
|
(Just (ContentSha256
|
||||||
|
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"))
|
||||||
|
settled <- withTransaction database $ \connection -> do
|
||||||
|
applying <- markApplying connection observationRecord
|
||||||
|
pending <- markExternalPending connection observationRecord
|
||||||
|
externalSettled <- markExternalSettled connection observationRecord
|
||||||
|
pure (applying && pending && externalSettled)
|
||||||
|
assert "observation fixture reached applying/settled" settled
|
||||||
|
unavailable <- withTransaction database $ \connection ->
|
||||||
|
finalizeFromObservation connection observationRecord
|
||||||
|
(ObjectUnavailable "timeout")
|
||||||
|
ambiguous <- withTransaction database $ \connection ->
|
||||||
|
finalizeFromObservation connection observationRecord thirdState
|
||||||
|
assertEqual "unavailable observation remains non-terminal" Nothing unavailable
|
||||||
|
assertEqual "third-state observation remains non-terminal" Nothing ambiguous
|
||||||
|
assertAuthority database 1 10
|
||||||
|
durableState <- Db.query database
|
||||||
|
"SELECT state, external_state FROM mutations \
|
||||||
|
\WHERE user_id = ? AND kind = ? AND operation_id = ?"
|
||||||
|
( "integration-user" :: Text
|
||||||
|
, "put" :: Text
|
||||||
|
, "observation-operation" :: Text
|
||||||
|
)
|
||||||
|
:: IO [(Text, Text)]
|
||||||
|
assertEqual "ambiguous observation retains mutation authority"
|
||||||
|
[("applying", "settled")]
|
||||||
|
durableState
|
||||||
|
|
||||||
|
verifyStorage :: Config -> S3 -> IO ()
|
||||||
|
verifyStorage config store = do
|
||||||
|
let fallbackKey = ObjectKey "shared/fallback.bin"
|
||||||
|
fallbackBody = BL.replicate (5 * 1024 * 1024) 0
|
||||||
|
fallbackIdentity =
|
||||||
|
ContentSha256
|
||||||
|
"c036cbb7553a909f8b8877d4461924307f27ecb66cff928eeeafd569c3887e29"
|
||||||
|
expectRight "put object without identity metadata"
|
||||||
|
=<< putObject store fallbackKey
|
||||||
|
(toInteger (BL.length fallbackBody))
|
||||||
|
Nothing
|
||||||
|
fallbackBody
|
||||||
|
fallbackHead <- expectJust "observe object without identity metadata"
|
||||||
|
=<< headObject store fallbackKey
|
||||||
|
assertEqual "streaming digest fallback size"
|
||||||
|
(toInteger (BL.length fallbackBody))
|
||||||
|
(oiSize fallbackHead)
|
||||||
|
assertEqual "streaming digest fallback identity"
|
||||||
|
(Just fallbackIdentity)
|
||||||
|
(oiSha256 fallbackHead)
|
||||||
|
expectRight "delete fallback object" =<< deleteObject store fallbackKey
|
||||||
|
|
||||||
|
let unicodeKey = ObjectKey "shared/문서/βeta.txt"
|
||||||
|
firstBody = "same-size-a"
|
||||||
|
secondBody = "same-size-b"
|
||||||
|
firstIdentity = contentIdentity firstBody
|
||||||
|
secondIdentity = contentIdentity secondBody
|
||||||
|
expectRight "put Unicode key"
|
||||||
|
=<< putObject store unicodeKey (toInteger (BL.length firstBody))
|
||||||
|
(Just firstIdentity) firstBody
|
||||||
|
fetched <- expectRight "get Unicode key" =<< getObjectBytes store unicodeKey
|
||||||
|
assertEqual "Unicode key round trip" firstBody fetched
|
||||||
|
firstHead <- expectJust "head Unicode key" =<< headObject store unicodeKey
|
||||||
|
assertEqual "head size" (toInteger (BL.length firstBody)) (oiSize firstHead)
|
||||||
|
assertEqual "head metadata" (Just firstIdentity) (oiSha256 firstHead)
|
||||||
|
expectRight "overwrite same-size content"
|
||||||
|
=<< putObject store unicodeKey (toInteger (BL.length secondBody))
|
||||||
|
(Just secondIdentity) secondBody
|
||||||
|
secondHead <- expectJust "head overwritten key" =<< headObject store unicodeKey
|
||||||
|
assert "same-size content identity differs"
|
||||||
|
(oiSha256 firstHead /= oiSha256 secondHead)
|
||||||
|
|
||||||
|
let paged =
|
||||||
|
[ (ObjectKey "shared/page/α", "a")
|
||||||
|
, (ObjectKey "shared/page/β", "b")
|
||||||
|
, (ObjectKey "shared/page/γ", "c")
|
||||||
|
]
|
||||||
|
mapM_
|
||||||
|
(\(key, body) ->
|
||||||
|
expectRight "put paged key"
|
||||||
|
=<< putObject store key (toInteger (BL.length body))
|
||||||
|
(Just (contentIdentity body)) body)
|
||||||
|
paged
|
||||||
|
keys <- collectPages store "shared/page/" Nothing []
|
||||||
|
assertEqual "paginated Unicode list"
|
||||||
|
(sort [unObjectKey key | (key, _) <- paged])
|
||||||
|
(sort keys)
|
||||||
|
|
||||||
|
missing <- getObjectBytes store (ObjectKey "shared/missing")
|
||||||
|
assertEqual "typed not-found error" (Left S3NotFound) missing
|
||||||
|
deniedStore <- newS3 config
|
||||||
|
{ cfgS3SecretKey = cfgS3SecretKey config <> "-invalid" }
|
||||||
|
denied <- listObjects deniedStore "" Nothing Nothing 1
|
||||||
|
case denied of
|
||||||
|
Left (S3ServiceError code) ->
|
||||||
|
assert "typed provider rejection" (code >= 400 && code < 500)
|
||||||
|
other -> fail ("expected typed provider rejection, got " <> show other)
|
||||||
|
expectRight "delete Unicode key" =<< deleteObject store unicodeKey
|
||||||
|
absent <- headObject store unicodeKey
|
||||||
|
assertEqual "deleted key absent" (Right Nothing) absent
|
||||||
|
|
||||||
|
collectPages :: S3 -> Text -> Maybe Text -> [Text] -> IO [Text]
|
||||||
|
collectPages store prefix marker accumulated = do
|
||||||
|
page <- expectRight "list page" =<< listObjects store prefix Nothing marker 1
|
||||||
|
let accumulated' = accumulated <> map fst (lpObjects page)
|
||||||
|
if lpIsTruncated page
|
||||||
|
then case lpContinuationToken page of
|
||||||
|
Nothing -> fail "truncated list omitted continuation marker"
|
||||||
|
Just next -> collectPages store prefix (Just next) accumulated'
|
||||||
|
else pure accumulated'
|
||||||
|
|
||||||
|
admit :: Db -> Text -> Text -> Integer -> IO AdmitOutcome
|
||||||
|
admit database operation key size =
|
||||||
|
withTransaction database $ \connection ->
|
||||||
|
admitMutation
|
||||||
|
connection
|
||||||
|
(UserId "integration-user")
|
||||||
|
Put
|
||||||
|
(OperationId operation)
|
||||||
|
sharedPool
|
||||||
|
(ObjectKey ("shared/" <> key))
|
||||||
|
(RelativePath key)
|
||||||
|
size
|
||||||
|
(Just fixedIdentity)
|
||||||
|
1000
|
||||||
|
|
||||||
|
resetAuthority :: Db -> Integer -> IO ()
|
||||||
|
resetAuthority database limitBytes = do
|
||||||
|
_ <- Db.execute_ database
|
||||||
|
"TRUNCATE key_locks, mutations, objects_meta, pools CASCADE"
|
||||||
|
configurePoolLimit database sharedPool limitBytes
|
||||||
|
|
||||||
|
assertAuthority :: Db -> Int -> Integer -> IO ()
|
||||||
|
assertAuthority database expectedLocks expectedReserved = do
|
||||||
|
locks <- Db.query_ database "SELECT count(*) FROM key_locks"
|
||||||
|
:: IO [Only Int]
|
||||||
|
reservations <- Db.query database
|
||||||
|
"SELECT reserved_bytes FROM pools WHERE pool_id = ?"
|
||||||
|
(Only (unPoolId sharedPool))
|
||||||
|
:: IO [Only Integer]
|
||||||
|
assertEqual "active key locks" [Only expectedLocks] locks
|
||||||
|
assertEqual "reserved bytes" [Only expectedReserved] reservations
|
||||||
|
|
||||||
|
countPrepared :: [AdmitOutcome] -> Int
|
||||||
|
countPrepared outcomes = length [() | AdmitPrepared _ <- outcomes]
|
||||||
|
|
||||||
|
countReplay :: [AdmitOutcome] -> Int
|
||||||
|
countReplay outcomes = length [() | AdmitReplay MutationPending _ <- outcomes]
|
||||||
|
|
||||||
|
countConflict :: [AdmitOutcome] -> Int
|
||||||
|
countConflict outcomes = length [() | AdmitConflict <- outcomes]
|
||||||
|
|
||||||
|
countRejected :: [AdmitOutcome] -> Int
|
||||||
|
countRejected outcomes = length [() | AdmitRejected _ _ <- outcomes]
|
||||||
|
|
||||||
|
preparedRecord :: [AdmitOutcome] -> MutationRecord
|
||||||
|
preparedRecord outcomes =
|
||||||
|
case [record | AdmitPrepared record <- outcomes] of
|
||||||
|
[record] -> record
|
||||||
|
_ -> error "expected exactly one prepared record"
|
||||||
|
|
||||||
|
fixedIdentity :: ContentSha256
|
||||||
|
fixedIdentity =
|
||||||
|
ContentSha256 "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||||
|
|
||||||
|
contentIdentity :: BL.ByteString -> ContentSha256
|
||||||
|
contentIdentity =
|
||||||
|
ContentSha256
|
||||||
|
. TE.decodeUtf8
|
||||||
|
. B16.encode
|
||||||
|
. BA.convert
|
||||||
|
. (hashlazy :: BL.ByteString -> Digest SHA256)
|
||||||
|
|
||||||
|
expectRight :: String -> Either S3Error value -> IO value
|
||||||
|
expectRight label result =
|
||||||
|
case result of
|
||||||
|
Left err -> fail (label <> ": " <> show err)
|
||||||
|
Right value -> pure value
|
||||||
|
|
||||||
|
expectJust :: String -> Either S3Error (Maybe value) -> IO value
|
||||||
|
expectJust label result = do
|
||||||
|
value <- expectRight label result
|
||||||
|
case value of
|
||||||
|
Nothing -> fail (label <> ": object absent")
|
||||||
|
Just present -> pure present
|
||||||
|
|
||||||
|
assert :: String -> Bool -> IO ()
|
||||||
|
assert label condition =
|
||||||
|
unless condition (fail label)
|
||||||
|
|
||||||
|
assertEqual :: (Eq value, Show value) => String -> value -> value -> IO ()
|
||||||
|
assertEqual label expected actual =
|
||||||
|
unless (expected == actual) $
|
||||||
|
fail (label <> ": expected " <> show expected <> ", got " <> show actual)
|
||||||
|
|
||||||
|
isLeft :: Either left right -> Bool
|
||||||
|
isLeft (Left _) = True
|
||||||
|
isLeft (Right _) = False
|
||||||
15
test/Spec.hs
15
test/Spec.hs
|
|
@ -108,6 +108,21 @@ protocolTests = testGroup "mutation protocol decisions"
|
||||||
[ testProperty "request fingerprints distinguish every bound field" propFingerprintDistinction
|
[ testProperty "request fingerprints distinguish every bound field" propFingerprintDistinction
|
||||||
, testProperty "quota growth and admission preserve the limit" propQuotaInvariant
|
, testProperty "quota growth and admission preserve the limit" propQuotaInvariant
|
||||||
, testProperty "object observations match the independent classifier" propObservations
|
, testProperty "object observations match the independent classifier" propObservations
|
||||||
|
, testCase "unavailable observations match no terminal state" $ do
|
||||||
|
let rec = sampleMutation Put (ObjectKey "shared/file") 10
|
||||||
|
(Just (ContentSha256 "desired"))
|
||||||
|
observation = ObjectUnavailable "timeout"
|
||||||
|
desiredMatches rec observation @?= False
|
||||||
|
priorMatches rec observation @?= False
|
||||||
|
, testCase "ambiguous same-size content matches no terminal state" $ do
|
||||||
|
let rec = (sampleMutation Put (ObjectKey "shared/file") 10
|
||||||
|
(Just (ContentSha256 "desired")))
|
||||||
|
{ mrPriorSize = 10
|
||||||
|
, mrPriorSha256 = Just (ContentSha256 "prior")
|
||||||
|
}
|
||||||
|
observation = ObjectPresent 10 (Just (ContentSha256 "other"))
|
||||||
|
desiredMatches rec observation @?= False
|
||||||
|
priorMatches rec observation @?= False
|
||||||
, testProperty "only settled terminal records are expiry eligible" propExpiryEligibility
|
, testProperty "only settled terminal records are expiry eligible" propExpiryEligibility
|
||||||
, testProperty "only stale active records are recovery eligible" propRecoveryTiming
|
, testProperty "only stale active records are recovery eligible" propRecoveryTiming
|
||||||
]
|
]
|
||||||
|
|
|
||||||
27
tests/kind/wp04-job.yaml.in
Normal file
27
tests/kind/wp04-job.yaml.in
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
apiVersion: batch/v1
|
||||||
|
kind: Job
|
||||||
|
metadata:
|
||||||
|
name: @@JOB_NAME@@
|
||||||
|
namespace: nuthatch-testbed
|
||||||
|
spec:
|
||||||
|
activeDeadlineSeconds: 300
|
||||||
|
backoffLimit: 0
|
||||||
|
ttlSecondsAfterFinished: 300
|
||||||
|
template:
|
||||||
|
spec:
|
||||||
|
automountServiceAccountToken: false
|
||||||
|
enableServiceLinks: false
|
||||||
|
restartPolicy: Never
|
||||||
|
containers:
|
||||||
|
- name: storage-foundation
|
||||||
|
image: @@TEST_IMAGE@@
|
||||||
|
imagePullPolicy: Never
|
||||||
|
command: [nuthatch-wp04-integration]
|
||||||
|
args: ["+RTS", "-N2", "-RTS"]
|
||||||
|
envFrom:
|
||||||
|
- configMapRef: {name: @@PROFILE_NAME@@}
|
||||||
|
env:
|
||||||
|
- {name: NUTHATCH_REDIS_URL, value: "redis://127.0.0.1:6379/0"}
|
||||||
|
resources:
|
||||||
|
requests: {cpu: 100m, memory: 32Mi}
|
||||||
|
limits: {cpu: "2", memory: 96Mi}
|
||||||
49
tests/kind/wp04-run.sh
Executable file
49
tests/kind/wp04-run.sh
Executable file
|
|
@ -0,0 +1,49 @@
|
||||||
|
#!/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-wp04: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
|
||||||
|
|
||||||
|
acquire_fixture_lock
|
||||||
|
OWNS_FIXTURE=true
|
||||||
|
setup_fixture durable target
|
||||||
|
"$NUTHATCH_REAL_NERDCTL" --namespace default build \
|
||||||
|
--file "$ROOT/Dockerfile" \
|
||||||
|
--target test-runtime \
|
||||||
|
--tag "$APP_IMAGE" \
|
||||||
|
"$ROOT"
|
||||||
|
load_local_image "$APP_IMAGE" default
|
||||||
|
run_job_matrix \
|
||||||
|
wp04-storage \
|
||||||
|
"$TESTBED/wp04-job.yaml.in" \
|
||||||
|
"$APP_IMAGE" \
|
||||||
|
durable target
|
||||||
|
SUCCEEDED=true
|
||||||
|
printf 'Nuthatch WP-04 storage foundation passed\n'
|
||||||
Loading…
Reference in a new issue