packages feed

salmon-ops-recipes 0.1.0.0 → 0.2.0.0

raw patch · 102 files changed

+27964/−1155 lines, 102 filesdep +ghc-heapdep ~salmon-opsdep ~salmon-ops-recipesPVP ok

version bump matches the API change (PVP)

Dependencies added: ghc-heap

Dependency ranges changed: salmon-ops, salmon-ops-recipes

API changes (from Hackage documentation)

- SreBox.CabalBuilding: instance GHC.Show.Show SreBox.CabalBuilding.Report
- SreBox.DNSRegistration: instance GHC.Generics.Generic SreBox.DNSRegistration.RegisteredMachineSetup
- SreBox.DNSRegistration: instance GHC.Show.Show SreBox.DNSRegistration.Report
- SreBox.Environment: instance GHC.Show.Show SreBox.Environment.Environment
- SreBox.Gcp.CloudRunAlerts: instance GHC.Show.Show SreBox.Gcp.CloudRunAlerts.AlertThresholds
- SreBox.Gcp.CloudRunAlerts: instance GHC.Show.Show SreBox.Gcp.CloudRunAlerts.CloudRunAlertsConfig
- SreBox.Gcp.CloudRunAlerts: instance GHC.Show.Show SreBox.Gcp.CloudRunAlerts.Report
- SreBox.Gcp.CloudRunDeploy: instance GHC.Show.Show SreBox.Gcp.CloudRunDeploy.Report
- SreBox.Gcp.PostgrestCloudRun: instance GHC.Show.Show SreBox.Gcp.PostgrestCloudRun.Report
- SreBox.Gcp.PreviewEnvironment: instance GHC.Show.Show SreBox.Gcp.PreviewEnvironment.Report
- SreBox.Gcp.VmProvision: instance GHC.Show.Show SreBox.Gcp.VmProvision.Report
- SreBox.JWTSigning: instance GHC.Show.Show SreBox.JWTSigning.Report
- SreBox.MicroDNS: instance GHC.Generics.Generic SreBox.MicroDNS.MicroDNSSetup
- SreBox.MicroDNS: instance GHC.Show.Show SreBox.MicroDNS.Report
- SreBox.PostgresBackup: instance GHC.Generics.Generic SreBox.PostgresBackup.Credentials
- SreBox.PostgresBackup: instance GHC.Generics.Generic SreBox.PostgresBackup.GcsDestination
- SreBox.PostgresBackup: instance GHC.Generics.Generic SreBox.PostgresBackup.NamingPolicy
- SreBox.PostgresBackup: instance GHC.Generics.Generic SreBox.PostgresBackup.PgBackupConfig
- SreBox.PostgresBackup: instance GHC.Show.Show SreBox.PostgresBackup.Credentials
- SreBox.PostgresBackup: instance GHC.Show.Show SreBox.PostgresBackup.GcsDestination
- SreBox.PostgresBackup: instance GHC.Show.Show SreBox.PostgresBackup.NamingPolicy
- SreBox.PostgresBackup: instance GHC.Show.Show SreBox.PostgresBackup.PgBackupConfig
- SreBox.PostgresBackup: instance GHC.Show.Show SreBox.PostgresBackup.Report
- SreBox.PostgresInit: instance GHC.Generics.Generic (SreBox.PostgresInit.InitSetup pass)
- SreBox.PostgresInit: instance GHC.Show.Show SreBox.PostgresInit.Report
- SreBox.PostgresMigrations: instance GHC.Generics.Generic (SreBox.PostgresMigrations.MigrationPlan a)
- SreBox.PostgresMigrations: instance GHC.Generics.Generic SreBox.PostgresMigrations.MigrationFile
- SreBox.PostgresMigrations: instance GHC.Generics.Generic SreBox.PostgresMigrations.MigrationSetup
- SreBox.PostgresMigrations: instance GHC.Generics.Generic SreBox.PostgresMigrations.PrepareRemoteMigrationSetup
- SreBox.PostgresMigrations: instance GHC.Show.Show SreBox.PostgresMigrations.MigrationFile
- SreBox.PostgresMigrations: instance GHC.Show.Show SreBox.PostgresMigrations.Report
- SreBox.PostgresPair: instance GHC.Generics.Generic SreBox.PostgresPair.Bouncer
- SreBox.PostgresPair: instance GHC.Generics.Generic SreBox.PostgresPair.Lsn
- SreBox.PostgresPair: instance GHC.Generics.Generic SreBox.PostgresPair.Member
- SreBox.PostgresPair: instance GHC.Generics.Generic SreBox.PostgresPair.Pair
- SreBox.PostgresPair: instance GHC.Generics.Generic SreBox.PostgresPair.Side
- SreBox.PostgresPair: instance GHC.Show.Show SreBox.PostgresPair.Bouncer
- SreBox.PostgresPair: instance GHC.Show.Show SreBox.PostgresPair.BouncerState
- SreBox.PostgresPair: instance GHC.Show.Show SreBox.PostgresPair.Lsn
- SreBox.PostgresPair: instance GHC.Show.Show SreBox.PostgresPair.Member
- SreBox.PostgresPair: instance GHC.Show.Show SreBox.PostgresPair.Observed
- SreBox.PostgresPair: instance GHC.Show.Show SreBox.PostgresPair.Pair
- SreBox.PostgresPair: instance GHC.Show.Show SreBox.PostgresPair.Report
- SreBox.PostgresPair: instance GHC.Show.Show SreBox.PostgresPair.Side
- SreBox.PostgresPair: instance GHC.Show.Show SreBox.PostgresPair.Step
- SreBox.PostgresPair: instance GHC.Show.Show SreBox.PostgresPair.Target
- SreBox.PostgresTemplate: instance GHC.Generics.Generic SreBox.PostgresTemplate.Template
- SreBox.PostgresTemplate: instance GHC.Show.Show SreBox.PostgresTemplate.Report
- SreBox.PostgresTemplate: instance GHC.Show.Show SreBox.PostgresTemplate.Template
- SreBox.PostgresTls: instance GHC.Show.Show SreBox.PostgresTls.ClientAccess
- SreBox.PostgresTls: instance GHC.Show.Show SreBox.PostgresTls.Report
- SreBox.PostgresTls: instance GHC.Show.Show SreBox.PostgresTls.SslMode
- SreBox.Postgrest: instance GHC.Generics.Generic SreBox.Postgrest.PostgrestSetup
- SreBox.Postgrest: instance GHC.Show.Show SreBox.Postgrest.Report
- SreBox.WireGuardVpn: instance GHC.Show.Show SreBox.WireGuardVpn.Report
+ SreBox.CabalBuilding: instance GHC.Internal.Show.Show SreBox.CabalBuilding.Report
+ SreBox.DNSRegistration: instance GHC.Internal.Generics.Generic SreBox.DNSRegistration.RegisteredMachineSetup
+ SreBox.DNSRegistration: instance GHC.Internal.Show.Show SreBox.DNSRegistration.Report
+ SreBox.Environment: instance GHC.Internal.Show.Show SreBox.Environment.Environment
+ SreBox.Gcp.CloudRunAlerts: instance GHC.Internal.Show.Show SreBox.Gcp.CloudRunAlerts.AlertThresholds
+ SreBox.Gcp.CloudRunAlerts: instance GHC.Internal.Show.Show SreBox.Gcp.CloudRunAlerts.CloudRunAlertsConfig
+ SreBox.Gcp.CloudRunAlerts: instance GHC.Internal.Show.Show SreBox.Gcp.CloudRunAlerts.Report
+ SreBox.Gcp.CloudRunAlerts: standardAlertsAfter :: [Op] -> Reporter Report -> Track' (Binary "gcloud") -> CloudRunAlertsConfig -> Op
+ SreBox.Gcp.CloudRunDeploy: buildPushDeployAfter :: [Op] -> [Op] -> Reporter Report -> Track' (Binary "gcloud") -> Track' (Binary "podman") -> CloudRunDeployConfig -> Op
+ SreBox.Gcp.CloudRunDeploy: instance GHC.Internal.Show.Show SreBox.Gcp.CloudRunDeploy.Report
+ SreBox.Gcp.PlakarGcsBackup: GcsBackup :: Project -> Text -> Region -> Text -> FilePath -> Text -> Text -> IntegrationVersion -> Text -> FilePath -> Text -> FilePath -> Schedule -> Text -> KeepPolicy -> NominalDiffTime -> FilePath -> GcsBackup
+ SreBox.Gcp.PlakarGcsBackup: IamReport :: !Report -> Report
+ SreBox.Gcp.PlakarGcsBackup: StorageReport :: !Report -> Report
+ SreBox.Gcp.PlakarGcsBackup: [gbBucket] :: GcsBackup -> Text
+ SreBox.Gcp.PlakarGcsBackup: [gbCredentialsFile] :: GcsBackup -> FilePath
+ SreBox.Gcp.PlakarGcsBackup: [gbCredentialsOption] :: GcsBackup -> Text
+ SreBox.Gcp.PlakarGcsBackup: [gbGcsVersion] :: GcsBackup -> IntegrationVersion
+ SreBox.Gcp.PlakarGcsBackup: [gbJobName] :: GcsBackup -> Text
+ SreBox.Gcp.PlakarGcsBackup: [gbKeep] :: GcsBackup -> KeepPolicy
+ SreBox.Gcp.PlakarGcsBackup: [gbKeyfile] :: GcsBackup -> FilePath
+ SreBox.Gcp.PlakarGcsBackup: [gbLocation] :: GcsBackup -> Region
+ SreBox.Gcp.PlakarGcsBackup: [gbMaxAge] :: GcsBackup -> NominalDiffTime
+ SreBox.Gcp.PlakarGcsBackup: [gbProject] :: GcsBackup -> Project
+ SreBox.Gcp.PlakarGcsBackup: [gbSchedule] :: GcsBackup -> Schedule
+ SreBox.Gcp.PlakarGcsBackup: [gbScriptPath] :: GcsBackup -> FilePath
+ SreBox.Gcp.PlakarGcsBackup: [gbServiceAccountId] :: GcsBackup -> Text
+ SreBox.Gcp.PlakarGcsBackup: [gbSource] :: GcsBackup -> FilePath
+ SreBox.Gcp.PlakarGcsBackup: [gbStoreName] :: GcsBackup -> Text
+ SreBox.Gcp.PlakarGcsBackup: [gbStorePrefix] :: GcsBackup -> Text
+ SreBox.Gcp.PlakarGcsBackup: [gbUser] :: GcsBackup -> Text
+ SreBox.Gcp.PlakarGcsBackup: data GcsBackup
+ SreBox.Gcp.PlakarGcsBackup: data Report
+ SreBox.Gcp.PlakarGcsBackup: gcsBackup :: Reporter Report -> Track' (Binary "plakar") -> GcsBackup -> Op
+ SreBox.Gcp.PlakarGcsBackup: gcsBucketRole :: Text
+ SreBox.Gcp.PlakarGcsBackup: gcsServiceAccountEmail :: GcsBackup -> Text
+ SreBox.Gcp.PlakarGcsBackup: instance GHC.Internal.Show.Show SreBox.Gcp.PlakarGcsBackup.Report
+ SreBox.Gcp.PostgrestCloudRun: instance GHC.Internal.Show.Show SreBox.Gcp.PostgrestCloudRun.Report
+ SreBox.Gcp.PreviewEnvironment: instance GHC.Internal.Show.Show SreBox.Gcp.PreviewEnvironment.Report
+ SreBox.Gcp.VmProvision: RunHandOff :: !Report -> Report
+ SreBox.Gcp.VmProvision: caTrustStartupScript :: Text -> Text
+ SreBox.Gcp.VmProvision: caTrustStartupScriptFile :: FilePath -> Text -> Op
+ SreBox.Gcp.VmProvision: handOff :: (FromJSON directive, ToJSON directive) => Reporter Report -> VmProvisionConfig directive -> [Op] -> Op
+ SreBox.Gcp.VmProvision: instance GHC.Internal.Show.Show SreBox.Gcp.VmProvision.Report
+ SreBox.Gcp.VmProvision: provisionedVmReadingHost :: (FromJSON directive, ToJSON directive) => Reporter Report -> Track' (Binary "gcloud") -> Track' (Binary "ssh-keygen") -> IO (Maybe Text) -> (Text -> VmProvisionConfig directive -> VmProvisionConfig directive) -> VmProvisionConfig directive -> Op
+ SreBox.Gcp.VmProvision: withStartupScriptFile :: FilePath -> Instance -> Instance
+ SreBox.JWTSigning: instance GHC.Internal.Show.Show SreBox.JWTSigning.Report
+ SreBox.MicroDNS: instance GHC.Internal.Generics.Generic SreBox.MicroDNS.MicroDNSSetup
+ SreBox.MicroDNS: instance GHC.Internal.Show.Show SreBox.MicroDNS.Report
+ SreBox.PgVectorDemo: Candidate :: FilePath -> Kind -> Text -> Candidate
+ SreBox.PgVectorDemo: Demo :: FilePath -> Int -> Port -> DatabaseName -> Text -> WalkPolicy -> Demo
+ SreBox.PgVectorDemo: EmbedFailed :: !FilePath -> !Text -> Report
+ SreBox.PgVectorDemo: Embedder :: Text -> Int -> [Kind] -> Op -> (Input -> IO (Either Text [Double])) -> Embedder
+ SreBox.PgVectorDemo: Empty :: Skip
+ SreBox.PgVectorDemo: FileSkipped :: !FilePath -> !Skip -> Report
+ SreBox.PgVectorDemo: Hidden :: Skip
+ SreBox.PgVectorDemo: Hit :: FilePath -> Text -> Double -> Hit
+ SreBox.PgVectorDemo: ImageKind :: Kind
+ SreBox.PgVectorDemo: Indexed :: !FilePath -> !Kind -> Report
+ SreBox.PgVectorDemo: InputImage :: FilePath -> Input
+ SreBox.PgVectorDemo: InputText :: Text -> Input
+ SreBox.PgVectorDemo: NotEmbedded :: !Kind -> Skip
+ SreBox.PgVectorDemo: StaleRows :: !Int -> Report
+ SreBox.PgVectorDemo: SymbolicLink :: Skip
+ SreBox.PgVectorDemo: TextKind :: Kind
+ SreBox.PgVectorDemo: TooLarge :: !Integer -> Skip
+ SreBox.PgVectorDemo: UnknownExtension :: Skip
+ SreBox.PgVectorDemo: WalkPolicy :: [String] -> [String] -> Integer -> Integer -> WalkPolicy
+ SreBox.PgVectorDemo: [candHash] :: Candidate -> Text
+ SreBox.PgVectorDemo: [candKind] :: Candidate -> Kind
+ SreBox.PgVectorDemo: [candPath] :: Candidate -> FilePath
+ SreBox.PgVectorDemo: [embDimension] :: Embedder -> Int
+ SreBox.PgVectorDemo: [embEmbed] :: Embedder -> Input -> IO (Either Text [Double])
+ SreBox.PgVectorDemo: [embKinds] :: Embedder -> [Kind]
+ SreBox.PgVectorDemo: [embName] :: Embedder -> Text
+ SreBox.PgVectorDemo: [embNode] :: Embedder -> Op
+ SreBox.PgVectorDemo: [hitDistance] :: Hit -> Double
+ SreBox.PgVectorDemo: [hitKind] :: Hit -> Text
+ SreBox.PgVectorDemo: [hitPath] :: Hit -> FilePath
+ SreBox.PgVectorDemo: [imageExtensions] :: WalkPolicy -> [String]
+ SreBox.PgVectorDemo: [maxImageBytes] :: WalkPolicy -> Integer
+ SreBox.PgVectorDemo: [maxTextBytes] :: WalkPolicy -> Integer
+ SreBox.PgVectorDemo: [pvdDatabase] :: Demo -> DatabaseName
+ SreBox.PgVectorDemo: [pvdDirectory] :: Demo -> FilePath
+ SreBox.PgVectorDemo: [pvdMajor] :: Demo -> Int
+ SreBox.PgVectorDemo: [pvdPolicy] :: Demo -> WalkPolicy
+ SreBox.PgVectorDemo: [pvdPort] :: Demo -> Port
+ SreBox.PgVectorDemo: [pvdTable] :: Demo -> Text
+ SreBox.PgVectorDemo: [textExtensions] :: WalkPolicy -> [String]
+ SreBox.PgVectorDemo: classify :: WalkPolicy -> [Kind] -> FilePath -> Integer -> Either Skip Kind
+ SreBox.PgVectorDemo: clipEmbedder :: Reporter Report -> ClipTools -> ClipCpp -> Embedder
+ SreBox.PgVectorDemo: contentHash :: ByteString -> Text
+ SreBox.PgVectorDemo: data Candidate
+ SreBox.PgVectorDemo: data Demo
+ SreBox.PgVectorDemo: data Embedder
+ SreBox.PgVectorDemo: data Hit
+ SreBox.PgVectorDemo: data Input
+ SreBox.PgVectorDemo: data Kind
+ SreBox.PgVectorDemo: data Report
+ SreBox.PgVectorDemo: data Skip
+ SreBox.PgVectorDemo: data WalkPolicy
+ SreBox.PgVectorDemo: defaultDemo :: FilePath -> Int -> Port -> DatabaseName -> Demo
+ SreBox.PgVectorDemo: defaultWalkPolicy :: WalkPolicy
+ SreBox.PgVectorDemo: hashEmbedder :: Int -> Embedder
+ SreBox.PgVectorDemo: hashVector :: Int -> ByteString -> [Double]
+ SreBox.PgVectorDemo: indexDirectory :: Reporter Report -> Demo -> Embedder -> Op
+ SreBox.PgVectorDemo: indexedSql :: Text -> Text
+ SreBox.PgVectorDemo: inspectSchemaSql :: Text -> Text
+ SreBox.PgVectorDemo: instance GHC.Classes.Eq SreBox.PgVectorDemo.Candidate
+ SreBox.PgVectorDemo: instance GHC.Classes.Eq SreBox.PgVectorDemo.Demo
+ SreBox.PgVectorDemo: instance GHC.Classes.Eq SreBox.PgVectorDemo.Hit
+ SreBox.PgVectorDemo: instance GHC.Classes.Eq SreBox.PgVectorDemo.Input
+ SreBox.PgVectorDemo: instance GHC.Classes.Eq SreBox.PgVectorDemo.Kind
+ SreBox.PgVectorDemo: instance GHC.Classes.Eq SreBox.PgVectorDemo.Skip
+ SreBox.PgVectorDemo: instance GHC.Classes.Eq SreBox.PgVectorDemo.WalkPolicy
+ SreBox.PgVectorDemo: instance GHC.Classes.Ord SreBox.PgVectorDemo.Kind
+ SreBox.PgVectorDemo: instance GHC.Internal.Show.Show SreBox.PgVectorDemo.Candidate
+ SreBox.PgVectorDemo: instance GHC.Internal.Show.Show SreBox.PgVectorDemo.Demo
+ SreBox.PgVectorDemo: instance GHC.Internal.Show.Show SreBox.PgVectorDemo.Hit
+ SreBox.PgVectorDemo: instance GHC.Internal.Show.Show SreBox.PgVectorDemo.Input
+ SreBox.PgVectorDemo: instance GHC.Internal.Show.Show SreBox.PgVectorDemo.Kind
+ SreBox.PgVectorDemo: instance GHC.Internal.Show.Show SreBox.PgVectorDemo.Report
+ SreBox.PgVectorDemo: instance GHC.Internal.Show.Show SreBox.PgVectorDemo.Skip
+ SreBox.PgVectorDemo: instance GHC.Internal.Show.Show SreBox.PgVectorDemo.WalkPolicy
+ SreBox.PgVectorDemo: interpretSchemaRow :: Text -> Int -> Text -> CheckResult
+ SreBox.PgVectorDemo: kindName :: Kind -> Text
+ SreBox.PgVectorDemo: llamaEmbedder :: Op -> LlamaServer -> Embedder
+ SreBox.PgVectorDemo: parseHits :: Text -> [Hit]
+ SreBox.PgVectorDemo: parseIndexed :: Text -> Map FilePath (Text, Text)
+ SreBox.PgVectorDemo: pendingFiles :: Text -> [Candidate] -> Map FilePath (Text, Text) -> [Candidate]
+ SreBox.PgVectorDemo: pgVectorDemo :: Reporter Report -> Reporter Report -> Reporter Report -> Track' (Binary "psql") -> Track' () -> Track' DatabaseName -> Demo -> Embedder -> Op
+ SreBox.PgVectorDemo: schema :: Demo -> Int -> Op
+ SreBox.PgVectorDemo: schemaSql :: Text -> Int -> Text
+ SreBox.PgVectorDemo: search :: Demo -> Embedder -> Input -> Int -> IO (Either Text [Hit])
+ SreBox.PgVectorDemo: searchSql :: Text -> Text -> [Double] -> Int -> Text
+ SreBox.PgVectorDemo: staleCount :: [Candidate] -> Map FilePath (Text, Text) -> Int
+ SreBox.PgVectorDemo: upsertSql :: Text -> Text -> Candidate -> [Double] -> Text
+ SreBox.PgVectorDemo: vectorLiteral :: [Double] -> Text
+ SreBox.PgVectorDemo: walk :: WalkPolicy -> [Kind] -> FilePath -> IO ([(FilePath, Kind)], [(FilePath, Skip)])
+ SreBox.PostgresBackup: instance GHC.Internal.Generics.Generic SreBox.PostgresBackup.Credentials
+ SreBox.PostgresBackup: instance GHC.Internal.Generics.Generic SreBox.PostgresBackup.GcsDestination
+ SreBox.PostgresBackup: instance GHC.Internal.Generics.Generic SreBox.PostgresBackup.NamingPolicy
+ SreBox.PostgresBackup: instance GHC.Internal.Generics.Generic SreBox.PostgresBackup.PgBackupConfig
+ SreBox.PostgresBackup: instance GHC.Internal.Show.Show SreBox.PostgresBackup.Credentials
+ SreBox.PostgresBackup: instance GHC.Internal.Show.Show SreBox.PostgresBackup.GcsDestination
+ SreBox.PostgresBackup: instance GHC.Internal.Show.Show SreBox.PostgresBackup.NamingPolicy
+ SreBox.PostgresBackup: instance GHC.Internal.Show.Show SreBox.PostgresBackup.PgBackupConfig
+ SreBox.PostgresBackup: instance GHC.Internal.Show.Show SreBox.PostgresBackup.Report
+ SreBox.PostgresInit: instance GHC.Internal.Generics.Generic (SreBox.PostgresInit.InitSetup pass)
+ SreBox.PostgresInit: instance GHC.Internal.Show.Show SreBox.PostgresInit.Report
+ SreBox.PostgresMigrations: instance GHC.Internal.Generics.Generic (SreBox.PostgresMigrations.MigrationPlan a)
+ SreBox.PostgresMigrations: instance GHC.Internal.Generics.Generic SreBox.PostgresMigrations.MigrationFile
+ SreBox.PostgresMigrations: instance GHC.Internal.Generics.Generic SreBox.PostgresMigrations.MigrationSetup
+ SreBox.PostgresMigrations: instance GHC.Internal.Generics.Generic SreBox.PostgresMigrations.PrepareRemoteMigrationSetup
+ SreBox.PostgresMigrations: instance GHC.Internal.Show.Show SreBox.PostgresMigrations.MigrationFile
+ SreBox.PostgresMigrations: instance GHC.Internal.Show.Show SreBox.PostgresMigrations.Report
+ SreBox.PostgresMigrations: remoteMigrateOpaqueSetupAfter :: (FromJSON directive, ToJSON directive) => [Op] -> Text -> Reporter Report -> Track' directive -> Remote -> SelfPath -> (Either PrepareRemoteMigrationSetup MigrationSetup -> directive) -> RemoteMigrateConfig TrackedIO -> Op
+ SreBox.PostgresPair: ClientCerts :: FilePath -> FilePath -> FilePath -> FilePath -> ClientCerts
+ SreBox.PostgresPair: Encrypted :: ServerCheck
+ SreBox.PostgresPair: PlainMd5 :: ConnSecurity
+ SreBox.PostgresPair: RoutedDatabase :: Text -> Text -> RoutedDatabase
+ SreBox.PostgresPair: ServerFiles :: FilePath -> FilePath -> Maybe FilePath -> ServerFiles
+ SreBox.PostgresPair: Tls :: ServerCheck -> Maybe ServerFiles -> Tls
+ SreBox.PostgresPair: TlsClientCert :: Tls -> ClientCerts -> ConnSecurity
+ SreBox.PostgresPair: TlsScram :: Tls -> ConnSecurity
+ SreBox.PostgresPair: VerifyCa :: FilePath -> ServerCheck
+ SreBox.PostgresPair: VerifyFull :: FilePath -> ServerCheck
+ SreBox.PostgresPair: [bouncer_more_databases] :: Bouncer -> Maybe [RoutedDatabase]
+ SreBox.PostgresPair: [client_repl_cert] :: ClientCerts -> FilePath
+ SreBox.PostgresPair: [client_repl_key] :: ClientCerts -> FilePath
+ SreBox.PostgresPair: [client_rewind_cert] :: ClientCerts -> FilePath
+ SreBox.PostgresPair: [client_rewind_key] :: ClientCerts -> FilePath
+ SreBox.PostgresPair: [member_ssh_host] :: Member -> Maybe Text
+ SreBox.PostgresPair: [pair_conn_security] :: Pair -> Maybe ConnSecurity
+ SreBox.PostgresPair: [routed_alias] :: RoutedDatabase -> Text
+ SreBox.PostgresPair: [routed_dbname] :: RoutedDatabase -> Text
+ SreBox.PostgresPair: [server_cert] :: ServerFiles -> FilePath
+ SreBox.PostgresPair: [server_client_ca] :: ServerFiles -> Maybe FilePath
+ SreBox.PostgresPair: [server_key] :: ServerFiles -> FilePath
+ SreBox.PostgresPair: [tls_check] :: Tls -> ServerCheck
+ SreBox.PostgresPair: [tls_server] :: Tls -> Maybe ServerFiles
+ SreBox.PostgresPair: bouncerProblems :: Pair -> [Text]
+ SreBox.PostgresPair: connSecurity :: Pair -> ConnSecurity
+ SreBox.PostgresPair: data ClientCerts
+ SreBox.PostgresPair: data ConnSecurity
+ SreBox.PostgresPair: data RoutedDatabase
+ SreBox.PostgresPair: data ServerCheck
+ SreBox.PostgresPair: data ServerFiles
+ SreBox.PostgresPair: data Tls
+ SreBox.PostgresPair: hbaLinesFor :: Pair -> Side -> ConnSecurity -> [Text]
+ SreBox.PostgresPair: hbaLinesRetired :: Pair -> Side -> ConnSecurity -> [Text]
+ SreBox.PostgresPair: instance Data.Aeson.Types.FromJSON.FromJSON SreBox.PostgresPair.ClientCerts
+ SreBox.PostgresPair: instance Data.Aeson.Types.FromJSON.FromJSON SreBox.PostgresPair.ConnSecurity
+ SreBox.PostgresPair: instance Data.Aeson.Types.FromJSON.FromJSON SreBox.PostgresPair.RoutedDatabase
+ SreBox.PostgresPair: instance Data.Aeson.Types.FromJSON.FromJSON SreBox.PostgresPair.ServerCheck
+ SreBox.PostgresPair: instance Data.Aeson.Types.FromJSON.FromJSON SreBox.PostgresPair.ServerFiles
+ SreBox.PostgresPair: instance Data.Aeson.Types.FromJSON.FromJSON SreBox.PostgresPair.Tls
+ SreBox.PostgresPair: instance Data.Aeson.Types.ToJSON.ToJSON SreBox.PostgresPair.ClientCerts
+ SreBox.PostgresPair: instance Data.Aeson.Types.ToJSON.ToJSON SreBox.PostgresPair.ConnSecurity
+ SreBox.PostgresPair: instance Data.Aeson.Types.ToJSON.ToJSON SreBox.PostgresPair.RoutedDatabase
+ SreBox.PostgresPair: instance Data.Aeson.Types.ToJSON.ToJSON SreBox.PostgresPair.ServerCheck
+ SreBox.PostgresPair: instance Data.Aeson.Types.ToJSON.ToJSON SreBox.PostgresPair.ServerFiles
+ SreBox.PostgresPair: instance Data.Aeson.Types.ToJSON.ToJSON SreBox.PostgresPair.Tls
+ SreBox.PostgresPair: instance GHC.Classes.Eq SreBox.PostgresPair.ClientCerts
+ SreBox.PostgresPair: instance GHC.Classes.Eq SreBox.PostgresPair.ConnSecurity
+ SreBox.PostgresPair: instance GHC.Classes.Eq SreBox.PostgresPair.RoutedDatabase
+ SreBox.PostgresPair: instance GHC.Classes.Eq SreBox.PostgresPair.ServerCheck
+ SreBox.PostgresPair: instance GHC.Classes.Eq SreBox.PostgresPair.ServerFiles
+ SreBox.PostgresPair: instance GHC.Classes.Eq SreBox.PostgresPair.Tls
+ SreBox.PostgresPair: instance GHC.Internal.Generics.Generic SreBox.PostgresPair.Bouncer
+ SreBox.PostgresPair: instance GHC.Internal.Generics.Generic SreBox.PostgresPair.ClientCerts
+ SreBox.PostgresPair: instance GHC.Internal.Generics.Generic SreBox.PostgresPair.ConnSecurity
+ SreBox.PostgresPair: instance GHC.Internal.Generics.Generic SreBox.PostgresPair.Lsn
+ SreBox.PostgresPair: instance GHC.Internal.Generics.Generic SreBox.PostgresPair.Member
+ SreBox.PostgresPair: instance GHC.Internal.Generics.Generic SreBox.PostgresPair.Pair
+ SreBox.PostgresPair: instance GHC.Internal.Generics.Generic SreBox.PostgresPair.RoutedDatabase
+ SreBox.PostgresPair: instance GHC.Internal.Generics.Generic SreBox.PostgresPair.ServerCheck
+ SreBox.PostgresPair: instance GHC.Internal.Generics.Generic SreBox.PostgresPair.ServerFiles
+ SreBox.PostgresPair: instance GHC.Internal.Generics.Generic SreBox.PostgresPair.Side
+ SreBox.PostgresPair: instance GHC.Internal.Generics.Generic SreBox.PostgresPair.Tls
+ SreBox.PostgresPair: instance GHC.Internal.Show.Show SreBox.PostgresPair.Bouncer
+ SreBox.PostgresPair: instance GHC.Internal.Show.Show SreBox.PostgresPair.BouncerState
+ SreBox.PostgresPair: instance GHC.Internal.Show.Show SreBox.PostgresPair.ClientCerts
+ SreBox.PostgresPair: instance GHC.Internal.Show.Show SreBox.PostgresPair.ConnSecurity
+ SreBox.PostgresPair: instance GHC.Internal.Show.Show SreBox.PostgresPair.Lsn
+ SreBox.PostgresPair: instance GHC.Internal.Show.Show SreBox.PostgresPair.Member
+ SreBox.PostgresPair: instance GHC.Internal.Show.Show SreBox.PostgresPair.Observed
+ SreBox.PostgresPair: instance GHC.Internal.Show.Show SreBox.PostgresPair.Pair
+ SreBox.PostgresPair: instance GHC.Internal.Show.Show SreBox.PostgresPair.Report
+ SreBox.PostgresPair: instance GHC.Internal.Show.Show SreBox.PostgresPair.RoutedDatabase
+ SreBox.PostgresPair: instance GHC.Internal.Show.Show SreBox.PostgresPair.ServerCheck
+ SreBox.PostgresPair: instance GHC.Internal.Show.Show SreBox.PostgresPair.ServerFiles
+ SreBox.PostgresPair: instance GHC.Internal.Show.Show SreBox.PostgresPair.Side
+ SreBox.PostgresPair: instance GHC.Internal.Show.Show SreBox.PostgresPair.Step
+ SreBox.PostgresPair: instance GHC.Internal.Show.Show SreBox.PostgresPair.Target
+ SreBox.PostgresPair: instance GHC.Internal.Show.Show SreBox.PostgresPair.Tls
+ SreBox.PostgresPair: memberSshHost :: Member -> Text
+ SreBox.PostgresPair: parseBouncerStates :: Bouncer -> Text -> [BouncerState]
+ SreBox.PostgresPair: remoteCommand :: Target -> String -> [String]
+ SreBox.PostgresPair: routedDatabases :: Bouncer -> [RoutedDatabase]
+ SreBox.PostgresPair: securityProblems :: Pair -> [Text]
+ SreBox.PostgresPair: sshLogin :: Target -> Text
+ SreBox.PostgresPair: sshToTarget :: Pair -> Target -> String -> IO (ExitCode, Text, Text)
+ SreBox.PostgresPair: sshUser :: Target -> Text
+ SreBox.PostgresPairPrereqs: Application :: Text -> Text -> FilePath -> [Text] -> Text -> Application
+ SreBox.PostgresPairPrereqs: Prereqs :: [Text] -> [Text] -> SecretFile -> SecretFile -> SecretFile -> SecretFile -> [Application] -> Prereqs
+ SreBox.PostgresPairPrereqs: SecretFile :: Maybe FilePath -> Text -> Text -> Text -> SecretFile
+ SreBox.PostgresPairPrereqs: [app_clients] :: Application -> [Text]
+ SreBox.PostgresPairPrereqs: [app_database] :: Application -> Text
+ SreBox.PostgresPairPrereqs: [app_hba_method] :: Application -> Text
+ SreBox.PostgresPairPrereqs: [app_passfile] :: Application -> FilePath
+ SreBox.PostgresPairPrereqs: [app_role] :: Application -> Text
+ SreBox.PostgresPairPrereqs: [prereq_applications] :: Prereqs -> [Application]
+ SreBox.PostgresPairPrereqs: [prereq_bouncer_packages] :: Prereqs -> [Text]
+ SreBox.PostgresPairPrereqs: [prereq_console_passfile] :: Prereqs -> SecretFile
+ SreBox.PostgresPairPrereqs: [prereq_member_packages] :: Prereqs -> [Text]
+ SreBox.PostgresPairPrereqs: [prereq_repl_passfile] :: Prereqs -> SecretFile
+ SreBox.PostgresPairPrereqs: [prereq_rewind_passfile] :: Prereqs -> SecretFile
+ SreBox.PostgresPairPrereqs: [prereq_userlist] :: Prereqs -> SecretFile
+ SreBox.PostgresPairPrereqs: [secret_from] :: SecretFile -> Maybe FilePath
+ SreBox.PostgresPairPrereqs: [secret_group] :: SecretFile -> Text
+ SreBox.PostgresPairPrereqs: [secret_mode] :: SecretFile -> Text
+ SreBox.PostgresPairPrereqs: [secret_owner] :: SecretFile -> Text
+ SreBox.PostgresPairPrereqs: applications :: Reporter Report -> Pair -> Prereqs -> Side -> Op
+ SreBox.PostgresPairPrereqs: applicationsScript :: Pair -> Prereqs -> Side -> String
+ SreBox.PostgresPairPrereqs: bouncerPrereqs :: Reporter Report -> Pair -> Prereqs -> Bouncer -> Op
+ SreBox.PostgresPairPrereqs: bouncerPrereqsScript :: Prereqs -> Bouncer -> String
+ SreBox.PostgresPairPrereqs: data Application
+ SreBox.PostgresPairPrereqs: data Prereqs
+ SreBox.PostgresPairPrereqs: data SecretFile
+ SreBox.PostgresPairPrereqs: defaultPrereqs :: Prereqs
+ SreBox.PostgresPairPrereqs: hbaLines :: Prereqs -> [Text]
+ SreBox.PostgresPairPrereqs: inPlace :: SecretFile
+ SreBox.PostgresPairPrereqs: instance Data.Aeson.Types.FromJSON.FromJSON SreBox.PostgresPairPrereqs.Application
+ SreBox.PostgresPairPrereqs: instance Data.Aeson.Types.FromJSON.FromJSON SreBox.PostgresPairPrereqs.Prereqs
+ SreBox.PostgresPairPrereqs: instance Data.Aeson.Types.FromJSON.FromJSON SreBox.PostgresPairPrereqs.SecretFile
+ SreBox.PostgresPairPrereqs: instance Data.Aeson.Types.ToJSON.ToJSON SreBox.PostgresPairPrereqs.Application
+ SreBox.PostgresPairPrereqs: instance Data.Aeson.Types.ToJSON.ToJSON SreBox.PostgresPairPrereqs.Prereqs
+ SreBox.PostgresPairPrereqs: instance Data.Aeson.Types.ToJSON.ToJSON SreBox.PostgresPairPrereqs.SecretFile
+ SreBox.PostgresPairPrereqs: instance GHC.Classes.Eq SreBox.PostgresPairPrereqs.Application
+ SreBox.PostgresPairPrereqs: instance GHC.Classes.Eq SreBox.PostgresPairPrereqs.Prereqs
+ SreBox.PostgresPairPrereqs: instance GHC.Classes.Eq SreBox.PostgresPairPrereqs.SecretFile
+ SreBox.PostgresPairPrereqs: instance GHC.Internal.Generics.Generic SreBox.PostgresPairPrereqs.Application
+ SreBox.PostgresPairPrereqs: instance GHC.Internal.Generics.Generic SreBox.PostgresPairPrereqs.Prereqs
+ SreBox.PostgresPairPrereqs: instance GHC.Internal.Generics.Generic SreBox.PostgresPairPrereqs.SecretFile
+ SreBox.PostgresPairPrereqs: instance GHC.Internal.Show.Show SreBox.PostgresPairPrereqs.Application
+ SreBox.PostgresPairPrereqs: instance GHC.Internal.Show.Show SreBox.PostgresPairPrereqs.Prereqs
+ SreBox.PostgresPairPrereqs: instance GHC.Internal.Show.Show SreBox.PostgresPairPrereqs.SecretFile
+ SreBox.PostgresPairPrereqs: memberPrereqs :: Reporter Report -> Pair -> Prereqs -> Side -> Op
+ SreBox.PostgresPairPrereqs: memberPrereqsScript :: Pair -> Prereqs -> Side -> String
+ SreBox.PostgresPairPrereqs: pairWithPrereqs :: Reporter Report -> Pair -> Prereqs -> Op
+ SreBox.PostgresPairPrereqs: postgresOwned :: FilePath -> SecretFile
+ SreBox.PostgresPairPrereqs: validate :: Pair -> Prereqs -> [Text]
+ SreBox.PostgresTemplate: instance GHC.Internal.Generics.Generic SreBox.PostgresTemplate.Template
+ SreBox.PostgresTemplate: instance GHC.Internal.Show.Show SreBox.PostgresTemplate.Report
+ SreBox.PostgresTemplate: instance GHC.Internal.Show.Show SreBox.PostgresTemplate.Template
+ SreBox.PostgresTls: instance GHC.Internal.Show.Show SreBox.PostgresTls.ClientAccess
+ SreBox.PostgresTls: instance GHC.Internal.Show.Show SreBox.PostgresTls.Report
+ SreBox.PostgresTls: instance GHC.Internal.Show.Show SreBox.PostgresTls.SslMode
+ SreBox.Postgrest: instance GHC.Internal.Generics.Generic SreBox.Postgrest.PostgrestSetup
+ SreBox.Postgrest: instance GHC.Internal.Show.Show SreBox.Postgrest.Report
+ SreBox.WireGuardMesh: AddressOutsideSubnet :: Text -> Text -> MeshError
+ SreBox.WireGuardMesh: AnyProto :: Proto
+ SreBox.WireGuardMesh: BadAddress :: Text -> Text -> MeshError
+ SreBox.WireGuardMesh: BadEndpoint :: Text -> Text -> MeshError
+ SreBox.WireGuardMesh: BadNetwork :: Text -> Text -> MeshError
+ SreBox.WireGuardMesh: BadPorts :: Int -> Text -> MeshError
+ SreBox.WireGuardMesh: BadSubnet :: Text -> MeshError
+ SreBox.WireGuardMesh: Binaries :: Track' (Binary "wg") -> Track' (Binary "ip") -> Track' (Binary "nft") -> Track' (Binary "sysctl") -> Binaries
+ SreBox.WireGuardMesh: DuplicateAddress :: Text -> [Text] -> MeshError
+ SreBox.WireGuardMesh: DuplicatePeerName :: Text -> MeshError
+ SreBox.WireGuardMesh: DuplicatePubkey :: [Text] -> MeshError
+ SreBox.WireGuardMesh: DuplicateRouterNetwork :: Text -> MeshError
+ SreBox.WireGuardMesh: ExitEndpointNotLiteral :: Text -> MeshError
+ SreBox.WireGuardMesh: HostSpec :: Text -> Text -> Text -> Int -> FilePath -> [PeerLink] -> [Text] -> [Text] -> Maybe Text -> [NftRuleSpec] -> HostSpec
+ SreBox.WireGuardMesh: Icmp :: Proto
+ SreBox.WireGuardMesh: MeshSeed :: Text -> Text -> Int -> FilePath -> [PeerDecl] -> [Policy] -> [RouterDecl] -> MeshSeed
+ SreBox.WireGuardMesh: NftRuleSpec :: [Text] -> NftRuleSpec
+ SreBox.WireGuardMesh: PeerDecl :: Text -> Text -> Text -> Maybe Text -> [Text] -> PeerDecl
+ SreBox.WireGuardMesh: PeerLink :: Text -> Text -> Maybe Text -> [Text] -> Maybe Int -> PeerLink
+ SreBox.WireGuardMesh: Policy :: [Text] -> [Text] -> Proto -> [PortRange] -> Policy
+ SreBox.WireGuardMesh: PortRange :: Int -> Int -> PortRange
+ SreBox.WireGuardMesh: RouterDecl :: Text -> Text -> RouterDecl
+ SreBox.WireGuardMesh: SeveralExitRouters :: [Text] -> MeshError
+ SreBox.WireGuardMesh: Tcp :: Proto
+ SreBox.WireGuardMesh: Udp :: Proto
+ SreBox.WireGuardMesh: UnknownGroup :: Int -> Text -> MeshError
+ SreBox.WireGuardMesh: UnknownHost :: Text -> MeshError
+ SreBox.WireGuardMesh: UnknownRouterPeer :: Text -> MeshError
+ SreBox.WireGuardMesh: [binIp] :: Binaries -> Track' (Binary "ip")
+ SreBox.WireGuardMesh: [binNft] :: Binaries -> Track' (Binary "nft")
+ SreBox.WireGuardMesh: [binSysctl] :: Binaries -> Track' (Binary "sysctl")
+ SreBox.WireGuardMesh: [binWg] :: Binaries -> Track' (Binary "wg")
+ SreBox.WireGuardMesh: [host_addr] :: HostSpec -> Text
+ SreBox.WireGuardMesh: [host_exit_pins] :: HostSpec -> [Text]
+ SreBox.WireGuardMesh: [host_forwards] :: HostSpec -> Maybe Text
+ SreBox.WireGuardMesh: [host_iface] :: HostSpec -> Text
+ SreBox.WireGuardMesh: [host_input_rules] :: HostSpec -> [NftRuleSpec]
+ SreBox.WireGuardMesh: [host_name] :: HostSpec -> Text
+ SreBox.WireGuardMesh: [host_peers] :: HostSpec -> [PeerLink]
+ SreBox.WireGuardMesh: [host_port] :: HostSpec -> Int
+ SreBox.WireGuardMesh: [host_privkey_path] :: HostSpec -> FilePath
+ SreBox.WireGuardMesh: [host_routes] :: HostSpec -> [Text]
+ SreBox.WireGuardMesh: [link_allowed_ips] :: PeerLink -> [Text]
+ SreBox.WireGuardMesh: [link_endpoint] :: PeerLink -> Maybe Text
+ SreBox.WireGuardMesh: [link_keepalive] :: PeerLink -> Maybe Int
+ SreBox.WireGuardMesh: [link_name] :: PeerLink -> Text
+ SreBox.WireGuardMesh: [link_pubkey] :: PeerLink -> Text
+ SreBox.WireGuardMesh: [mesh_iface] :: MeshSeed -> Text
+ SreBox.WireGuardMesh: [mesh_peers] :: MeshSeed -> [PeerDecl]
+ SreBox.WireGuardMesh: [mesh_policies] :: MeshSeed -> [Policy]
+ SreBox.WireGuardMesh: [mesh_port] :: MeshSeed -> Int
+ SreBox.WireGuardMesh: [mesh_privkey_path] :: MeshSeed -> FilePath
+ SreBox.WireGuardMesh: [mesh_routers] :: MeshSeed -> [RouterDecl]
+ SreBox.WireGuardMesh: [mesh_subnet] :: MeshSeed -> Text
+ SreBox.WireGuardMesh: [peer_addr] :: PeerDecl -> Text
+ SreBox.WireGuardMesh: [peer_endpoint] :: PeerDecl -> Maybe Text
+ SreBox.WireGuardMesh: [peer_groups] :: PeerDecl -> [Text]
+ SreBox.WireGuardMesh: [peer_name] :: PeerDecl -> Text
+ SreBox.WireGuardMesh: [peer_pubkey] :: PeerDecl -> Text
+ SreBox.WireGuardMesh: [policy_from] :: Policy -> [Text]
+ SreBox.WireGuardMesh: [policy_ports] :: Policy -> [PortRange]
+ SreBox.WireGuardMesh: [policy_protocol] :: Policy -> Proto
+ SreBox.WireGuardMesh: [policy_to] :: Policy -> [Text]
+ SreBox.WireGuardMesh: [port_from] :: PortRange -> Int
+ SreBox.WireGuardMesh: [port_to] :: PortRange -> Int
+ SreBox.WireGuardMesh: [router_network] :: RouterDecl -> Text
+ SreBox.WireGuardMesh: [router_peer] :: RouterDecl -> Text
+ SreBox.WireGuardMesh: [rule_words] :: NftRuleSpec -> [Text]
+ SreBox.WireGuardMesh: data Binaries
+ SreBox.WireGuardMesh: data HostSpec
+ SreBox.WireGuardMesh: data MeshError
+ SreBox.WireGuardMesh: data MeshSeed
+ SreBox.WireGuardMesh: data PeerDecl
+ SreBox.WireGuardMesh: data PeerLink
+ SreBox.WireGuardMesh: data Policy
+ SreBox.WireGuardMesh: data PortRange
+ SreBox.WireGuardMesh: data Proto
+ SreBox.WireGuardMesh: data Report
+ SreBox.WireGuardMesh: data RouterDecl
+ SreBox.WireGuardMesh: genAll :: MeshSeed -> Either [MeshError] [HostSpec]
+ SreBox.WireGuardMesh: genHost :: MeshSeed -> Text -> Either [MeshError] HostSpec
+ SreBox.WireGuardMesh: hostDocument :: Text -> HostSpec -> Document
+ SreBox.WireGuardMesh: hostOp :: Reporter Report -> Binaries -> HostSpec -> Op
+ SreBox.WireGuardMesh: inSubnet :: (Word32, Int) -> Word32 -> Bool
+ SreBox.WireGuardMesh: instance Data.Aeson.Types.FromJSON.FromJSON SreBox.WireGuardMesh.HostSpec
+ SreBox.WireGuardMesh: instance Data.Aeson.Types.FromJSON.FromJSON SreBox.WireGuardMesh.MeshSeed
+ SreBox.WireGuardMesh: instance Data.Aeson.Types.FromJSON.FromJSON SreBox.WireGuardMesh.NftRuleSpec
+ SreBox.WireGuardMesh: instance Data.Aeson.Types.FromJSON.FromJSON SreBox.WireGuardMesh.PeerDecl
+ SreBox.WireGuardMesh: instance Data.Aeson.Types.FromJSON.FromJSON SreBox.WireGuardMesh.PeerLink
+ SreBox.WireGuardMesh: instance Data.Aeson.Types.FromJSON.FromJSON SreBox.WireGuardMesh.Policy
+ SreBox.WireGuardMesh: instance Data.Aeson.Types.FromJSON.FromJSON SreBox.WireGuardMesh.PortRange
+ SreBox.WireGuardMesh: instance Data.Aeson.Types.FromJSON.FromJSON SreBox.WireGuardMesh.Proto
+ SreBox.WireGuardMesh: instance Data.Aeson.Types.FromJSON.FromJSON SreBox.WireGuardMesh.RouterDecl
+ SreBox.WireGuardMesh: instance Data.Aeson.Types.ToJSON.ToJSON SreBox.WireGuardMesh.HostSpec
+ SreBox.WireGuardMesh: instance Data.Aeson.Types.ToJSON.ToJSON SreBox.WireGuardMesh.MeshSeed
+ SreBox.WireGuardMesh: instance Data.Aeson.Types.ToJSON.ToJSON SreBox.WireGuardMesh.NftRuleSpec
+ SreBox.WireGuardMesh: instance Data.Aeson.Types.ToJSON.ToJSON SreBox.WireGuardMesh.PeerDecl
+ SreBox.WireGuardMesh: instance Data.Aeson.Types.ToJSON.ToJSON SreBox.WireGuardMesh.PeerLink
+ SreBox.WireGuardMesh: instance Data.Aeson.Types.ToJSON.ToJSON SreBox.WireGuardMesh.Policy
+ SreBox.WireGuardMesh: instance Data.Aeson.Types.ToJSON.ToJSON SreBox.WireGuardMesh.PortRange
+ SreBox.WireGuardMesh: instance Data.Aeson.Types.ToJSON.ToJSON SreBox.WireGuardMesh.Proto
+ SreBox.WireGuardMesh: instance Data.Aeson.Types.ToJSON.ToJSON SreBox.WireGuardMesh.RouterDecl
+ SreBox.WireGuardMesh: instance GHC.Classes.Eq SreBox.WireGuardMesh.HostSpec
+ SreBox.WireGuardMesh: instance GHC.Classes.Eq SreBox.WireGuardMesh.MeshError
+ SreBox.WireGuardMesh: instance GHC.Classes.Eq SreBox.WireGuardMesh.MeshSeed
+ SreBox.WireGuardMesh: instance GHC.Classes.Eq SreBox.WireGuardMesh.NftRuleSpec
+ SreBox.WireGuardMesh: instance GHC.Classes.Eq SreBox.WireGuardMesh.PeerDecl
+ SreBox.WireGuardMesh: instance GHC.Classes.Eq SreBox.WireGuardMesh.PeerLink
+ SreBox.WireGuardMesh: instance GHC.Classes.Eq SreBox.WireGuardMesh.Policy
+ SreBox.WireGuardMesh: instance GHC.Classes.Eq SreBox.WireGuardMesh.PortRange
+ SreBox.WireGuardMesh: instance GHC.Classes.Eq SreBox.WireGuardMesh.Proto
+ SreBox.WireGuardMesh: instance GHC.Classes.Eq SreBox.WireGuardMesh.RouterDecl
+ SreBox.WireGuardMesh: instance GHC.Internal.Generics.Generic SreBox.WireGuardMesh.HostSpec
+ SreBox.WireGuardMesh: instance GHC.Internal.Generics.Generic SreBox.WireGuardMesh.MeshSeed
+ SreBox.WireGuardMesh: instance GHC.Internal.Generics.Generic SreBox.WireGuardMesh.NftRuleSpec
+ SreBox.WireGuardMesh: instance GHC.Internal.Generics.Generic SreBox.WireGuardMesh.PeerDecl
+ SreBox.WireGuardMesh: instance GHC.Internal.Generics.Generic SreBox.WireGuardMesh.PeerLink
+ SreBox.WireGuardMesh: instance GHC.Internal.Generics.Generic SreBox.WireGuardMesh.Policy
+ SreBox.WireGuardMesh: instance GHC.Internal.Generics.Generic SreBox.WireGuardMesh.PortRange
+ SreBox.WireGuardMesh: instance GHC.Internal.Generics.Generic SreBox.WireGuardMesh.Proto
+ SreBox.WireGuardMesh: instance GHC.Internal.Generics.Generic SreBox.WireGuardMesh.RouterDecl
+ SreBox.WireGuardMesh: instance GHC.Internal.Show.Show SreBox.WireGuardMesh.HostSpec
+ SreBox.WireGuardMesh: instance GHC.Internal.Show.Show SreBox.WireGuardMesh.MeshError
+ SreBox.WireGuardMesh: instance GHC.Internal.Show.Show SreBox.WireGuardMesh.MeshSeed
+ SreBox.WireGuardMesh: instance GHC.Internal.Show.Show SreBox.WireGuardMesh.NftRuleSpec
+ SreBox.WireGuardMesh: instance GHC.Internal.Show.Show SreBox.WireGuardMesh.PeerDecl
+ SreBox.WireGuardMesh: instance GHC.Internal.Show.Show SreBox.WireGuardMesh.PeerLink
+ SreBox.WireGuardMesh: instance GHC.Internal.Show.Show SreBox.WireGuardMesh.Policy
+ SreBox.WireGuardMesh: instance GHC.Internal.Show.Show SreBox.WireGuardMesh.PortRange
+ SreBox.WireGuardMesh: instance GHC.Internal.Show.Show SreBox.WireGuardMesh.Proto
+ SreBox.WireGuardMesh: instance GHC.Internal.Show.Show SreBox.WireGuardMesh.RouterDecl
+ SreBox.WireGuardMesh: newtype NftRuleSpec
+ SreBox.WireGuardMesh: parseCidr :: Text -> Maybe (Word32, Int)
+ SreBox.WireGuardMesh: parseIpv4 :: Text -> Maybe Word32
+ SreBox.WireGuardMesh: renderError :: MeshError -> Text
+ SreBox.WireGuardMesh: validate :: MeshSeed -> [MeshError]
+ SreBox.WireGuardVpn: instance GHC.Internal.Show.Show SreBox.WireGuardVpn.Report
+ SreBox.WireGuardVpn: pinHostRoute :: Reporter Report -> Binaries -> Text -> Op
- SreBox.PostgresPair: Bouncer :: Text -> Text -> Text -> Maybe FilePath -> Text -> Port -> FilePath -> Text -> Text -> FilePath -> FilePath -> Port -> Bouncer
+ SreBox.PostgresPair: Bouncer :: Text -> Text -> Text -> Maybe FilePath -> Text -> Port -> FilePath -> Text -> Text -> FilePath -> FilePath -> Port -> Maybe [RoutedDatabase] -> Bouncer
- SreBox.PostgresPair: Member :: Text -> Host -> ClusterName -> Port -> Maybe FilePath -> Member
+ SreBox.PostgresPair: Member :: Text -> Host -> ClusterName -> Port -> Maybe FilePath -> Maybe Text -> Member
- SreBox.PostgresPair: Pair :: Text -> Member -> Member -> Side -> RoleName -> FilePath -> RoleName -> FilePath -> Maybe FilePath -> Int -> Maybe Side -> [Bouncer] -> Maybe Side -> Maybe Side -> Pair
+ SreBox.PostgresPair: Pair :: Text -> Member -> Member -> Side -> RoleName -> FilePath -> RoleName -> FilePath -> Maybe FilePath -> Int -> Maybe Side -> [Bouncer] -> Maybe Side -> Maybe Side -> Maybe ConnSecurity -> Pair

Files

CHANGELOG.md view
@@ -1,5 +1,20 @@ # Revision history for salmon-ops-recipes -## 0.1.0.0 -- unreleased+## 0.2.0.0 -- unreleased++Breaking: `SreBox.PostgresPair`'s records and some exported functions changed. Requires+`salmon-ops ^>=0.2.0.0`.++* `SreBox.PostgresPair`: one bouncer routing several databases, `hostssl` and SCRAM for+  replication and rewind, a member's SSH address separate from its replication address, every+  script under one `sudo` when the SSH login is not root.+* New `SreBox.PostgresPairPrereqs`: what a pair assumed was done first, as nodes.+* New `SreBox.PgVectorDemo`: index a directory of text and images into pgvector with a+  llama-server, clip.cpp or a model-free stand-in embedder, and search it by cosine distance.+* New `SreBox.WireGuardMesh`: declared peers, groups and policies unfolded per host.+* New `SreBox.Gcp.PlakarGcsBackup`; `SreBox.Gcp.VmProvision` exports the startup script that makes+  an instance trust the SSH CA.++## 0.1.0.0 -- 2026-10-01  * First release.
+ bench/GraphProfiles.hs view
@@ -0,0 +1,1035 @@+{-# LANGUAGE BangPatterns #-}+{-# LANGUAGE OverloadedStrings #-}++{- | How each graph operation scales, per shape: every operation below run+over the "Test.GraphFixture" shapes at growing sizes, timed, with the bytes+it allocated, each series stopped once a step passes its budget.++Not part of @cabal test@ and not built by @cabal build all@ (it is a+@benchmark@ stanza): it is slow, and the numbers only mean something on a+quiet machine.++@+cabal bench salmon-ops-recipes:graph-profiles \\+  --benchmark-options='--max-size 100000 --heap 4g --out profiles.tsv'+cabal bench salmon-ops-recipes:graph-profiles \\+  --benchmark-options='--summarise profiles.tsv'+@++Each series (one operation over one shape) runs in a process of its own,+see 'measureAll' for why.++= What a row is++One operation, one shape, one size: the wall-clock time of the operation+alone (inputs are built and forced before the clock starts, a major+collection runs first) and the bytes allocated meanwhile, on every thread.+A step that takes under a quarter of a second is run three times and the+fastest kept.++= Two size axes++An operation that reads the whole expanded graph (the expansion itself,+@query show@ of every path, and the per-occurrence fold kept as+"Test.DagReference") pays per __occurrence__: per path from the root to a+node. On a shape that shares nodes that number is exponential in the node+count, so those series are sized by occurrences and reach a few dozen+nodes. An operation that reads a 'Dag' pays per __node__, and is given one+built by 'Test.GraphFixture.dagOf' without going through the fold, at+whatever node count the cap allows. 'Salmon.Op.Dag.foldDag', and so+@query plan@ and a declaration in @serve@, were sized by occurrences until+the fold went per node; they are sized by nodes now.++= When a series stops++At the size cap; when a step took more than a third of the budget (the next+one is three times larger, so it would be over); when a step was still+running at three times the budget (it is abandoned); when building its+inputs took two budgets; or when the heap bound (@--heap@) was hit. Which of+these ended a series is recorded.+-}+module Main (main) where++import Control.Comonad.Cofree (Cofree (..))+import Control.Concurrent.STM (atomically, readTVar, retry)+import Control.Exception (AsyncException (HeapOverflow), Exception, bracket, evaluate, finally, throwIO, try)+import Control.Monad (forM, forM_, unless, when)+import Control.Monad.Identity (runIdentity)+import Data.Aeson (Value (..), encode, toJSON)+import qualified Data.Aeson.KeyMap as KeyMap+import qualified Data.ByteString.Lazy as LByteString+import Data.Dynamic (toDyn)+import Data.Foldable (toList)+import Data.IORef (IORef, modifyIORef', newIORef, readIORef, writeIORef)+import Data.List (foldl', intercalate, nub)+import qualified Data.Map.Strict as Map+import Data.Maybe (fromMaybe, isNothing, mapMaybe)+import qualified Data.Set as Set+import Data.Text (Text)+import qualified Data.Text as Text+import Data.Time.Clock (getCurrentTime)+import Data.Word (Word64)+import GHC.Clock (getMonotonicTimeNSec)+import GHC.Stats (allocated_bytes, getRTSStats, getRTSStatsEnabled, max_mem_in_use_bytes)+import Options.Applicative hiding (help)+import qualified Options.Applicative as O+import System.Environment (getExecutablePath)+import System.Exit (ExitCode (..), exitFailure)+import System.IO (IOMode (AppendMode, WriteMode), hClose, hFlush, hPutStr, hPutStrLn, openFile, stderr, stdout)+import System.Mem (performMajorGC, performMinorGC)+import System.Posix.Process (exitImmediately)+import System.Process (CreateProcess (..), StdStream (NoStream), createPipe, createProcess, proc, terminateProcess, waitForProcess)+import System.Timeout (timeout)+import Text.Printf (printf)+import Text.Read (readMaybe)++import GHC.IO.Handle (hDuplicate, hDuplicateTo)++import qualified Salmon.Actions.Concurrent as Concurrent+import qualified Salmon.Actions.Dot as Dot+import qualified Salmon.Actions.Help as Help+import qualified Salmon.Actions.Query as Query+import Salmon.Actions.Serve (Convergence (..), NodeState (..))+import qualified Salmon.Actions.Serve as Serve+import qualified Salmon.Actions.Serve.Http as Http+import qualified Salmon.Actions.Serve.StatusSink as StatusSink+import qualified Salmon.Actions.UpDown as UpDown+import qualified Salmon.Actions.Upkeep as Upkeep+import Salmon.Builtin.Extension (Extension (..), Op, evalDeps, nodeps, op, opAct)+import qualified Salmon.Client.Model as Model+import Salmon.Op.Actions (Act (..))+import Salmon.Op.Configure (Configure (..))+import Salmon.Op.Dag (Dag (..))+import qualified Salmon.Op.Dag as Dag+import qualified Salmon.Op.Ledger as Ledger+import Salmon.Op.Ref (Ref, mkRef)+import qualified Salmon.Op.Rewrite as Rewrite+import Salmon.Op.Status (Direction (..), Stability (..), Status (..))+import Salmon.Op.Track (Track (..))+import Salmon.Reporter (ReporterM (..), silent)+import Salmon.Reporter.Tagged (Tagged (..))++import Test.DagReference (foldDagPerOccurrence, stuckRounds)+import Test.GraphFixture+import Test.PathListReference (outlinePathsUncapped)++-------------------------------------------------------------------------------+-- options++data Options' = Options'+    { optMaxSize :: Int+    , optMaxOccurrences :: Integer+    , optBudget :: Double+    , optOut :: Maybe FilePath+    , optOnly :: [String]+    , optShapes :: [String]+    , optSummarise :: Maybe FilePath+    , optList :: Bool+    , optHeap :: String+    , optSeries :: Maybe String+    }++optionsP :: Parser Options'+optionsP =+    Options'+        <$> option auto (long "max-size" <> metavar "NODES" <> value 10000 <> showDefault <> O.help "The size cap: no graph has more nodes than this.")+        <*> option auto (long "max-occurrences" <> metavar "N" <> value 30000000 <> showDefault <> O.help "The cap for operations that read the expanded graph: no expansion has more nodes than this.")+        <*> option auto (long "budget" <> metavar "SECONDS" <> value 10 <> showDefault <> O.help "A series stops after a step over a third of this, and a step is abandoned at three times this.")+        <*> optional (strOption (long "out" <> metavar "FILE" <> O.help "Append the rows to this file (tab-separated). Standard output otherwise."))+        <*> many (strOption (long "only" <> metavar "TEXT" <> O.help "Only the operations whose group or name contains this. Repeatable."))+        <*> many (strOption (long "shape" <> metavar "NAME" <> O.help "Only this shape (see --list). Repeatable."))+        <*> optional (strOption (long "summarise" <> metavar "FILE" <> O.help "Measure nothing: print the table of the rows in this file, as markdown."))+        <*> switch (long "list" <> O.help "Measure nothing: list the operations and the shapes.")+        <*> strOption (long "heap" <> metavar "SIZE" <> value "4g" <> showDefault <> O.help "The heap bound of each series (its process runs with +RTS -M<SIZE>).")+        <*> optional (strOption (long "series" <> metavar "SHAPE:INDEX" <> internal))++main :: IO ()+main = do+    opts <- execParser (info (optionsP <**> helper) (fullDesc <> progDesc "Time the graph operations over synthetic graphs of growing size."))+    case (optList opts, optSummarise opts, optSeries opts) of+        (True, _, _) -> do+            forM_ shapes $ \(name, shape) -> putStrLn ("shape\t" <> name <> "\t" <> show shape)+            forM_ benches $ \b -> putStrLn ("operation\t" <> benchGroup b <> "\t" <> benchName b <> "\t" <> scaleName (benchScale b))+        (_, Just path, _) -> summarise path+        (_, _, Just series)+            | (shapeName, ':' : index) <- break (== ':') series+            , Just shape <- lookup shapeName shapes+            , Just i <- readMaybe index+            , b : _ <- drop i benches ->+                measureSeries opts shapeName shape b+            | otherwise -> hPutStrLn stderr ("no such series: " <> series) >> exitFailure+        _ -> measureAll opts++-------------------------------------------------------------------------------+-- shapes and sizes++shapes :: [(String, Shape)]+shapes =+    [ ("chain", Chain)+    , ("fan", Fan)+    , ("tree2", Tree 2)+    , ("diamonds2", Diamonds 2)+    , ("layered8x2", Layered 8 2)+    , ("random8x3", RandomLayered 8 3)+    , ("dense32", Layered 32 32)+    ]++seed :: Seed+seed = 3++-- | What a series is sized by, and fitted against.+data Scale = PerNode | PerOccurrence+    deriving (Eq)++scaleName :: Scale -> String+scaleName PerNode = "nodes"+scaleName PerOccurrence = "occurrences"++-- | 100, 300, 1000, 3000, ...+targets :: [Integer]+targets = concat [[10 ^ k, 3 * 10 ^ k] | k <- [2 :: Int ..]]++-- | The requested sizes a series visits: for each target, the largest size+-- under the cap whose node count (or occurrence count) does not exceed it.+sizesFor :: Options' -> Scale -> Shape -> [Int]+sizesFor opts scale shape = dedupe (mapMaybe fit (takeWhile (<= ceiling') targets))+  where+    ceiling' = case scale of+        PerNode -> fromIntegral (optMaxSize opts)+        PerOccurrence -> optMaxOccurrences opts+    metric n = case scale of+        PerNode -> (counts shape n).countNodes+        PerOccurrence -> (counts shape n).countOccurrences+    fit t =+        let hi = fromIntegral (min t (fromIntegral (optMaxSize opts)))+            n = search 1 hi+         in if metric n <= t then Just n else Nothing+      where+        -- the largest n in [lo, hi] with metric n <= t; the metric never+        -- decreases with the requested size.+        search lo hi+            | lo >= hi = lo+            | otherwise =+                let mid = (lo + hi + 1) `div` 2+                 in if metric mid <= t then search mid hi else search lo (mid - 1)+    -- two requests that realise the same graph are one step.+    dedupe = go Nothing+      where+        go _ [] = []+        go prev (n : rest)+            | Just (counts shape n).countNodes == prev = go prev rest+            | otherwise = n : go (Just (counts shape n).countNodes) rest++-------------------------------------------------------------------------------+-- measuring++data Sample = Sample+    { sampleLabel :: String+    , sampleSecs :: Double+    , sampleAlloc :: Word64+    }++allocated :: IO Word64+allocated = allocated_bytes <$> getRTSStats++-- | One run of an action whose result is forced by the 'Int' it returns.+timed :: IO Int -> IO (Double, Word64)+timed act = do+    performMajorGC+    a0 <- allocated+    t0 <- getMonotonicTimeNSec+    n <- act+    _ <- evaluate n+    t1 <- getMonotonicTimeNSec+    -- the counter is only brought up to date by a collection.+    performMinorGC+    a1 <- allocated+    pure (fromIntegral (t1 - t0) / 1e9, a1 - a0)++{- | The outer action prepares one run and returns what to time, so that a+repeat never finds a structure the previous run already evaluated.+-}+best :: IO (IO Int) -> IO (Double, Word64)+best prepare = do+    first <- prepare >>= timed+    if fst first >= 0.25+        then pure first+        else do+            more <- forM [1 :: Int, 2] (const (prepare >>= timed))+            pure (foldl (\a b -> if fst b < fst a then b else a) first more)++-- | Time a sequence of steps that hand state on to each other, once each.+phases :: [(String, IO Int)] -> IO [Sample]+phases steps = forM steps $ \(label, act) -> do+    (secs, bytes) <- timed act+    pure (Sample label secs bytes)++data Env = Env+    { envShape :: Shape+    , envSize :: Int+    , envBudget :: Double+    , envFresh :: IO Op+    -- ^ a new root each time: an 'Op' keeps the predecessors it has been+    -- asked for, so a root that was expanded once is no longer a+    -- description of a graph but the graph.+    , envFreshManaged :: IO Op+    , envDag :: Dag Extension+    -- ^ shared by every operation at this size; built by 'dagOf'+    , envPaths :: Map.Map Ref [Text]+    -- ^ what @serve@ lists per node, see 'pathsOf'+    }++data Bench = Bench+    { benchGroup :: String+    , benchName :: String+    , benchScale :: Scale+    , benchRun :: Env -> IO [Sample]+    }++-- | An operation with one number to report.+single :: String -> String -> Scale -> (Env -> IO (IO Int)) -> Bench+single grp name scale prepare =+    Bench grp name scale $ \e -> do+        (secs, bytes) <- best (prepare e)+        pure [Sample "" secs bytes]++-- | An operation over the shared 'Dag', forced before the clock starts.+overDag :: String -> String -> (Dag Extension -> IO (IO Int)) -> Bench+overDag grp name prepare =+    single grp name PerNode $ \e -> do+        _ <- evaluate (weighDag e.envDag)+        prepare e.envDag++-- | Building an operation's inputs took too long: the series ends, and the+-- operation itself was not what ended it.+data InputsOver = InputsOver+    deriving (Show)++instance Exception InputsOver++row :: Bench -> String -> Shape -> Int -> String -> Double -> Word64 -> String -> String+row b shapeName shape size label secs bytes status =+    intercalate+        "\t"+        [ benchGroup b+        , benchName b+        , label+        , scaleName (benchScale b)+        , shapeName+        , show size+        , show c.countNodes+        , show c.countEdges+        , abbreviated c.countOccurrences+        , printf "%.6f" secs+        , show bytes+        , status+        ]+  where+    c = counts shape size+    -- a shape that shares has more occurrences than digits worth writing:+    -- past eighteen of them only the magnitude is kept, as @1e<exponent>@.+    abbreviated n =+        let digits = show n+         in if length digits <= 18 then digits else "1e" <> show (length digits - 1)++{- | Every series in a process of its own, one after the other.++A step that is abandoned leaves its threads behind (a supervisor's machines,+a loop mid-pass), and they keep waking: measured in one process, every+series after the first abandoned step came out hundreds of times slower+than it is. A series ends at its first abandoned step, so in a process of+its own nothing is measured after one.+-}+measureAll :: Options' -> IO ()+measureAll opts = do+    self <- getExecutablePath+    let selected = [(i, b) | (i, b) <- zip [0 :: Int ..] benches, null (optOnly opts) || any (\o -> isIn o (benchGroup b) || isIn o (benchName b)) (optOnly opts)]+        chosen = [s | s@(name, _) <- shapes, null (optShapes opts) || name `elem` optShapes opts]+    forM_ chosen $ \(shapeName, shape) ->+        forM_ selected $ \(i, b) -> do+            let args =+                    ["--max-size", show (optMaxSize opts), "--max-occurrences", show (optMaxOccurrences opts), "--budget", show (optBudget opts)]+                        <> concat [["--out", path] | Just path <- [optOut opts]]+                        <> ["--series", shapeName <> ":" <> show i, "+RTS", "-M" <> optHeap opts, "-RTS"]+            hFlush stdout+            (_, _, _, child) <- createProcess (proc self args){std_in = NoStream}+            -- a series is a dozen steps of at most three budgets each.+            ended <- timeout (round (60 * optBudget opts * 1e6)) (waitForProcess child)+            died <- case ended of+                Just ExitSuccess -> pure Nothing+                Just (ExitFailure code) -> pure (Just ("stop:exit " <> show code))+                Nothing -> do+                    terminateProcess child+                    _ <- waitForProcess child+                    pure (Just "stop:killed")+            forM_ died $ \why -> do+                let line = row b shapeName shape 0 "" 0 0 why+                maybe (putStrLn line) (`appendFile` (line <> "\n")) (optOut opts)+  where+    isIn needle hay = Text.pack needle `Text.isInfixOf` Text.pack hay++-- | One operation over one shape, at growing sizes, until it stops.+measureSeries :: Options' -> String -> Shape -> Bench -> IO ()+measureSeries opts shapeName shape b = do+    enabled <- getRTSStatsEnabled+    unless enabled $ do+        hPutStrLn stderr "the allocation counter is off: run with +RTS -T"+        exitFailure+    out <- maybe (pure stdout) (`openFile` AppendMode) (optOut opts)+    let scheduled = sizesFor opts (benchScale b) shape+        steps size = do+            let c = counts shape size+                say label secs bytes status = hPutStrLn out (row b shapeName shape size label secs bytes status) >> hFlush out+            hPutStrLn stderr (shapeName <> " " <> show c.countNodes <> " nodes: " <> benchGroup b <> " / " <> benchName b)+            env <- newEnv (optBudget opts) shape size+            outcome <- try (try (timeout (round (3 * optBudget opts * 1e6)) (benchRun b env)))+            case outcome of+                Left HeapOverflow -> say "" 0 0 "stop:heap" >> pure False+                Left other -> throwIO other+                Right (Left InputsOver) -> say "" 0 0 "stop:inputs" >> pure False+                Right (Right Nothing) -> say "" 0 0 "stop:timeout" >> pure False+                Right (Right (Just samples)) -> do+                    forM_ samples $ \s -> say (sampleLabel s) (sampleSecs s) (sampleAlloc s) "ok"+                    if maximum (0 : fmap sampleSecs samples) > optBudget opts / 3+                        then say "" 0 0 "stop:budget" >> pure False+                        else+                            if size == last scheduled+                                then say "" 0 0 "stop:cap" >> pure False+                                else pure True+        go [] = pure ()+        go (size : rest) = do+            more <- steps size+            performMajorGC+            when more (go rest)+    go scheduled+    peak <- max_mem_in_use_bytes <$> getRTSStats+    hPutStrLn stderr ("peak memory in use: " <> show (peak `div` (1024 * 1024)) <> " MiB")+    hClose out+    -- not a normal return: whatever an abandoned step left running is not waited for.+    hFlush stdout+    exitImmediately ExitSuccess++newEnv :: Double -> Shape -> Int -> IO Env+newEnv budget shape size = do+    -- read back through a reference so that no two calls can be shared.+    cell <- newIORef seed+    let fresh options = do+            s <- readIORef cell+            pure (generateWith options shape size s)+    root <- fresh fixtureOptions+    pathRoot <- fresh fixtureOptions+    pure+        Env+            { envShape = shape+            , envSize = size+            , envBudget = budget+            , envFresh = fresh fixtureOptions+            , envFreshManaged = fresh fixtureOptions{optManaged = True}+            , envDag = dagOf root+            , envPaths = pathsOf pathRoot+            }++-- | A marker one node in ten carries, sixteen values of it: what the+-- rewrites below collect.+newtype Batch = Batch Int+    deriving (Show, Eq, Ord)++{- | The nodes every series runs over: no-ops whose @check@ answers+'UpDown.Immaterial' (what a node with no check says, so an @up@ pass runs+every @up@), one in ten carrying a 'Batch'.+-}+fixtureOptions :: Options+fixtureOptions =+    defaultOptions+        { optDynamics = \i -> [toDyn (Batch ((i `div` 10) `mod` 16)) | i `mod` 10 == 0]+        }++-------------------------------------------------------------------------------+-- forcing++weighDag :: Dag ext -> Int+weighDag dag =+    Map.size (dagNodes dag)+        + sum (fmap length (Map.elems (dagDependencies dag)))+        + sum (fmap length (Map.elems (dagDependants dag)))+        + length (dagOrderRev dag)+        + length (dagConflicts dag)++weighTexts :: [Text] -> Int+weighTexts = foldl' (\n t -> n + Text.length t) 0++weighPaths :: Map.Map Ref [Text] -> Int+weighPaths = Map.foldl' (\n ts -> n + weighTexts ts) 0++weighBytes :: LByteString.ByteString -> Int+weighBytes = fromIntegral . LByteString.length++-- | The number of nodes of an expansion, without the stack a recursion+-- over a deep one would take.+weighCofree :: Cofree f a -> ([Cofree f a] -> f (Cofree f a) -> [Cofree f a]) -> Int+weighCofree root push = go 0 [root]+  where+    go !n [] = n+    go !n ((_ :< g) : rest) = go (n + 1) (push rest g)++expansionSize :: Op -> Int+expansionSize root = weighCofree (evalDeps root) (\rest g -> toList g <> rest)++weighModel :: Model.Model -> Int+weighModel m =+    Model.orderSize m.modelOrder+        + Map.foldl' (\n x -> n + Text.length x.nodeShorthand + length x.nodeDependencies + length x.nodeDependants + length x.nodePaths + Text.length x.nodeConvergence) 0 m.modelNodes++-- | Run an action with standard output going nowhere: two of the renders+-- print rather than return.+quietly :: IO a -> IO a+quietly act =+    bracket+        ( do+            hFlush stdout+            saved <- hDuplicate stdout+            devNull <- openFile "/dev/null" WriteMode+            hDuplicateTo devNull stdout+            hClose devNull+            pure saved+        )+        (\saved -> hFlush stdout >> hDuplicateTo saved stdout >> hClose saved)+        (const act)++-------------------------------------------------------------------------------+-- the operations++{- | The paths @serve@ lists for each node ('Serve.worldPaths', for a world+with one declaration): at most 'Query.pathLimit' per node, a path longer+than 'Query.pathCap' shown as its tail.+-}+pathsOf :: Op -> Map.Map Ref [Text]+pathsOf root =+    Map.map (fmap Query.renderListedPath) (Query.outlinePaths Query.pathLimit Query.pathCap (Query.outline (evalDeps root)))++-- | The same before the cap ("Test.PathListReference"): every path in full.+pathsUncappedOf :: Op -> Map.Map Ref [Text]+pathsUncappedOf root =+    Map.map (fmap (Text.intercalate "/")) (outlinePathsUncapped Query.pathLimit (Query.outline (evalDeps root)))++benches :: [Bench]+benches =+    concat+        [ foldBenches+        , dagBenches+        , walkBenches+        , upkeepBenches+        , rewriteBenches+        , [ledgerBench]+        , queryBenches+        , [serveBench]+        , renderBenches+        , clientBenches+        ]++foldBenches :: [Bench]+foldBenches =+    [ single "fold" "expand (evalDeps, every occurrence visited)" PerOccurrence $ \e -> do+        root <- e.envFresh+        pure (evaluate (expansionSize root))+    , single "fold" "expand + foldDag" PerNode $ \e -> do+        root <- e.envFresh+        pure (evaluate (weighDag (Dag.foldDag Dag.sameRepresentative (evalDeps root))))+    , -- what 'Dag.foldDag' was before it went per node, for the comparison:+      -- "Test.DagReference", which the tests use as their reference.+      single "fold" "expand + foldDagPerOccurrence (the fold before it went per node)" PerOccurrence $ \e -> do+        root <- e.envFresh+        pure (evaluate (weighDag (foldDagPerOccurrence Dag.sameRepresentative (evalDeps root))))+    , single "fold" "dagOf (the fixture's per-node build, for comparison)" PerNode $ \e -> do+        root <- e.envFresh+        pure (evaluate (weighDag (dagOf root)))+    ]++dagBenches :: [Bench]+dagBenches =+    [ overDag "dag" "fromMagma" $ \dag -> do+        let magma = dagNodes dag+            edges = Dag.dagEdges dag+        _ <- evaluate (Map.size magma + Set.size edges)+        pure (evaluate (weighDag (Dag.fromMagma magma edges)))+    , overDag "dag" "mergeDag into an empty Dag" $ \dag ->+        pure (evaluate (weighDag (Dag.mergeDag Dag.sameRepresentative Dag.emptyDag dag)))+    , overDag "dag" "mergeDag onto itself" $ \dag ->+        pure (evaluate (weighDag (Dag.mergeDag Dag.sameRepresentative dag dag)))+    , overDag "dag" "dagEdges" $ \dag ->+        pure (evaluate (Set.size (Dag.dagEdges dag)))+    , overDag "dag" "roots + leaves" $ \dag ->+        pure (evaluate (length (Dag.roots dag) + length (Dag.leaves dag)))+    , overDag "dag" "stuck (waiting on dependencies)" $ \dag ->+        pure (evaluate (Set.size (Dag.stuck Dag.dependenciesOf dag)))+    , overDag "dag" "stuck (waiting on dependants)" $ \dag ->+        pure (evaluate (Set.size (Dag.stuck Dag.dependantsOf dag)))+    , -- what 'Dag.stuck' was before it went one pass, for the comparison:+      -- "Test.DagReference", which the tests use as their reference.+      overDag "dag" "stuckRounds (stuck before it went one pass, waiting on dependencies)" $ \dag ->+        pure (evaluate (Set.size (stuckRounds Dag.dependenciesOf dag)))+    ]++walkBenches :: [Bench]+walkBenches =+    [ overDag "walk" "upDag (sequential)" $ \dag ->+        pure (fromEnum <$> UpDown.upDag UpDown.alwaysRequired silent dag)+    , overDag "walk" "downDag (sequential)" $ \dag ->+        pure (fromEnum <$> UpDown.downDag UpDown.alwaysRequired silent dag)+    , overDag "walk" "upDagConcurrent" $ \dag ->+        pure (fromEnum <$> Concurrent.upDagConcurrent UpDown.alwaysRequired silent Concurrent.noMailboxes Nothing dag)+    , overDag "walk" "downDagConcurrent" $ \dag ->+        pure (fromEnum <$> Concurrent.downDagConcurrent UpDown.alwaysRequired silent Concurrent.noMailboxes Nothing dag)+    ]++-- | Wait until every machine has settled.+awaitStable :: Upkeep.Supervisor Extension -> IO Int+awaitStable sup = do+    forM_ (Map.elems (Upkeep.supervisorStatuses sup)) $ \var ->+        atomically $ do+            st <- readTVar var+            unless (st.statusStability == Stable) retry+    pure (Map.size (Upkeep.supervisorStatuses sup))++-- | Stop a supervisor a step left behind, whatever it was holding.+standDown :: IORef (Maybe (Upkeep.Supervisor Extension)) -> IO ()+standDown cell = do+    left <- readIORef cell+    writeIORef cell Nothing+    forM_ left $ \sup -> do+        kept <- Upkeep.stopUpkeep sup+        _ <- Upkeep.releaseKept silent (const False) kept+        pure ()++upkeepBenches :: [Bench]+upkeepBenches =+    [ Bench "upkeep" "tend nodes already up (settled)" PerNode $ \e -> do+        _ <- evaluate (weighDag e.envDag)+        cell <- newIORef Nothing+        flip finally (standDown cell) $+            phases+                [+                    ( "startUpkeep"+                    , do+                        sup <- Upkeep.startUpkeep silent Upkeep.noKept (const (Just (Upkeep.Tend TurnUp Upkeep.Settled))) e.envDag+                        writeIORef cell (Just sup)+                        pure (Map.size (Upkeep.supervisorStatuses sup))+                    )+                , ("until every machine is stable", readIORef cell >>= maybe (pure 0) awaitStable)+                , ("stopUpkeep", standDown cell >> pure 0)+                ]+    , Bench "upkeep" "bring nodes up (unsettled)" PerNode $ \e -> do+        _ <- evaluate (weighDag e.envDag)+        cell <- newIORef Nothing+        flip finally (standDown cell) $+            phases+                [+                    ( "startUpkeep"+                    , do+                        sup <- Upkeep.startUpkeep silent Upkeep.noKept (const (Just (Upkeep.Tend TurnUp Upkeep.Unsettled))) e.envDag+                        writeIORef cell (Just sup)+                        pure (Map.size (Upkeep.supervisorStatuses sup))+                    )+                , ("until every machine is stable", readIORef cell >>= maybe (pure 0) awaitStable)+                , ("stopUpkeep", standDown cell >> pure 0)+                ]+    , Bench "upkeep" "managed nodes: hold, hand over, adopt" PerNode $ \e -> do+        root <- e.envFreshManaged+        let dag = dagOf root+            tend = const (Just (Upkeep.Tend TurnUp Upkeep.Unsettled))+        _ <- evaluate (weighDag dag)+        cell <- newIORef Nothing+        kept <- newIORef Upkeep.noKept+        let release = do+                standDown cell+                held <- readIORef kept+                writeIORef kept Upkeep.noKept+                _ <- Upkeep.releaseKept silent (const False) held+                pure ()+        flip finally release $+            phases+                [+                    ( "startUpkeep"+                    , do+                        sup <- Upkeep.startUpkeep silent Upkeep.noKept tend dag+                        writeIORef cell (Just sup)+                        pure (Map.size (Upkeep.supervisorStatuses sup))+                    )+                , ("until every machine is stable", readIORef cell >>= maybe (pure 0) awaitStable)+                ,+                    ( "stopUpkeep (machines kept)"+                    , do+                        left <- readIORef cell+                        writeIORef cell Nothing+                        forM_ left $ \sup -> Upkeep.stopUpkeep sup >>= writeIORef kept+                        Map.size . Upkeep.keptHeld <$> readIORef kept+                    )+                ,+                    ( "startUpkeep (adopting)"+                    , do+                        held <- readIORef kept+                        writeIORef kept Upkeep.noKept+                        sup <- Upkeep.startUpkeep silent held tend dag+                        writeIORef cell (Just sup)+                        pure (Map.size (Upkeep.supervisorStatuses sup))+                    )+                , ("stopUpkeep + releaseKept", release >> pure 0)+                ]+    ]++rewriteBenches :: [Bench]+rewriteBenches =+    [ overDag "rewrite" "no phase registered (wholeGraph + rewrite)" $ \dag ->+        pure (evaluate (weighRewritten (Rewrite.rewrite [] (Rewrite.wholeGraph dag) dag)))+    , overDag "rewrite" "one batch of a tenth of the nodes" $ \dag ->+        pure (evaluate (weighRewritten (Rewrite.rewrite [oneBatch] (Rewrite.wholeGraph dag) dag)))+    , overDag "rewrite" "sixteen batches of a tenth of the nodes" $ \dag ->+        pure (evaluate (weighRewritten (Rewrite.rewrite [sixteenBatches] (Rewrite.wholeGraph dag) dag)))+    ]+  where+    weighRewritten r = weighDag (Rewrite.computedDag r) + sum (fmap Set.size (Map.elems (Rewrite.computedMembers r)))++    oneBatch :: Rewrite.Rewrite Extension+    oneBatch _ r =+        Rewrite.introduce (batchAct 0) (Set.fromList [aref | (aref, _ :: [Batch]) <- Rewrite.collectDynamic r]) r++    sixteenBatches :: Rewrite.Rewrite Extension+    sixteenBatches _ r =+        let groups = Map.fromListWith Set.union [(k, Set.singleton aref) | (aref, Batch k : _) <- Rewrite.collectDynamic r]+         in foldl' (\acc (k, members) -> Rewrite.introduce (batchAct (k + 1)) members acc) r (Map.toList groups)++    batchAct :: Int -> Act Extension+    batchAct k =+        fromMaybe (error "a batch node with no action") $+            opAct (op "batch" nodeps (\x -> x{ref = mkRef "graph-profiles-batch" k, help = "a batch"}))++{- | Eight declarations over the same graph, each leaving out a different+eighth of its nodes: several declarations that mostly overlap.+-}+ledgerBench :: Bench+ledgerBench =+    Bench "ledger" "eight overlapping declarations" PerNode $ \e -> do+        let dag = e.envDag+            position = Map.fromList (zip (Dag.dagOrder dag) [0 :: Int ..])+            whole = Ledger.contribution dag+            keeps k r = maybe False (\i -> i `mod` 8 /= k) (Map.lookup r position)+            part k =+                whole+                    { Ledger.contribRefs = Set.filter (keeps k) (Ledger.contribRefs whole)+                    , Ledger.contribEdges = Set.filter (\(a, b) -> keeps k a && keeps k b) (Ledger.contribEdges whole)+                    }+            ledger = foldl' (\l k -> Ledger.declare k (part k) l) Ledger.emptyLedger [0 .. 7 :: Int]+        _ <- evaluate (weighDag dag)+        _ <- evaluate (sum [Set.size (Ledger.contribRefs c) + Set.size (Ledger.contribEdges c) | c <- Map.elems ledger])+        phases+            [ ("contribution (of one Dag)", evaluate (let c = Ledger.contribution dag in Set.size (Ledger.contribRefs c) + Set.size (Ledger.contribEdges c)))+            , ("desired", evaluate (Set.size (Ledger.desired ledger)))+            , ("precedenceOf", evaluate (Set.size (Ledger.precedenceOf ledger)))+            , ("knownRefs", evaluate (Set.size (Ledger.knownRefs ledger)))+            , ("retractAll + collect, nothing standing", evaluate (Map.size (Ledger.collect (const False) (Ledger.retractAll ledger))))+            ]++queryBenches :: [Bench]+queryBenches =+    [ single "query" "outline" PerNode $ \e -> do+        root <- e.envFresh+        pure (evaluate (Set.size (Query.outlineRefs (Query.outline (evalDeps root)))))+    , single "query" "paths per node (outline + outlinePaths, as worldPaths)" PerNode $ \e -> do+        root <- e.envFresh+        pure (evaluate (weighPaths (pathsOf root)))+    , single "query" "paths per node, uncapped reference (outlinePathsUncapped)" PerNode $ \e -> do+        root <- e.envFresh+        pure (evaluate (weighPaths (pathsUncappedOf root)))+    , single "query" "resolveSelectors (select **, exclude **/n1/**)" PerNode $ \e -> do+        root <- e.envFresh+        pure $ do+            let (selected, excluded) = Query.resolveSelectors (evalDeps root) ["**"] ["**/n1/**"]+            evaluate (Set.size selected + Set.size excluded)+    , single "query" "query show --dedupe (renderAnnotated)" PerNode $ \e -> do+        root <- e.envFresh+        pure (evaluate (weighTexts (Query.renderAnnotated (evalDeps root) Set.empty Set.empty True True)))+    , single "query" "query show, every path (renderAnnotated)" PerOccurrence $ \e -> do+        root <- e.envFresh+        pure (evaluate (weighTexts (Query.renderAnnotated (evalDeps root) Set.empty Set.empty False True)))+    , single "query" "query plan (expand, fold, resolve, encode)" PerNode $ \e -> do+        root <- e.envFresh+        pure $ do+            -- what the command line does for @query plan@, minus reading the directive.+            let cograph = evalDeps root+            dag <- UpDown.expandDag silent (pure . runIdentity) root+            let computed = Rewrite.rewrite [] (Rewrite.wholeGraph dag) dag+                (_, excluded) = Query.resolveRewrittenSelectors cograph computed [] ["**/n1/**"]+                plan = Query.Plan "digest" (Set.toList excluded) ["**/n1/**"] Nothing+            evaluate (weighBytes (encode plan))+    , overDag "query" "run tree (dagLines)" $ \dag ->+        pure (evaluate (weighTexts (Help.dagLines dag)))+    , overDag "query" "run dag (printDagCograph, to /dev/null)" $ \dag ->+        pure (quietly (Dot.printDagCograph dag) >> pure 0)+    ]++{- | The loop itself, over a scripted input: supervision off (so nothing+tends between commands), one declaration, @status@, an idle @converge@,+@clear@. The steps are the intervals between the loop's own reports, so+each is what the operator waits for; evaluation is lazy, so a step can be+charged work that an earlier one set up.+-}+serveBench :: Bench+serveBench =+    Bench "serve" "loop" PerNode $ \e -> do+        marks <- newIORef []+        let mark name = do+                t <- getMonotonicTimeNSec+                performMinorGC+                a <- allocated+                t' <- getMonotonicTimeNSec+                modifyIORef' marks ((name, t, a, t') :)+            reporter = ReporterM $ \rep -> case rep of+                Serve.Supervised _ -> mark "supervised"+                Serve.Declared{} -> mark "declared"+                Serve.ConvergeStart{} -> mark "converge-start"+                Serve.ConvergeStop{} -> mark "converge-stop"+                Serve.StatusReport _ nodes paths -> do+                    _ <- evaluate (length nodes + weighPaths paths)+                    mark "status"+                Serve.Cleared _ -> mark "cleared"+                _ -> pure ()+            parse args = case args of+                [n] | Just size <- readMaybe n -> Right (size :: Int)+                _ -> Left "expected a size"+            program = Track (\size -> generateWith fixtureOptions e.envShape size seed)+        (readEnd, writeEnd) <- createPipe+        hPutStr writeEnd (unlines ["supervise off", "up " <> show e.envSize, "status", "converge", "clear"])+        hClose writeEnd+        performMajorGC+        _ <- Serve.serveWith [] Nothing True reporter silent parse (Configure pure) program readEnd+        seen <- reverse <$> readIORef marks+        let intervals = [(from <> " -> " <> to, fromIntegral (t1 - t0) / 1e9, a1 - a0) | ((from, _, a0, t0), (to, t1, a1, _)) <- zip seen (drop 1 seen)]+            names =+                [ "record a declaration (expand, fold, ledger)"+                , "before the first pass (rewrite, worldDag)"+                , "first pass, every node up"+                , "status"+                , "before the idle pass"+                , "idle pass, nothing pending"+                , "clear"+                , "before the teardown pass"+                , "teardown pass, every node down"+                ]+            expected = ["supervised", "declared", "converge-start", "converge-stop", "status", "converge-start", "converge-stop", "cleared", "converge-start", "converge-stop"]+        if [name | (name, _, _, _) <- seen] == expected+            then pure [Sample name secs bytes | (name, (_, secs, bytes)) <- zip names intervals]+            else pure [Sample ("unexpected: " <> name) secs bytes | (name, secs, bytes) <- intervals]++-- | What the HTTP reads are answered from, for a world whose nodes are all up.+viewOf :: Env -> Http.WorldView+viewOf e =+    Http.WorldView+        { Http.viewDag = e.envDag+        , Http.viewConflicts = Map.empty+        , Http.viewNodes = nodeStates e+        , Http.viewPaths = e.envPaths+        , Http.viewHistory = []+        , Http.viewElided = 0+        }++nodeStates :: Env -> Map.Map Ref NodeState+nodeStates e =+    Map.map+        (\act -> NodeState{nodeShorthand = act.shorthand, nodeHelp = act.extension.help, nodeDirection = Serve.TurnUp, nodeConvergence = Converged, nodeStatus = Nothing, nodeHeld = Nothing})+        (dagNodes e.envDag)++{- | The inputs of a render, forced. The listed paths are the expensive one+(the query group times them on their own), so they are given two budgets+and the series ends as 'InputsOver' rather than as a slow render.+-}+readied :: Env -> IO ()+readied e = do+    _ <- evaluate (weighDag e.envDag)+    listed <- timeout (round (2 * e.envBudget * 1e6)) (evaluate (weighPaths e.envPaths))+    when (isNothing listed) (throwIO InputsOver)+    _ <- evaluate (Map.foldl' (\n st -> n + Text.length st.nodeShorthand) 0 (nodeStates e))+    pure ()++renderBenches :: [Bench]+renderBenches =+    [ single "render" "GET /dag (dagValue + encode)" PerNode $ \e -> do+        readied e+        let view = viewOf e+        pure (evaluate (weighBytes (encode (Http.dagValue Serve.Interactive view))))+    , single "render" "GET /status (status report + encode)" PerNode $ \e -> do+        readied e+        let nodes = nodeStates e+        pure (evaluate (weighBytes (encode (toJSON (FromServe (Serve.StatusReport Serve.Interactive (Map.toList nodes) e.envPaths))))))+    , single "render" "status sink document (encode)" PerNode $ \e -> do+        readied e+        let nodes = nodeStates e+        now <- getCurrentTime+        pure $+            evaluate . weighBytes . encode $+                StatusSink.Document+                    { StatusSink.docHost = "host"+                    , StatusSink.docWritten = now+                    , StatusSink.docMode = Serve.renderMode Serve.Interactive+                    , StatusSink.docLabels = []+                    , StatusSink.docStatus = toJSON (Serve.StatusReport Serve.Interactive (Map.toList nodes) e.envPaths)+                    , StatusSink.docLastConverge = Nothing+                    , StatusSink.docLastFollow = Nothing+                    }+    ]++-- | A @\/dag@ answer as a client receives it, already decoded.+dagAnswer :: Env -> IO Value+dagAnswer e = do+    readied e+    let answer = case Http.dagValue Serve.Interactive (viewOf e) of+            Object o -> Object (KeyMap.insert "seq" (Number 0) o)+            other -> other+    _ <- evaluate (weighBytes (encode answer))+    pure answer++modelOf :: Env -> IO Model.Model+modelOf e = do+    answer <- dagAnswer e+    case Model.fromDag answer of+        Left err -> error ("fromDag: " <> err)+        Right m -> do+            _ <- evaluate (weighModel m)+            pure m++doneEvents :: Model.Model -> [Model.Event]+doneEvents m = [Model.Event (Just i) "updown" "done" (Just r) Nothing Null | (i, r) <- zip [1 ..] (Model.orderToList m.modelOrder)]++clientBenches :: [Bench]+clientBenches =+    [ single "client" "Model.fromDag" PerNode $ \e -> do+        answer <- dagAnswer e+        pure (evaluate (either (const 0) weighModel (Model.fromDag answer)))+    , single "client" "Model.step, one done event per node" PerNode $ \e -> do+        m <- modelOf e+        let events = doneEvents m+        _ <- evaluate (length events)+        pure (evaluate (weighModel (foldl' Model.step m events)))+    , single "client" "Model.step, a teardown: one done event per node wanted down" PerNode $ \e -> do+        m0 <- modelOf e+        let m = m0{Model.modelNodes = Map.map (\n -> n{Model.nodeDirection = "down"}) m0.modelNodes}+            events = doneEvents m+        _ <- evaluate (weighModel m + length events)+        pure (evaluate (weighModel (foldl' Model.step m events)))+    ]++-------------------------------------------------------------------------------+-- the table++data Row = Row+    { rowGroup :: String+    , rowBench :: String+    , rowLabel :: String+    , rowScale :: String+    , rowShape :: String+    , rowNodes :: Integer+    , rowOccurrences :: Integer+    , rowSecs :: Double+    , rowAlloc :: Double+    , rowStatus :: String+    }++parseRow :: String -> Maybe Row+parseRow line =+    case splitTabs line of+        [grp, bench, label, scale, shape, _, nodes, _, occurrences, secs, bytes, status] ->+            Row grp bench label scale shape <$> readMaybe nodes <*> magnitude occurrences <*> readMaybe secs <*> readMaybe bytes <*> pure status+        _ -> Nothing+  where+    magnitude s = case s of+        '1' : 'e' : k -> (10 ^) <$> (readMaybe k :: Maybe Int)+        _ -> readMaybe s+    splitTabs s = case break (== '\t') s of+        (cell, []) -> [cell]+        (cell, _ : rest) -> cell : splitTabs rest++{- | One table per group: an operation per row, a shape per column. A cell+reads @growth, largest size, time and allocation there, why it stopped@.+The growth is the slope of log time against log size over the last four+steps that took at least a millisecond.+-}+summarise :: FilePath -> IO ()+summarise path = do+    rows <- mapMaybe parseRow . lines <$> readFile path+    let groups = nub (fmap rowGroup rows)+    forM_ groups $ \grp -> do+        let inGroup = [r | r <- rows, rowGroup r == grp]+            series = nub [(rowBench r, rowLabel r) | r <- inGroup, rowStatus r == "ok"]+        putStrLn ("### " <> grp)+        putStrLn ""+        putStrLn ("| operation | sized by | " <> intercalate " | " (fmap fst shapes) <> " |")+        putStrLn ("|---|---|" <> concatMap (const "---|") shapes)+        forM_ series $ \(bench, label) -> do+            let mine = [r | r <- inGroup, rowBench r == bench]+                scale = maybe "" rowScale (safeHead mine)+                title = if null label then bench else bench <> ": " <> label+            cells <- forM shapes $ \(shapeName, _) -> do+                let points = [r | r <- mine, rowShape r == shapeName, rowLabel r == label, rowStatus r == "ok"]+                    ending = [r | r <- mine, rowShape r == shapeName, take 5 (rowStatus r) == "stop:"]+                pure (cell scale points ending)+            putStrLn ("| " <> title <> " | " <> scale <> " | " <> intercalate " | " cells <> " |")+        putStrLn ""+  where+    safeHead xs = case xs of+        [] -> Nothing+        (x : _) -> Just x++    size scale r = if scale == "occurrences" then rowOccurrences r else rowNodes r++    cell :: String -> [Row] -> [Row] -> String+    cell _ [] _ = "-"+    cell scale points ending =+        let top = last points+            why = case ending of+                [] -> "interrupted"+                (r : _) -> case drop 5 (rowStatus r) of+                    "cap" -> "cap"+                    "budget" -> "budget"+                    other+                        | rowNodes r <= 1 -> "process: " <> other+                        | otherwise -> other <> " at " <> human (size scale r)+         in growth scale points <> ", " <> human (size scale top) <> sizeNote scale top <> ": " <> seconds (rowSecs top) <> ", " <> bytes (rowAlloc top) <> " (" <> why <> ")"++    -- for a series sized by occurrences, the node count it corresponds to.+    sizeNote scale r+        | scale == "occurrences" && rowOccurrences r /= rowNodes r = " (" <> human (rowNodes r) <> " nodes)"+        | otherwise = ""++    growth :: String -> [Row] -> String+    growth scale points =+        let usable = [(log (fromIntegral (size scale r)), log (rowSecs r)) | r <- points, rowSecs r >= 0.001]+            window = drop (length usable - 4) usable+         in if length window < 3+                then "too fast to fit"+                else printf "n^%.1f" (slope window)++    slope :: [(Double, Double)] -> Double+    slope pts =+        let n = fromIntegral (length pts)+            mx = sum (fmap fst pts) / n+            my = sum (fmap snd pts) / n+            sxx = sum [(x - mx) ^ (2 :: Int) | (x, _) <- pts]+            sxy = sum [(x - mx) * (y - my) | (x, y) <- pts]+         in if sxx == 0 then 0 else sxy / sxx++    human :: Integer -> String+    human n+        | n >= 999950 = printf "%.1fM" (fromIntegral n / 1e6 :: Double)+        | n >= 1000 = printf "%.1fk" (fromIntegral n / 1e3 :: Double)+        | otherwise = show n++    seconds :: Double -> String+    seconds s+        | s >= 1 = printf "%.1f s" s+        | s >= 0.001 = printf "%.0f ms" (s * 1e3)+        | otherwise = printf "%.0f us" (s * 1e6)++    bytes :: Double -> String+    bytes b+        | b >= 1e9 = printf "%.1f GB" (b / 1e9)+        | b >= 1e6 = printf "%.0f MB" (b / 1e6)+        | otherwise = printf "%.0f kB" (b / 1e3)
salmon-ops-recipes.cabal view
@@ -1,6 +1,6 @@ cabal-version:      2.4 name:               salmon-ops-recipes-version:            0.1.0.0+version:            0.2.0.0 synopsis:           Opinionated recipes built from Salmon builtins. description:        Higher-level recipes (postgres migrations, postgres primary/standby pairs, micro DNS, ...) composed from salmon-ops builtins. homepage:           https://lucasdicioccio.github.io/salmon/@@ -33,13 +33,17 @@                    , SreBox.PostgresBackup                    , SreBox.PostgresTemplate                    , SreBox.PostgresPair+                   , SreBox.PostgresPairPrereqs+                   , SreBox.PgVectorDemo                    , SreBox.JWTSigning                    , SreBox.WireGuardVpn+                   , SreBox.WireGuardMesh                    , SreBox.Gcp.VmProvision                    , SreBox.Gcp.CloudRunDeploy                    , SreBox.Gcp.CloudRunAlerts                    , SreBox.Gcp.PostgrestCloudRun                    , SreBox.Gcp.PreviewEnvironment+                   , SreBox.Gcp.PlakarGcsBackup     build-depends:    base >=4.16.3.0 && <4.22                     , aeson                     , base16-bytestring@@ -68,7 +72,7 @@                     , process                     , process-extras                     , salmon-core ^>=0.1.0.0-                    , salmon-ops ^>=0.1.0.0+                    , salmon-ops ^>=0.2.0.0                     , text                     , time                     , tls@@ -88,19 +92,46 @@     hs-source-dirs:   test     other-modules:    Test.Harness                     , Test.CheckSpec+                    , Test.AmendNodeSpec+                    , Test.CardTemplateSpec+                    , Test.BuiltinCardsSpec+                    , Test.HttpServerFlagSpec+                    , Test.ClientModelReference                     , Test.ClientModelSpec+                    , Test.ClientModelStepSpec                     , Test.AptRepositorySpec                     , Test.DebianPackageSpec+                    , Test.ClipCppSpec                     , Test.LlamaServerSpec                     , Test.ServeApi                     , Test.ServeApiSpec                     , Test.PgVectorSpec+                    , Test.PgVectorDemoSpec+                    , Test.PgTextSearchSpec                     , Test.PlakarSpec                     , Test.WireGuardSpec+                    , Test.WireGuardMeshSpec+                    , Test.NetbirdSpec+                    , Test.PortMappingSpec+                    , Test.RsyncSpec                     , Test.DebootstrapSpec                     , Test.ConcurrentSpec+                    , Test.BinaryOutputSpec+                    , Test.BinarySpec                     , Test.DaemonSpec+                    , Test.DetachedSpec                     , Test.DagSpec+                    , Test.DagPerNodeSpec+                    , Test.DagReference+                    , Test.DagStuckSpec+                    , Test.EdgeListReference+                    , Test.EdgeListSpec+                    , Test.GraphFixture+                    , Test.GraphFixtureSpec+                    , Test.GraphScaleSpec+                    , Test.ListedPathsSpec+                    , Test.PathListReference+                    , Test.DeferredSpec                     , Test.DownTreeSpec                     , Test.FilesystemSpec                     , Test.FollowCacheSpec@@ -109,31 +140,42 @@                     , Test.FollowSignatureSpec                     , Test.FollowSpec                     , Test.GcpSpec+                    , Test.SecretDeliverySpec                     , Test.PodmanCommandSpec                     , Test.JWTSigningSpec                     , Test.LedgerSpec                     , Test.MigratorTemplateSpec                     , Test.PgBackupSpec+                    , Test.PgBackRestSpec                     , Test.PodmanSpec                     , Test.PostgresBackupSpec                     , Test.PostgresInitSpec                     , Test.PostgresReplicationSpec                     , Test.PostgresClusterSpec                     , Test.PgBouncerSpec+                    , Test.HaproxySpec                     , Test.EtcdSpec+                    , Test.PatroniSpec+                    , Test.PatroniPreferredLeaderSpec+                    , Test.PatroniRollingMaintenanceSpec                     , Test.PgPairDemoSpec+                    , Test.PatroniArchiveVmsSpec                     , Test.PatroniHarnessSpec                     , Test.PatroniVms                     , Test.PostgresPairSpec+                    , Test.PostgresPairPrereqsSpec                     , Test.PostgresSwitchoverSpec                     , Test.PostgresVms                     , Test.PostgresTemplateSpec+                    , Test.PostgresUserSpec                     , Test.PostgresTlsSpec                     , Test.PostgrestCloudRunSpec                     , Test.QemuResolveKernelSpec                     , Test.QemuShutdownSpec                     , Test.QemuSmokeSpec                     , Test.QuerySpec+                    , Test.ReadinessSpec+                    , Test.RemoteOutputSpec                     , Test.ReportJsonSpec                     , Test.RewriteSpec                     , Test.ServeEventsSpec@@ -143,10 +185,18 @@                     , Test.ServeSpec                     , Test.ServeTlsSpec                     , Test.StatusSinkSpec+                    , Test.QuadletSpec+                    , Test.QuadletUserSpec+                    , Test.SharedDirSpec+                    , Test.SystemdJobSpec                     , Test.SystemdSpec+                    , Test.UiTailTemplateSpec+                    , Test.UpDownTextSpec                     , Test.UpTreeSpec                     , Test.UpkeepSpec+                    , Test.UpkeepRetentionSpec                     , Test.WindowSpec+                    , Test.WrapperOrderingSpec     build-depends:    QuickCheck                     , base >=4.16.3.0 && <4.22                     , aeson@@ -159,6 +209,7 @@                     , directory                     , filepath                     , free+                    , ghc-heap                     , http-client                     , http-client-tls                     , http-types@@ -169,8 +220,8 @@                     , process-extras                     , stm                     , salmon-core ^>=0.1.0.0-                    , salmon-ops ^>=0.1.0.0-                    , salmon-ops-recipes ^>=0.1.0.0+                    , salmon-ops ^>=0.2.0.0+                    , salmon-ops-recipes ^>=0.2.0.0                     , tasty                     , tasty-hunit                     , tasty-quickcheck@@ -183,8 +234,46 @@                     , warp     -- Salmon.Actions.Serve reads its input handle on a forked thread, so a     -- test driving the loop wants a runtime where a blocking read does not-    -- stall every other Haskell thread.-    ghc-options:      -threaded+    -- stall every other Haskell thread. -rtsopts so that a large-graph run+    -- (Test.GraphFixtureSpec's opt-in case) can be given a heap bound.+    ghc-options:      -threaded -rtsopts+    default-language: Haskell2010+    default-extensions: DataKinds+                      , KindSignatures+                      , OverloadedStrings+                      , OverloadedRecordDot+                      , ScopedTypeVariables+                      , TypeApplications++-- How each graph operation scales, per shape and size: see+-- resources/graph-profiles.md. A benchmark stanza so that neither+-- `cabal build all` nor `cabal test` builds or runs it; it shares the test+-- suite's graph fixture by source directory.+benchmark graph-profiles+    type:             exitcode-stdio-1.0+    main-is:          GraphProfiles.hs+    hs-source-dirs:   bench test+    other-modules:    Test.GraphFixture+                    , Test.DagReference+                    , Test.PathListReference+    build-depends:    base >=4.16.3.0 && <4.22+                    , aeson+                    , bytestring+                    , comonad+                    , containers+                    , free+                    , mtl+                    , optparse-applicative+                    , process+                    , stm+                    , unix+                    , salmon-core ^>=0.1.0.0+                    , salmon-ops ^>=0.2.0.0+                    , text+                    , time+    -- one capability, as the binaries in salmon-apps are built; -T for the+    -- allocation counter; -rtsopts so a run can be given a heap bound.+    ghc-options:      -threaded -rtsopts "-with-rtsopts=-T"     default-language: Haskell2010     default-extensions: DataKinds                       , KindSignatures
src/SreBox/DNSRegistration.hs view
@@ -57,7 +57,10 @@     (RegisteredMachineSetup -> directive) ->     Op setupRegistration r mkRemote simulate selfpath selfRemote dns cfg toSpec =-    op "registration" (deps [trackedGraph remoteRegistration, uploadPem, uploadToken]) id+    -- The uploads are injected onto the remote call rather than listed beside+    -- it: the call reads both files, and nodes listed side by side are+    -- unordered.+    op "registration" (deps [trackedGraph remoteRegistration `inject` uploadPem `inject` uploadToken]) id   where     rsyncRemote :: Rsync.Remote     rsyncRemote = (\(Self.Remote a b) -> Rsync.Remote a b) selfRemote
src/SreBox/Gcp/CloudRunAlerts.hs view
@@ -25,6 +25,7 @@     defaultAlertThresholds,     CloudRunAlertsConfig (..),     standardAlerts,+    standardAlertsAfter,     standardPolicies,     Report (..), ) where@@ -87,8 +88,19 @@  -- | The channel and the policies, under one node. standardAlerts :: Reporter Report -> Track' (Binary "gcloud") -> CloudRunAlertsConfig -> Op-standardAlerts r gcloudTrack cfg =-    op "gcp-cloudrun-alerts" (deps (map (Monitoring.alertPolicy rMon gcloudTrack) (standardPolicies cfg))) $ \actions ->+standardAlerts = standardAlertsAfter []++{- | 'standardAlerts', with nodes every policy and the channel wait for+(@monitoring.googleapis.com@ enabled, typically).++The node returned (@gcp-cloudrun-alerts@) has no effect of its own: a node+@inject@ed onto it is a sibling of the policies and the channel, not+something they wait for. These go under each of them instead. With @[]@ the+graph is 'standardAlerts''s, unchanged.+-}+standardAlertsAfter :: [Op] -> Reporter Report -> Track' (Binary "gcloud") -> CloudRunAlertsConfig -> Op+standardAlertsAfter prerequisites r gcloudTrack cfg =+    op "gcp-cloudrun-alerts" (deps (map (Monitoring.alertPolicyAfter prerequisites rMon gcloudTrack) (standardPolicies cfg))) $ \actions ->         actions             { help = Text.unwords ["the standard alerts for Cloud Run service", cfg.cra_service, "to", cfg.cra_email]             , ref = mkRef "gcp-cloudrun-alerts" (cfg.cra_project.projectId, cfg.cra_region.regionName, cfg.cra_service)
src/SreBox/Gcp/CloudRunDeploy.hs view
@@ -22,6 +22,7 @@ module SreBox.Gcp.CloudRunDeploy (     CloudRunDeployConfig (..),     buildPushDeploy,+    buildPushDeployAfter,     Report (..), ) where @@ -88,7 +89,30 @@     Track' (Binary "podman") ->     CloudRunDeployConfig ->     Op-buildPushDeploy r gcloudTrack podmanTrack cfg =+buildPushDeploy = buildPushDeployAfter [] []++{- | 'buildPushDeploy', with nodes the build waits for (a file the+Containerfile copies, say), and nodes the deploy waits for (the Cloud Run+API, the service account, the secrets the revision mounts and their grants).++The node returned (@gcp-cloudrun-deploy@) has no effect of its own: a node+@inject@ed onto it is a sibling of the build, the push and the deploy, not+something they wait for. These go under the build and under the deploy+instead. Neither list may hold a node of this recipe's own (the repository,+say): that would be a node waiting for itself. With @[] []@ the graph is+'buildPushDeploy''s, unchanged.+-}+buildPushDeployAfter ::+    -- | under the build (so under the push and the deploy too)+    [Op] ->+    -- | under the deploy+    [Op] ->+    Reporter Report ->+    Track' (Binary "gcloud") ->+    Track' (Binary "podman") ->+    CloudRunDeployConfig ->+    Op+buildPushDeployAfter beforeBuild beforeDeploy r gcloudTrack podmanTrack cfg =     op "gcp-cloudrun-deploy" (deps [deployed]) $ \actions ->         actions             { help = Text.unwords ["builds, pushes and deploys", cfg.crd_image, "as CloudRun service", cfg.crd_service]@@ -116,16 +140,21 @@             `inject` repo      built :: Op-    built = Podman.buildImage rPodman podmanTrack cfg.crd_containerfile cfg.crd_image+    built = foldl inject (Podman.buildImage rPodman podmanTrack cfg.crd_containerfile cfg.crd_image) beforeBuild      pushed :: Op     pushed =-        Podman.push rPodman podmanTrack (Just cfg.crd_authFile) cfg.crd_image+        -- logs in again itself: the build sits between the login node and+        -- this one, and an access token lasts an hour+        Podman.pushLoggingIn rPodman podmanTrack cfg.crd_authFile registry (Podman.Username "oauth2accesstoken") Core.printAccessToken cfg.crd_image             `inject` built             `inject` loggedIn      deployed :: Op-    deployed =+    deployed = foldl inject service beforeDeploy++    service :: Op+    service =         CloudRun.cloudRunService             rCloudRun             gcloudTrack
+ src/SreBox/Gcp/PlakarGcsBackup.hs view
@@ -0,0 +1,148 @@+{-# LANGUAGE OverloadedStrings #-}++{- | A Plakar backup job whose Kloset lives in a Google Cloud Storage bucket,+built from what the tree already has: 'Storage.bucket', 'Iam.serviceAccount',+'Iam.iamBinding' (object admin on that one bucket) and+'Iam.serviceAccountKey', feeding the GCS store of+"Salmon.Builtin.Nodes.Plakar" and the job on top of it.++= What it sets and what it leaves to the bucket's owner++It creates the bucket with uniform bucket-level access, and nothing else:+__no retention policy, no object versioning, no lifecycle rules__. Plakar+advertises immutable snapshots, but how long a bucket refuses deletes is a+bucket property (as for "SreBox.PostgresBackup"), and a job whose @prune@ has+to delete objects would be broken by a retention lock shorter than its own+'KeepPolicy'. Set those on the bucket yourself.++= Credentials++A service-account key file, written by 'Iam.serviceAccountKey' (so by whoever+runs salmon, with gcloud's own file mode) and then only ever named by path. The+job's user must be able to read it, and the Plakar passphrase keyfile+('gbKeyfile') is provisioned by somebody else, as for the local store. Workload+identity is not supported.++= The integration version has no default++'gbGcsVersion' is a 'Pinned' release or an explicit 'Latest'.+-}+module SreBox.Gcp.PlakarGcsBackup (+    GcsBackup (..),+    gcsBackup,+    gcsServiceAccountEmail,+    gcsBucketRole,+    Report (..),+) where++import Data.Text (Text)+import qualified Data.Text as Text+import Data.Time (NominalDiffTime)++import Salmon.Builtin.Extension+import Salmon.Builtin.Nodes.Binary (Binary)+import Salmon.Builtin.Nodes.CronTask (Schedule)+import qualified Salmon.Builtin.Nodes.Gcp.Core as Core+import qualified Salmon.Builtin.Nodes.Gcp.Iam as Iam+import qualified Salmon.Builtin.Nodes.Gcp.Storage as Storage+import Salmon.Builtin.Nodes.Plakar+import Salmon.Op.Ref (mkRef)+import Salmon.Op.Track+import Salmon.Reporter++data Report+    = StorageReport !Storage.Report+    | IamReport !Iam.Report+    deriving (Show)++data GcsBackup = GcsBackup+    { gbProject :: Core.Project+    , gbBucket :: Text+    , gbLocation :: Core.Region+    , gbServiceAccountId :: Text+    -- ^ the account id (before the @\@@), created if absent+    , gbCredentialsFile :: FilePath+    -- ^ where its key is written, and the store reads it+    , gbStoreName :: Text+    , gbStorePrefix :: Text+    , gbGcsVersion :: IntegrationVersion+    , gbCredentialsOption :: Text+    -- ^ the GCS integration's option for the credentials file; see 'gcsCredentialsOption'+    , gbKeyfile :: FilePath+    -- ^ the Kloset passphrase, provisioned by somebody else+    , gbJobName :: Text+    , gbSource :: FilePath+    , gbSchedule :: Schedule+    , gbUser :: Text+    -- ^ runs the job, and owns the integration and the store configuration+    , gbKeep :: KeepPolicy+    , gbMaxAge :: NominalDiffTime+    , gbScriptPath :: FilePath+    }++-- | @ID\@PROJECT.iam.gserviceaccount.com@.+gcsServiceAccountEmail :: GcsBackup -> Text+gcsServiceAccountEmail b = b.gbServiceAccountId <> "@" <> b.gbProject.projectId <> ".iam.gserviceaccount.com"++-- | The role the account gets, on the bucket only.+gcsBucketRole :: Text+gcsBucketRole = "roles/storage.objectAdmin"++gcsBackup :: Reporter Report -> Track' (Binary "plakar") -> GcsBackup -> Op+gcsBackup r plakar b =+    plakarJobOn remote job+  where+    gcloud = Core.gcloud+    sr = contramap StorageReport r+    ir = contramap IamReport r+    user = Just b.gbUser++    bucketOp =+        Storage.bucket+            sr+            gcloud+            (Storage.Bucket b.gbBucket b.gbProject b.gbLocation True)+    accountOp = Iam.serviceAccount ir gcloud b.gbProject b.gbServiceAccountId+    bindingOp =+        Iam.iamBinding+            ir+            gcloud+            (Iam.IamBinding (Iam.ServiceAccount (gcsServiceAccountEmail b)) gcsBucketRole ("buckets/" <> b.gbBucket))+    keyOp = Iam.serviceAccountKey ir gcloud (Iam.ServiceAccountKey b.gbProject b.gbServiceAccountId b.gbCredentialsFile)++    -- everything the store's configuration needs to exist first+    prerequisites =+        op "plakar-gcs-prerequisites" (deps [bucketOp, accountOp, bindingOp, keyOp, integrationOp]) $ \actions ->+            actions+                { help = Text.unwords ["gs://" <> b.gbBucket, "with an account and key for plakar"]+                , notes = ["bucket retention, versioning and lifecycle are left to the bucket's owner"]+                , ref = mkRef "plakar-gcs-prerequisites" (b.gbProject.projectId, b.gbBucket, b.gbStoreName)+                }++    integrationOp = plakarIntegration plakar (Integration "gcs" b.gbGcsVersion user)++    storeOp =+        gcsStore+            prerequisites+            GcsStore+                { gcsStoreName = b.gbStoreName+                , gcsBucket = b.gbBucket+                , gcsPrefix = b.gbStorePrefix+                , gcsCredentialsFile = b.gbCredentialsFile+                , gcsCredentialsOption = b.gbCredentialsOption+                , gcsStoreUser = user+                }++    remote = remoteKloset storeOp user b.gbStoreName b.gbKeyfile++    job =+        PlakarJob+            { jobName = b.gbJobName+            , jobStore = KlosetStore ("@" <> Text.unpack b.gbStoreName) b.gbKeyfile+            , jobSource = b.gbSource+            , jobSchedule = b.gbSchedule+            , jobUser = b.gbUser+            , jobKeep = b.gbKeep+            , jobMaxAge = b.gbMaxAge+            , jobScriptPath = b.gbScriptPath+            }
src/SreBox/Gcp/PostgrestCloudRun.hs view
@@ -161,11 +161,19 @@      (localCert, localKey, localCa) = cfg.pcr_clientMaterial +    -- The entrypoint goes under the build and the secrets and grants under+    -- the deploy, through 'CloudRunDeploy.buildPushDeployAfter':+    -- @gcp-cloudrun-deploy@ has no effect of its own, and injected onto it+    -- alone they were siblings of the build and the deploy, which could then+    -- run first. The injections onto it are kept, they leave that node's+    -- edges as they were.     deploy :: Op     deploy =         foldl             inject-            ( CloudRunDeploy.buildPushDeploy+            ( CloudRunDeploy.buildPushDeployAfter+                [entrypoint]+                secretsAndGrants                 rDeploy                 gcloudTrack                 podmanTrack
src/SreBox/Gcp/VmProvision.hs view
@@ -24,16 +24,26 @@ module SreBox.Gcp.VmProvision (     VmProvisionConfig (..),     provisionedVm,+    provisionedVmReadingHost,+    handOff,     Report (..),++    -- * Making the instance trust the CA+    caTrustStartupScript,+    caTrustStartupScriptFile,+    withStartupScriptFile, ) where  import Data.Aeson (FromJSON, ToJSON)+import qualified Data.Map as Map import Data.Text (Text) import qualified Data.Text as Text  import Salmon.Builtin.Extension import qualified Salmon.Builtin.CommandLine as CLI import Salmon.Builtin.Nodes.Binary (Binary)+import qualified Salmon.Builtin.Nodes.Deferred as Deferred+import qualified Salmon.Builtin.Nodes.Filesystem as FS import qualified Salmon.Builtin.Nodes.Gcp.Compute as Compute import qualified Salmon.Builtin.Nodes.Gcp.SshAccess as SshAccess import Salmon.Builtin.Nodes.Keys (SSHKeyPair)@@ -53,6 +63,8 @@     | RunSshAccess !SshAccess.Report     | RunKeys !Keys.Report     | RunSelf !Self.Report+    | -- | 'provisionedVmReadingHost': the hand-off's own nested walk.+      RunHandOff !Deferred.Report     deriving (Show)  -------------------------------------------------------------------------------@@ -63,19 +75,22 @@ The SSH endpoint (host/port/user) is supplied by the caller rather than derived from the 'Compute.Instance', mirroring @specs/gcloud-support.md@ section 6's own signature: an ephemeral instance's-address generally is not known until after 'up' runs, so a recipe that needs-one has to either reserve a static address up front or thread it through-some other channel -- deriving it here would just move that problem, not-solve it.+address generally is not known until after 'up' runs. A caller that knows the+host (a declared internal address, a second pass handed the address by its+driver) writes it in 'vmp_sshHost' and uses 'provisionedVm'; one that does not+(a reserved external address, which GCP picks) gives a read of it to+'provisionedVmReadingHost' instead. -} data VmProvisionConfig directive = VmProvisionConfig     { vmp_name :: Text     -- ^ a unique name for this provisioning declaration, used as the 'Ref' key.     , vmp_instance :: Compute.Instance     , vmp_ca :: SSHKeyPair-    -- ^ the CA whose public key is pushed into project metadata; instances-    -- must be configured (e.g. via a startup script writing @sshd_config@'s-    -- @TrustedUserCAKeys@) to trust it, which is outside this module's scope.+    -- ^ the CA whose public key is pushed into project metadata. Nothing on+    -- an instance reads that key by itself: the instance has to boot with a+    -- startup script that does, which is 'caTrustStartupScript' (written to+    -- a file by 'caTrustStartupScriptFile', a node for 'vmp_prerequisites',+    -- and named in 'vmp_instance' by 'withStartupScriptFile').     , vmp_clientIdentity :: SSHKeyPair     -- ^ the key salmon connects with, signed by 'vmp_ca'.     , vmp_sshUser :: Text@@ -102,6 +117,66 @@     , vmp_directive :: directive     } +{- | The startup script that makes an instance trust the CA 'provisionedVm'+publishes -- the piece 'SshAccess.installMetadataCaKey' deliberately does not+do: it puts the CA's public key in project metadata, and nothing on a GCE+instance reads that key by itself.++Given the login user (the principal the client certificate names, i.e.+'vmp_sshUser'), the script: waits for the @ssh-ca@ project attribute and+writes it to @\/etc\/ssh\/salmon_ca.pub@; points sshd's @TrustedUserCAKeys@ at+it; creates the user (without OS Login a principal has to be a local+account); gives it passwordless sudo ('Self.uploadAndCallSelfAsSudoWith' runs+the uploaded binary under sudo); makes sure @rsync@ is there for the upload;+and restarts sshd. Idempotent, because a startup script runs on every boot.++The wait loop is load-bearing. The key is published just before the instance+is created, and "just before" is not "already visible from inside the guest":+a bare @curl -f@ that meets a 404 aborts the whole script under @set -e@,+leaving a machine with no CA, no login user and an sshd that was never+restarted -- one nobody can log into, fixable only by a reset. That is the+reason this lives here rather than in each consumer's copy.++The user is spliced into shell unquoted, so it must be a plain account name;+it comes from the declaration, never from input.+-}+caTrustStartupScript :: Text -> Text+caTrustStartupScript user =+    Text.unlines+        [ "#!/bin/bash"+        , "set -eux"+        , "for attempt in $(seq 1 30); do"+        , "  if curl -fsS -H 'Metadata-Flavor: Google' \\"+        , "      http://metadata.google.internal/computeMetadata/v1/project/attributes/ssh-ca \\"+        , "      > /etc/ssh/salmon_ca.pub; then break; fi"+        , "  echo \"ssh-ca not in metadata yet (attempt $attempt)\"; sleep 2"+        , "done"+        , "test -s /etc/ssh/salmon_ca.pub"+        , "chmod 644 /etc/ssh/salmon_ca.pub"+        , "grep -qxF 'TrustedUserCAKeys /etc/ssh/salmon_ca.pub' /etc/ssh/sshd_config \\"+        , "  || echo 'TrustedUserCAKeys /etc/ssh/salmon_ca.pub' >> /etc/ssh/sshd_config"+        , "id -u " <> user <> " >/dev/null 2>&1 || useradd -m -s /bin/bash " <> user+        , "printf '%s ALL=(ALL) NOPASSWD:ALL\\n' " <> user <> " > /etc/sudoers.d/" <> user+        , "chmod 440 /etc/sudoers.d/" <> user+        , "command -v rsync >/dev/null || { apt-get update -qq && apt-get install -y rsync; }"+        , "systemctl restart ssh || systemctl restart sshd"+        ]++{- | 'caTrustStartupScript' written to a local file, as a node: @gcloud@ takes+a startup script by path (@--metadata-from-file@), so the file has to exist+before the instance is created. Belongs in 'vmp_prerequisites'.+-}+caTrustStartupScriptFile :: FilePath -> Text -> Op+caTrustStartupScriptFile path user =+    FS.filecontents (FS.FileContents path (caTrustStartupScript user))++{- | Points an instance's @startup-script@ metadata at a local file, replacing+whichever one it named and leaving every other metadata file alone.+-}+withStartupScriptFile :: FilePath -> Compute.Instance -> Compute.Instance+withStartupScriptFile path inst =+    inst{Compute.instanceMetadataFiles = Map.insert "startup-script" path inst.instanceMetadataFiles}+ {- | How this recipe's ssh, rsync and probe all authenticate: the signed client key, and a known-hosts file kept beside it rather than in the calling user's @~\/.ssh@.@@ -135,16 +210,95 @@     VmProvisionConfig directive ->     Op provisionedVm r gcloudTrack keygenTrack cfg =-    op "gcp-vm-provision" (deps [foldl inject (trackedGraph call) (sshReady : beforeCall)]) $ \actions ->+    op "gcp-vm-provision" (deps [handOff r cfg [vm, signedClient]]) $ \actions ->         actions-            { help = Text.unwords ["provisions GCE VM", cfg.vmp_instance.instanceName, "over SSH and runs the self binary on it"]+            { help = provisionHelp cfg             , ref = mkRef "gcp-vm-provision" cfg.vmp_name             }   where+    (vm, signedClient) = machine r gcloudTrack keygenTrack cfg++{- | 'provisionedVm' for a host that is not known when the graph is declared+-- an instance behind a reserved external address, which GCP picks -- so that+one pass creates the machine /and/ provisions it.++The host is a read (@'Compute.readAddress' addr@, typically) done once the+instance is up, and everything that has to name the machine -- the ssh probe,+the 'vmp_beforeCall' uploads, the remote call -- is a+"Salmon.Builtin.Nodes.Deferred" sub-graph built from what the read found.+The instance, the CA and the signed client key name nothing and stay+ordinary nodes, declared and ordered as in 'provisionedVm'.++Of the config, 'vmp_sshHost' is overwritten with the host read (give it+anything), and the completion function is applied after that: it is where a+'vmp_beforeCall' that needs the host is set, since one written in the config+would have to close over a host nobody has yet.++Three things follow from the hand-off being a nested walk, beyond what+'Salmon.Builtin.Nodes.Deferred.deferred' says of any:++* The node that makes the read answer has to be up before the instance is:+  the address belongs in 'vmp_prerequisites', where an instance claiming it+  by name needs it anyway.+* The 'vmp_beforeCall' nodes are walked /down/ by this node's @down@ along+  with the probe and the call, dependencies included. Whatever they stand on+  that is also declared elsewhere should be injected into this recipe's node+  rather than into them.+* A read that finds nothing fails this node's @up@ (nothing is provisioned+  at a guess) and makes its @down@ a no-op.+-}+provisionedVmReadingHost ::+    forall directive.+    (FromJSON directive, ToJSON directive) =>+    Reporter Report ->+    Track' (Binary "gcloud") ->+    Track' (Binary "ssh-keygen") ->+    -- | the ssh host, once the instance is up; e.g. @Compute.readAddress addr@+    IO (Maybe Text) ->+    -- | completes the config with the host just read+    (Text -> VmProvisionConfig directive -> VmProvisionConfig directive) ->+    VmProvisionConfig directive ->+    Op+provisionedVmReadingHost r gcloudTrack keygenTrack readHost complete cfg =+    op "gcp-vm-provision" (deps [late `inject` vm `inject` signedClient]) $ \actions ->+        actions+            { help = provisionHelp cfg+            , notes = ["ssh host read once the instance is up"]+            , ref = mkRef "gcp-vm-provision" cfg.vmp_name+            }+  where+    (vm, signedClient) = machine r gcloudTrack keygenTrack cfg++    late :: Op+    late =+        Deferred.deferred+            (contramap RunHandOff r)+            Deferred.Deferred+                { Deferred.deferredName = "gcp-vm-provision:" <> cfg.vmp_name+                , Deferred.deferredHelp =+                    Text.unwords ["reads the address of", cfg.vmp_instance.instanceName, "then waits for ssh and runs the self binary on it"]+                , Deferred.deferredRead = readHost+                , Deferred.deferredGraph = \host -> handOff r (complete host cfg{vmp_sshHost = host}) []+                }++provisionHelp :: VmProvisionConfig directive -> Text+provisionHelp cfg =+    Text.unwords ["provisions GCE VM", cfg.vmp_instance.instanceName, "over SSH and runs the self binary on it"]++{- | The half that names no host: the instance (after the CA is published and+every 'vmp_prerequisites' node), and the client key signed by that CA.+-}+machine ::+    Reporter Report ->+    Track' (Binary "gcloud") ->+    Track' (Binary "ssh-keygen") ->+    VmProvisionConfig directive ->+    (Op, Op)+machine r gcloudTrack keygenTrack cfg = (vm, signedClient)+  where     rCompute = contramap RunCompute r     rSshAccess = contramap RunSshAccess r     rKeys = contramap RunKeys r-    rSelf = contramap RunSelf r      -- The CA's public key has to be in project metadata *before* the     -- instance boots: the instance's startup script reads it from there to@@ -178,29 +332,63 @@             [Keys.Principal cfg.vmp_sshUser]             cfg.vmp_clientIdentity +{- | The half that names the host ('vmp_sshHost'): the ssh probe, standing on+the nodes given, then the upload of the self binary and every+'vmp_beforeCall' node (each waiting for the probe), then the remote call,+which waits for all of them.++The node returned is the call itself, so these are edges of the call and not+of something beside it: a probe that never answers blocks the upload and the+call, and under @run serve@ the call cannot start while an upload is still in+flight. Exported for the test that says so (@Test.DeferredSpec@).+-}+handOff ::+    forall directive.+    (FromJSON directive, ToJSON directive) =>+    Reporter Report ->+    VmProvisionConfig directive ->+    [Op] ->+    Op+handOff r cfg sshNeeds = foldl inject (trackedGraph call) (uploaded : sshReady : beforeCall)+  where+    rSshAccess = contramap RunSshAccess r+    rSelf = contramap RunSelf r+     sshReady :: Op     sshReady =-        SshAccess.sshAvailable-            rSshAccess-            (SshAccess.SshEndpoint (Just cfg.vmp_sshUser) cfg.vmp_sshHost cfg.vmp_sshPort (clientOpts cfg))-            `inject` vm-            `inject` signedClient+        foldl+            inject+            ( SshAccess.sshAvailable+                rSshAccess+                (SshAccess.SshEndpoint (Just cfg.vmp_sshUser) cfg.vmp_sshHost cfg.vmp_sshPort (clientOpts cfg))+            )+            sshNeeds      beforeCall :: [Op]     beforeCall = [step `inject` sshReady | step <- cfg.vmp_beforeCall (clientOpts cfg)] -    call :: Tracked' (Self.RemoteCall directive)-    call =-        -- The key this recipe just had signed lives at a path of its own-        -- choosing, which ssh has no reason to offer otherwise.-        Self.uploadAndCallSelfAsSudoWith+    -- The key this recipe just had signed lives at a path of its own+    -- choosing, which ssh has no reason to offer otherwise: hence the+    -- client options on both the upload and the call.+    upload :: Tracked' Self.RemoteSelf+    upload =+        Self.uploadSelfWith             (clientOpts cfg)             rSelf-            rSelf             cfg.vmp_remoteDir             (Self.Remote cfg.vmp_sshUser cfg.vmp_sshHost)             cfg.vmp_selfPath++    uploaded :: Op+    uploaded = trackedGraph upload `inject` sshReady++    call :: Tracked' (Self.RemoteCall directive)+    call =+        Self.callSelfAsSudoWith+            (clientOpts cfg)+            rSelf             Ssh.preExistingRemoteMachine+            upload.obj             cfg.vmp_directiveTrack             CLI.Up             cfg.vmp_directive
src/SreBox/Initialize.hs view
@@ -22,7 +22,7 @@   where     sudoerfile :: Op     sudoerfile =-        FS.filecontents+        FS.filecontentsInSharedDir             (FS.FileContents "/etc/sudoers.d/salmon" sudoercontent)      sudoercontent :: Text
+ src/SreBox/PgVectorDemo.hs view
@@ -0,0 +1,577 @@+{-# LANGUAGE OverloadedStrings #-}++{- | A demonstration of the embedding builtins end to end, on one machine:+__walk a directory, embed each file, keep the vectors in Postgres, and answer+"what is closest to this?"__. One @run up@ yields a table one can query by+meaning. It is a demo of the builtins, not a product.++The graph, in dependency order:++1. pgvector in the database ("Salmon.Builtin.Nodes.PgVector"), which brings+   the database (the caller's 'Track'') and the package (after the caller's+   choice of source).+2. 'schema': the table and its @hnsw@ index. The width of the column is the+   embedder's 'embDimension', never typed a second time, and a table already+   there with another width is refused rather than altered.+3. The embedder's own node ('embNode'): a llama-server for text+   ('llamaEmbedder'), clip.cpp for text and images ('clipEmbedder'), or the+   deterministic stand-in ('hashEmbedder') that needs no model.+4. 'indexDirectory': for each file of the directory whose content (or+   embedder) is not the one its row records, embed it and upsert the row.+   Its @check@ is "every file has a current row", so a second @run up@+   embeds nothing and @run serve@ notices a new or changed file.++'search' is the read side: embed the query with the same embedder and order+the rows by cosine distance. It never writes.++= What it decides++* __One embedder per table.__ Vectors from two models are not comparable,+  so the table records which embedder made each row ('embName') and a row+  from another one counts as stale. Text and images share a table only when+  one embedder takes both (clip.cpp).+* __The walk__ ('WalkPolicy'): recursive, sorted, no hidden files or+  directories, symbolic links not followed, kind by extension, whole files+  under a size cap, no chunking. Every skipped file is reported with why.+* __Rows of deleted files are left__, and counted in the report: dropping+  them is a deletion the operator did not name. Neither node's @down@ drops+  anything either.++= What is public++Paths and the embedder's name are in the reports. The files' contents are+not, but a text file given to 'clipEmbedder' is on @extract@'s command line+(see "Salmon.Builtin.Nodes.ClipCpp"); 'llamaEmbedder' sends it to @curl@ on+stdin. Do not index a directory of secrets.+-}+module SreBox.PgVectorDemo (+    Report (..),++    -- * Embedders+    Kind (..),+    kindName,+    Input (..),+    Embedder (..),+    llamaEmbedder,+    clipEmbedder,+    hashEmbedder,+    hashVector,++    -- * The walk+    WalkPolicy (..),+    defaultWalkPolicy,+    Skip (..),+    classify,+    walk,++    -- * The nodes+    Demo (..),+    defaultDemo,+    pgVectorDemo,+    schema,+    indexDirectory,++    -- * Search+    Hit (..),+    search,++    -- * Pieces, exposed for tests+    Candidate (..),+    vectorLiteral,+    schemaSql,+    inspectSchemaSql,+    interpretSchemaRow,+    indexedSql,+    parseIndexed,+    pendingFiles,+    staleCount,+    upsertSql,+    searchSql,+    parseHits,+    contentHash,+) where++import Control.Exception (throwIO)+import Control.Monad (forM, forM_, unless)+import qualified Crypto.Hash.SHA256 as SHA256+import qualified Data.ByteString as ByteString+import qualified Data.ByteString.Char8 as C8+import Data.Char (toLower)+import Data.List (sort)+import qualified Data.Map.Strict as Map+import Data.Maybe (mapMaybe)+import qualified Data.Set as Set+import Data.Text (Text)+import qualified Data.Text as Text+import qualified Data.Text.Encoding as Text+import qualified Data.Text.Encoding.Error as TextError+import GHC.IO.Exception (ExitCode (..))+import System.Directory (getFileSize, listDirectory, pathIsSymbolicLink, doesDirectoryExist, doesFileExist)+import System.FilePath (splitDirectories, takeExtension, (</>))+import System.Process.ByteString (readCreateProcessWithExitCode)+import System.Process.ListLike (proc)+import Text.Printf (printf)++import Salmon.Actions.UpDown (CheckResult (..))+import Salmon.Builtin.Extension+import Salmon.Builtin.Nodes.Binary (Binary)+import qualified Salmon.Builtin.Nodes.ClipCpp as ClipCpp+import qualified Salmon.Builtin.Nodes.Debian.Package as Package+import Salmon.Builtin.Nodes.LlamaServer (LlamaServer (..), ModelFile (..), llamaEmbed)+import Salmon.Builtin.Nodes.PgVector (PgVector (..), pgvector)+import Salmon.Builtin.Nodes.Postgres (DatabaseName, Port, dollarQuote, quoteIdent, quoteLiteral)+import qualified Salmon.Builtin.Nodes.Postgres as Postgres+import Salmon.Op.OpGraph (inject)+import Salmon.Op.Ref+import Salmon.Op.Track+import Salmon.Reporter++-------------------------------------------------------------------------------++data Report+    = FileSkipped !FilePath !Skip+    | Indexed !FilePath !Kind+    | EmbedFailed !FilePath !Text+    | StaleRows !Int+    deriving (Show)++-------------------------------------------------------------------------------+-- Embedders++data Kind = TextKind | ImageKind+    deriving (Eq, Ord, Show)++-- | What the @kind@ column holds.+kindName :: Kind -> Text+kindName TextKind = "text"+kindName ImageKind = "image"++data Input+    = InputText Text+    | InputImage FilePath+    deriving (Eq, Show)++{- | What turns a file into a vector, and what must be up before it can.++'embName' is stored with every row, so a row made by another embedder is+re-embedded. It should change whenever the vectors would (another model,+another build), and must say nothing secret: it is in the table and in the+reports.+-}+data Embedder = Embedder+    { embName :: Text+    , embDimension :: Int+    , embKinds :: [Kind]+    , embNode :: Op+    , embEmbed :: Input -> IO (Either Text [Double])+    }++{- | Text only, through a running server. The node is the caller's (a+'Salmon.Builtin.Nodes.LlamaServer.llamaServerDaemon' for @run serve@, or a+'Salmon.Builtin.Nodes.LlamaServer.llamaServerSystemd'), since which run mode+fits is not this recipe's choice.+-}+llamaEmbedder :: Op -> LlamaServer -> Embedder+llamaEmbedder node s =+    Embedder+        { embName = "llama-server:" <> s.lsName <> ":" <> Text.take 16 s.lsModel.modelSha256+        , embDimension = s.lsDimension+        , embKinds = [TextKind]+        , embNode = node+        , embEmbed = \input -> case input of+            InputText t -> llamaEmbed s t+            InputImage _ -> pure (Left "llama-server embeds no images")+        }++-- | Text and images in one space. A text file is on @extract@'s command line.+clipEmbedder :: Reporter ClipCpp.Report -> ClipCpp.ClipTools -> ClipCpp.ClipCpp -> Embedder+clipEmbedder r tools c =+    Embedder+        { embName = "clip.cpp:" <> Text.take 16 c.ccSource.clipCommit <> ":" <> Text.take 16 c.ccModel.modelSha256+        , embDimension = c.ccDimension+        , embKinds = [TextKind, ImageKind]+        , embNode = ClipCpp.clipCpp r tools c+        , embEmbed = \input ->+            fmap (fmap realToFrac) <$> ClipCpp.embed c (case input of InputText t -> ClipCpp.EmbedText t; InputImage p -> ClipCpp.EmbedImage p)+        }++{- | A stand-in that needs no model: the vector is the sha256 of the bytes,+stretched to the width. It knows nothing of meaning (two files are close+only if they are the same file), but it makes the whole path runnable with+no model and no network, and the same text queried finds its own file at+distance 0. This is what the tests use.+-}+hashEmbedder :: Int -> Embedder+hashEmbedder dim =+    Embedder+        { embName = "sha256-stand-in"+        , embDimension = dim+        , embKinds = [TextKind, ImageKind]+        , embNode = op "pgvector-demo-hash-embedder" nodeps $ \actions ->+            actions+                { help = "a " <> tshow dim <> "-dimensional stand-in embedder (sha256, no model)"+                , ref = mkRef "pgvector-demo-hash-embedder" dim+                , up = pure ()+                }+        , embEmbed = \input -> case input of+            InputText t -> pure (Right (hashVector dim (Text.encodeUtf8 t)))+            InputImage p -> Right . hashVector dim <$> ByteString.readFile p+        }++-- | @dim@ values in @[-1, 1]@ from the bytes; never all zero, so a cosine distance is defined.+hashVector :: Int -> ByteString.ByteString -> [Double]+hashVector dim bytes =+    take dim [scale b | k <- [0 :: Int ..], b <- ByteString.unpack (SHA256.hash (bytes <> C8.pack (show k)))]+  where+    scale b = (fromIntegral b - 127.5) / 127.5++-------------------------------------------------------------------------------+-- The walk++data WalkPolicy = WalkPolicy+    { textExtensions :: [String]+    , imageExtensions :: [String]+    , maxTextBytes :: Integer+    , maxImageBytes :: Integer+    }+    deriving (Eq, Show)++{- | @.txt@, @.md@, @.rst@, @.org@ up to 64 KiB (whole files, no chunking:+a model truncates a long input at its own context anyway); @.jpg@, @.jpeg@,+@.png@, @.gif@ up to 10 MiB.+-}+defaultWalkPolicy :: WalkPolicy+defaultWalkPolicy = WalkPolicy [".txt", ".md", ".rst", ".org"] [".jpg", ".jpeg", ".png", ".gif"] (64 * 1024) (10 * 1024 * 1024)++data Skip+    = Hidden+    | SymbolicLink+    | UnknownExtension+    | TooLarge !Integer+    | Empty+    | NotEmbedded !Kind+    -- ^ the embedder takes no file of this kind+    deriving (Eq, Show)++{- | The verdict on one file, from its path relative to the directory and its+size: a kind, or why it is skipped.+-}+classify :: WalkPolicy -> [Kind] -> FilePath -> Integer -> Either Skip Kind+classify policy kinds rel size+    | any hidden (splitDirectories rel) = Left Hidden+    | otherwise = do+        (kind, cap) <- case map toLower (takeExtension rel) of+            ext+                | ext `elem` policy.textExtensions -> Right (TextKind, policy.maxTextBytes)+                | ext `elem` policy.imageExtensions -> Right (ImageKind, policy.maxImageBytes)+                | otherwise -> Left UnknownExtension+        unless (kind `elem` kinds) (Left (NotEmbedded kind))+        unless (size > 0) (Left Empty)+        unless (size <= cap) (Left (TooLarge size))+        pure kind+  where+    hidden ('.' : _) = True+    hidden _ = False++{- | Every file under the directory, relative to it and sorted, either with+its kind or with why it is skipped. Symbolic links are skipped, not+followed; a hidden directory is not entered.+-}+walk :: WalkPolicy -> [Kind] -> FilePath -> IO ([(FilePath, Kind)], [(FilePath, Skip)])+walk policy kinds root = do+    verdicts <- go ""+    pure ([(p, k) | (p, Right k) <- verdicts], [(p, s) | (p, Left s) <- verdicts])+  where+    go :: FilePath -> IO [(FilePath, Either Skip Kind)]+    go rel = do+        names <- sort <$> listDirectory (root </> rel)+        concat <$> forM names (\name -> visit (if null rel then name else rel </> name))+    visit rel = do+        let path = root </> rel+        link <- pathIsSymbolicLink path+        isDir <- doesDirectoryExist path+        isFile <- doesFileExist path+        case () of+            _+                | link -> pure [(rel, Left SymbolicLink)]+                | isDir -> case classify policy kinds rel 1 of+                    Left Hidden -> pure [(rel, Left Hidden)]+                    _ -> go rel+                | isFile -> do+                    size <- getFileSize path+                    pure [(rel, classify policy kinds rel size)]+                | otherwise -> pure []++-------------------------------------------------------------------------------+-- The nodes++data Demo = Demo+    { pvdDirectory :: FilePath+    , pvdMajor :: Int+    -- ^ the cluster's major version, for the pgvector package+    , pvdPort :: Port+    , pvdDatabase :: DatabaseName+    , pvdTable :: Text+    , pvdPolicy :: WalkPolicy+    }+    deriving (Eq, Show)++-- | Table @items@ and 'defaultWalkPolicy'.+defaultDemo :: FilePath -> Int -> Port -> DatabaseName -> Demo+defaultDemo dir major port db = Demo dir major port db "items" defaultWalkPolicy++{- | The whole demo: pgvector, the schema, the embedder, the index. @psql@,+the package source and the database are the caller's, as for+'Salmon.Builtin.Nodes.PgVector.pgvector'.+-}+pgVectorDemo ::+    Reporter Report ->+    Reporter Postgres.Report ->+    Reporter Package.Report ->+    Track' (Binary "psql") ->+    Track' () ->+    Track' DatabaseName ->+    Demo ->+    Embedder ->+    Op+pgVectorDemo r pgr pkgr psql source mkdb demo emb =+    indexDirectory r demo emb `inject` tables `inject` emb.embNode+  where+    tables = schema demo emb.embDimension `inject` pgvector pgr pkgr psql source mkdb (PgVector demo.pvdMajor demo.pvdPort [demo.pvdDatabase] False)++{- | The table and its index, for vectors of this width. @up@ refuses (and+changes nothing) when the table is there with another width: re-embedding+everything is the operator's decision, made by naming another table. @down@+leaves the table.++Above 2000 dimensions pgvector cannot build an @hnsw@ index on a @vector@,+so there is none and a search is a sequential scan.+-}+schema :: Demo -> Int -> Op+schema demo dim =+    op "pgvector-demo-schema" nodeps $ \actions ->+        actions+            { help = "table " <> demo.pvdTable <> " of vector(" <> tshow dim <> ") in " <> demo.pvdDatabase+            , notes =+                ["down leaves the table"]+                    <> ["no hnsw index above 2000 dimensions: searches scan" | dim > 2000]+            , ref = mkRef "pgvector-demo-schema" (demo.pvdPort, demo.pvdDatabase, demo.pvdTable)+            , check = either (const Unknown) (interpretSchemaRow demo.pvdTable dim) <$> psqlIn demo (inspectSchemaSql demo.pvdTable)+            , up = psqlIn demo (schemaSql demo.pvdTable dim) >>= either (throwIO . userError . Text.unpack) (const (pure ()))+            }++indexName :: Text -> Text+indexName table = table <> "_embedding_hnsw"++-- | Refuse another width, then create the table and (up to 2000 dimensions) the index.+schemaSql :: Text -> Int -> Text+schemaSql table dim =+    Text.unlines $+        [ "DO "+            <> dollarQuote+                ( "DECLARE found text; BEGIN SELECT format_type(atttypid, atttypmod) INTO found FROM pg_attribute WHERE attrelid = to_regclass("+                    <> quoteLiteral (quoteIdent table)+                    <> ") AND attname = 'embedding' AND NOT attisdropped; IF found IS NOT NULL AND found <> "+                    <> quoteLiteral column+                    <> " THEN RAISE EXCEPTION '%', "+                    <> quoteLiteral (table <> ".embedding is ")+                    <> " || found || "+                    <> quoteLiteral (", the embedder produces " <> column <> ": name another table")+                    <> "; END IF; END"+                )+            <> ";"+        , "CREATE TABLE IF NOT EXISTS "+            <> quoteIdent table+            <> " (path text PRIMARY KEY, kind text NOT NULL, content_hash text NOT NULL, embedder text NOT NULL, embedding "+            <> column+            <> " NOT NULL);"+        ]+            <> [ "CREATE INDEX IF NOT EXISTS " <> quoteIdent (indexName table) <> " ON " <> quoteIdent table <> " USING hnsw (embedding vector_cosine_ops);"+               | dim <= 2000+               ]+  where+    column = "vector(" <> tshow dim <> ")"++-- | Empty when there is no table; otherwise @vector(N)|t@ (or @|f@ without the index).+inspectSchemaSql :: Text -> Text+inspectSchemaSql table =+    "SELECT format_type(atttypid, atttypmod) || '|' || (to_regclass("+        <> quoteLiteral (quoteIdent (indexName table))+        <> ") IS NOT NULL)::text FROM pg_attribute WHERE attrelid = to_regclass("+        <> quoteLiteral (quoteIdent table)+        <> ") AND attname = 'embedding' AND NOT attisdropped;"++interpretSchemaRow :: Text -> Int -> Text -> CheckResult+interpretSchemaRow table dim out =+    case Text.splitOn "|" (Text.strip out) of+        [""] -> Failure ("no table " <> table)+        [ty, hasIndex]+            | ty /= column -> Failure (table <> ".embedding is " <> ty <> ", the embedder produces " <> column)+            | dim <= 2000 && hasIndex /= "true" -> Failure ("no hnsw index on " <> table)+            | otherwise -> Success+        _ -> Unknown+  where+    column = "vector(" <> tshow dim <> ")"++-------------------------------------------------------------------------------++-- | A file to index: its path relative to the directory, its kind and the sha256 of its bytes.+data Candidate = Candidate+    { candPath :: FilePath+    , candKind :: Kind+    , candHash :: Text+    }+    deriving (Eq, Show)++{- | Embed every file whose row is missing or stale, and upsert it. A file+that cannot be embedded is reported and the others are still indexed; @up@+then throws, naming how many failed. @down@ leaves the rows.+-}+indexDirectory :: Reporter Report -> Demo -> Embedder -> Op+indexDirectory r demo emb =+    op "pgvector-demo-index" nodeps $ \actions ->+        actions+            { help = "indexes " <> Text.pack demo.pvdDirectory <> " into " <> demo.pvdDatabase <> "." <> demo.pvdTable <> " with " <> emb.embName+            , notes = ["rows of deleted files are left and counted", "down leaves the rows"]+            , ref = mkRef "pgvector-demo-index" (demo.pvdPort, demo.pvdDatabase, demo.pvdTable, demo.pvdDirectory)+            , check = do+                (cands, _) <- candidates+                rows <- psqlIn demo (indexedSql demo.pvdTable)+                pure $ case rows of+                    Left _ -> Unknown+                    Right out -> case pendingFiles emb.embName cands (parseIndexed out) of+                        [] -> Success+                        ps -> Failure (tshow (length ps) <> " file(s) without a current row")+            , up = do+                (cands, skipped) <- candidates+                forM_ skipped $ \(p, s) -> runReporter r (FileSkipped p s)+                rows <- psqlIn demo (indexedSql demo.pvdTable) >>= either (throwIO . userError . Text.unpack) pure+                let indexed = parseIndexed rows+                    stale = staleCount cands indexed+                unless (stale == 0) (runReporter r (StaleRows stale))+                failures <- fmap concat . forM (pendingFiles emb.embName cands indexed) $ \c -> do+                    let path = demo.pvdDirectory </> c.candPath+                    input <- case c.candKind of+                        TextKind -> InputText . Text.decodeUtf8With TextError.lenientDecode <$> ByteString.readFile path+                        ImageKind -> pure (InputImage path)+                    vector <- emb.embEmbed input+                    case vector of+                        Left why -> runReporter r (EmbedFailed c.candPath why) >> pure [c.candPath]+                        Right v+                            | length v /= emb.embDimension -> do+                                runReporter r (EmbedFailed c.candPath ("got " <> tshow (length v) <> " dimensions"))+                                pure [c.candPath]+                            | otherwise -> do+                                psqlIn demo (upsertSql demo.pvdTable emb.embName c v) >>= either (throwIO . userError . Text.unpack) pure+                                runReporter r (Indexed c.candPath c.candKind)+                                pure []+                case failures of+                    [] -> pure ()+                    (first : _) -> throwIO (userError (show (length failures) <> " file(s) could not be embedded, first: " <> first))+            }+  where+    candidates = do+        (files, skipped) <- walk demo.pvdPolicy emb.embKinds demo.pvdDirectory+        cands <- forM files $ \(p, k) -> Candidate p k . contentHash <$> ByteString.readFile (demo.pvdDirectory </> p)+        pure (cands, skipped)++-- | Lower-case hex sha256.+contentHash :: ByteString.ByteString -> Text+contentHash = Text.pack . concatMap (printf "%02x") . ByteString.unpack . SHA256.hash++indexedSql :: Text -> Text+indexedSql table = "SELECT path, content_hash, embedder FROM " <> quoteIdent table <> ";"++-- | Path to (hash, embedder). The two last fields hold no @|@; a path may.+parseIndexed :: Text -> Map.Map FilePath (Text, Text)+parseIndexed = Map.fromList . mapMaybe row . Text.lines+  where+    row line = case reverse (Text.splitOn "|" line) of+        (e : h : rest@(_ : _)) -> Just (Text.unpack (Text.intercalate "|" (reverse rest)), (h, e))+        _ -> Nothing++-- | Files with no row, a row of other content, or a row from another embedder.+pendingFiles :: Text -> [Candidate] -> Map.Map FilePath (Text, Text) -> [Candidate]+pendingFiles name cands indexed =+    [c | c <- cands, Map.lookup c.candPath indexed /= Just (c.candHash, name)]++-- | Rows whose path is no longer a file to index.+staleCount :: [Candidate] -> Map.Map FilePath (Text, Text) -> Int+staleCount cands indexed = Map.size (Map.withoutKeys indexed (Set.fromList (candPath <$> cands)))++upsertSql :: Text -> Text -> Candidate -> [Double] -> Text+upsertSql table name c v =+    "INSERT INTO "+        <> quoteIdent table+        <> " (path, kind, content_hash, embedder, embedding) VALUES ("+        <> Text.intercalate ", " [quoteLiteral (Text.pack c.candPath), quoteLiteral (kindName c.candKind), quoteLiteral c.candHash, quoteLiteral name, quoteLiteral (vectorLiteral v)]+        <> ") ON CONFLICT (path) DO UPDATE SET kind = EXCLUDED.kind, content_hash = EXCLUDED.content_hash, embedder = EXCLUDED.embedder, embedding = EXCLUDED.embedding;"++-- | @[0.25,-1.0e-2]@, as pgvector reads one.+vectorLiteral :: [Double] -> Text+vectorLiteral v = "[" <> Text.intercalate "," (tshow <$> v) <> "]"++-------------------------------------------------------------------------------+-- Search++data Hit = Hit+    { hitPath :: FilePath+    , hitKind :: Text+    , hitDistance :: Double+    }+    deriving (Eq, Show)++{- | The @k@ rows nearest to the query, by cosine distance, among the rows the+same embedder made. Reads only.+-}+search :: Demo -> Embedder -> Input -> Int -> IO (Either Text [Hit])+search demo emb input k = do+    vector <- emb.embEmbed input+    case vector of+        Left why -> pure (Left why)+        Right v -> fmap parseHits <$> psqlIn demo (searchSql demo.pvdTable emb.embName v k)++searchSql :: Text -> Text -> [Double] -> Int -> Text+searchSql table name v k =+    "SELECT path, kind, embedding <=> "+        <> q+        <> " FROM "+        <> quoteIdent table+        <> " WHERE embedder = "+        <> quoteLiteral name+        <> " ORDER BY embedding <=> "+        <> q+        <> " LIMIT "+        <> tshow (max 1 k)+        <> ";"+  where+    q = quoteLiteral (vectorLiteral v) <> "::vector"++parseHits :: Text -> [Hit]+parseHits = mapMaybe row . Text.lines+  where+    row line = case reverse (Text.splitOn "|" line) of+        (d : kind : rest@(_ : _))+            | [(dist, "")] <- reads (Text.unpack d) -> Just (Hit (Text.unpack (Text.intercalate "|" (reverse rest))) kind dist)+        _ -> Nothing++-------------------------------------------------------------------------------++{- | SQL on @psql@'s stdin, as the @postgres@ OS user, unaligned and+@|@-separated, stopping at the first error. On stdin rather than @-c@ since+a statement holds a vector of hundreds of numbers.+-}+psqlIn :: Demo -> Text -> IO (Either Text Text)+psqlIn demo sql = do+    (code, out, err) <-+        readCreateProcessWithExitCode+            (proc "sudo" ["-u", "postgres", "psql", "-p", show demo.pvdPort, "-X", "-q", "-tA", "-F", "|", "-v", "ON_ERROR_STOP=1", "-d", Text.unpack demo.pvdDatabase])+            (Text.encodeUtf8 sql)+    pure $ case code of+        ExitSuccess -> Right (decode out)+        ExitFailure n -> Left ("psql exited " <> tshow n <> ": " <> Text.take 500 (Text.strip (decode err)))+  where+    decode = Text.decodeUtf8With TextError.lenientDecode++tshow :: (Show a) => a -> Text+tshow = Text.pack . show
src/SreBox/PostgresInit.hs view
@@ -57,25 +57,38 @@   where     (ownerUser, ownerPass) = setup.init_setup_owner --- | generate a db with a given sets of users, all having some rights+{- | generate a db with a given sets of users, all having some rights++The @pg-*@ nodes grouping users, groups, grants and memberships have no+effect of their own, and 'inject' onto one of them adds a predecessor to it,+not to the nodes it groups: those would be siblings of what was injected,+unordered (under @run serve@, concurrent). So each ordering is said on the+nodes doing the work: a role after the groups and the database, the+ownership and every grant after the database. (A membership already stands+on its group and its role.) The injections onto the grouping nodes are+kept: they order nothing more, and leave those nodes' edges as they were.+-} setupPG :: Reporter Report -> InitSetup (FS.File "passfile") -> Op setupPG r setup =     op "pg-setup" (deps [memberships, groupAcls, userAcls `inject` basics]) id   where     basics = op "pg-basics" (deps [users `inject` groups `inject` db, dbOwner `inject` db]) id +    afterDb :: Op -> Op+    afterDb o = o `inject` db+     cluster = Track $ Postgres.pgLocalCluster (contramap InitPostgres r) Debian.postgres Debian.pg_ctlcluster      port = Postgres.localServer.serverPort      db = Postgres.database (contramap InitPostgres r) cluster Debian.psql port setup.init_setup_database -    groups = op "pg-groups" (deps $ fmap group setup.init_setup_groups) id+    groups = op "pg-groups" (deps $ fmap (afterDb . group) setup.init_setup_groups) id      group (g, _) =         Postgres.group (contramap InitPostgres r) cluster Debian.psql port g -    users = op "pg-users" (deps $ fmap user setup.init_setup_users) id+    users = op "pg-users" (deps $ fmap (\u -> afterDb (user u) `inject` groups) setup.init_setup_users) id      -- roundabout ways to generate users and owner user     baseUser u pass =@@ -91,7 +104,7 @@     trackgroupsFromAclsByCreatingAllGroups = Track $ const groups     trackOwnerRoleForUser = Track $ const owner -    dbOwner = op "pg-db-ownership" (deps [ownership]) id+    dbOwner = op "pg-db-ownership" (deps [afterDb ownership]) id       where         ownership =             Postgres.databaseOnwership@@ -104,7 +117,7 @@                 trackOwnerRoleForUser                 (Postgres.UserRole $ fst setup.init_setup_owner) -    userAcls = op "pg-user-grants" (deps $ fmap acl setup.init_setup_users) id+    userAcls = op "pg-user-grants" (deps $ fmap (afterDb . acl) setup.init_setup_users) id       where         acl (u, _, rights, _) =             Postgres.grant@@ -114,7 +127,7 @@                 trackuserFromAclsByCreatingAllUsers                 (Postgres.AccessRight setup.init_setup_database (Postgres.UserRole u) rights) -    groupAcls = op "pg-group-grants" (deps $ fmap acl setup.init_setup_groups) id+    groupAcls = op "pg-group-grants" (deps $ fmap (afterDb . acl) setup.init_setup_groups) id       where         acl (g, rights) =             Postgres.grant
src/SreBox/PostgresMigrations.hs view
@@ -179,8 +179,29 @@     (Either PrepareRemoteMigrationSetup MigrationSetup -> directive) ->     RemoteMigrateConfig TrackedIO ->     Op-remoteMigrateOpaqueSetup uniquename r simulate selfRemote selfpath toSpec cfg =-    op "migrate-remotely" (deps [opaquemigration `inject` uploadsecrets]) $ \actions ->+remoteMigrateOpaqueSetup = remoteMigrateOpaqueSetupAfter []++{- | 'remoteMigrateOpaqueSetup', with nodes the migration itself waits for+(the database and roles it migrates, typically).++The node returned (@migrate-remotely@) has no effect of its own: a node+@inject@ed onto it is a sibling of the migration, not something the+migration waits for. These go under the node that migrates. With @[]@ the+graph is 'remoteMigrateOpaqueSetup''s, unchanged.+-}+remoteMigrateOpaqueSetupAfter ::+    (FromJSON directive, ToJSON directive) =>+    [Op] ->+    Text ->+    Reporter Report ->+    Track' directive ->+    Self.Remote ->+    Self.SelfPath ->+    (Either PrepareRemoteMigrationSetup MigrationSetup -> directive) ->+    RemoteMigrateConfig TrackedIO ->+    Op+remoteMigrateOpaqueSetupAfter prerequisites uniquename r simulate selfRemote selfpath toSpec cfg =+    op "migrate-remotely" (deps [Prelude.foldl inject opaquemigration prerequisites `inject` uploadsecrets]) $ \actions ->         actions             { help = Text.unwords ["remotely apply the (opaque) migration", uniquename]             , ref = mkRef "remote-migrate" uniquename
src/SreBox/PostgresPair.hs view
@@ -1,6 +1,7 @@ {-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE OverloadedRecordDot #-} {-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE ScopedTypeVariables #-}  {- | A primary and a streaming standby on two machines, with the primary's location declared rather than discovered.@@ -47,9 +48,27 @@     Member (..),     Pair (..),     Bouncer (..),+    RoutedDatabase (..),+    routedDatabases,+    bouncerProblems,     memberOn,+    memberSshHost,+    sshLogin,+    sshUser,+    remoteCommand,     slotNameFor, +    -- * How the two machines talk to each other+    ConnSecurity (..),+    Tls (..),+    ServerCheck (..),+    ServerFiles (..),+    ClientCerts (..),+    connSecurity,+    hbaLinesFor,+    hbaLinesRetired,+    securityProblems,+     -- * What the machines are     Lsn (..),     parseLsn,@@ -60,6 +79,7 @@     BouncerState (..),     bouncerProbeScript,     parseBouncerState,+    parseBouncerStates,      -- * What to do about it     Step (..),@@ -83,11 +103,15 @@     converge,     convergeUpTo,     stepBudget,++    -- * For recipes beside this one ("SreBox.PostgresPairPrereqs")+    sshToTarget, ) where  import Control.Concurrent (threadDelay) import Control.Exception (throwIO) import Data.Aeson (FromJSON, ToJSON)+import Data.List (nub, (\\)) import Data.Maybe (fromMaybe) import Data.Text (Text) import qualified Data.Text as Text@@ -122,13 +146,20 @@  {- | One machine of the pair. -@member_ssh_user@ and @member_host@ are how the /controller/ reaches it;-@member_host@ is also how the peer and the bouncers do, so it has to be an-address both can use.+@member_host@ is how the /peer and the bouncers/ reach it: it is what goes+into @pg_hba.conf@, @primary_conninfo@ and the routing file, and what a+standby reports as its upstream. @member_ssh_user@ and 'memberSshHost' are+how the /controller/ reaches it, and nothing else reads them: on a cloud+network the operator sees a machine on its external address and the peer+sees it on its internal one, and with one field for both the controller has+to live inside that network. -} data Member     = Member     { member_ssh_user :: Text+    -- ^ who the controller logs in as. Anything but @root@ must have+    -- passwordless sudo: every script is then run under one @sudo -n@+    -- (see 'remoteCommand').     , member_host :: Postgres.Host     , member_cluster :: Postgres.ClusterName     , member_port :: Postgres.Port@@ -136,12 +167,21 @@     -- ^ a key to authenticate with, for a machine that does not answer to     -- whatever the controller offers by default. Per member rather than per     -- pair: two machines need not have been given the same key.+    , member_ssh_host :: Maybe Text+    -- ^ where the controller's ssh goes, when that is not 'member_host'.+    -- Absent (also from a directive written before this field existed) it is+    -- 'member_host'. It appears in no script, no ref and no report: it is+    -- the controller's route to the machine, not the machine's name.     }     deriving (Eq, Show, Generic)  instance FromJSON Member instance ToJSON Member +-- | The address the controller's ssh connects to for this member.+memberSshHost :: Member -> Text+memberSshHost m = fromMaybe m.member_host m.member_ssh_host+ {- | One pgbouncer in front of the pair, and everything needed to move traffic through it without dropping any. @@ -157,6 +197,7 @@     { bouncer_name :: Text     -- ^ what to call it in reports; it identifies nothing else.     , bouncer_ssh_user :: Text+    -- ^ as 'member_ssh_user': @root@, or a login with passwordless sudo.     , bouncer_ssh_host :: Text     -- ^ how the /controller/ reaches it, which need not be how clients do.     , bouncer_ssh_identity :: Maybe FilePath@@ -178,12 +219,69 @@     -- leaves authentication to whoever put that file there.     , bouncer_listen_port :: Postgres.Port     -- ^ what clients connect to, as opposed to 'bouncer_console_port'.+    , bouncer_more_databases :: Maybe [RoutedDatabase]+    -- ^ the pair's other databases this bouncer routes, beside+    -- 'bouncer_alias'. Absent (also from a directive written before this+    -- field existed) there are none, and the bouncer routes the one database+    -- it always did. Read them all through 'routedDatabases'.     }     deriving (Eq, Show, Generic)  instance FromJSON Bouncer instance ToJSON Bouncer +{- | One database of the pair as a bouncer's clients see it: the name they+connect to, and the database it resolves to on whichever member is the+primary.+-}+data RoutedDatabase+    = RoutedDatabase+    { routed_alias :: Text+    , routed_dbname :: Text+    }+    deriving (Eq, Show, Generic)++instance FromJSON RoutedDatabase+instance ToJSON RoutedDatabase++{- | Every database a bouncer routes, 'bouncer_alias' first.++They live in /one/ routing file and move in /one/ pause, rewrite, reload and+resume, which is the point of declaring them on one bouncer rather than on+several: two @Bouncer@s on one machine would each write the same+@pgbouncer.ini@ and own the same service.+-}+routedDatabases :: Bouncer -> [RoutedDatabase]+routedDatabases b =+    RoutedDatabase b.bouncer_alias b.bouncer_dbname : fromMaybe [] b.bouncer_more_databases++{- | Everything wrong with the declared bouncers, not the first thing.++One alias twice in a routing file is one key twice in an ini section: the+bouncer keeps one of them and the probe reads whichever row it kept, so the+declaration could never be observed to hold. And a name that is not a plain+word would be read as something else by the routing file, by the console's+@PAUSE@, or by the shell carrying both -- checked only for the databases+beyond the first, so that a directive written before there could be several+is refused nothing it was not refused before.+-}+bouncerProblems :: Pair -> [Text]+bouncerProblems pair =+    concat+        [ [ "bouncer " <> b.bouncer_name <> ": the database " <> alias <> " is routed more than once"+          | alias <- nub (aliases \\ nub aliases)+          ]+            <> [ "bouncer " <> b.bouncer_name <> ": \"" <> name <> "\" is not a database name made of plain characters"+               | d <- fromMaybe [] b.bouncer_more_databases+               , name <- [d.routed_alias, d.routed_dbname]+               , Text.null name || Text.any (not . plain) name+               ]+        | b <- pair.pair_bouncers+        , let aliases = map routed_alias (routedDatabases b)+        ]+  where+    plain c = c `elem` ("_-." :: String) || c `elem` ['a' .. 'z'] || c `elem` ['A' .. 'Z'] || c `elem` ['0' .. '9']+ data Pair     = Pair     { pair_name :: Text@@ -251,12 +349,239 @@     -- a pair that is healthy, or merely lagging, never has anything wiped.     -- 'pair_seed' cannot do this job -- it is the /first/ clone, and its     -- guard leaves a data directory of the primary's own cluster alone.+    , pair_conn_security :: Maybe ConnSecurity+    -- ^ what the replication and rewind connections between the two members+    -- must be: see 'ConnSecurity'. Absent (also from a directive written+    -- before this field existed) it is 'PlainMd5', which is what every pair+    -- was before there was a choice; read it through 'connSecurity'.     }     deriving (Eq, Show, Generic)  instance FromJSON Pair instance ToJSON Pair +{- | What the connections one member makes to the other -- streaming+replication, @pg_basebackup@, @pg_rewind@, the slot bookkeeping -- must be.++Three choices, weakest first, and the weakest is the one with the plain+name: it is what an absent 'pair_conn_security' means, because that is what+every pair deployed before this type existed is, and a recipe that tightened+@pg_hba.conf@ under a running pair on upgrade would be a recipe that broke+replication on upgrade.++['PlainMd5'] @host ... md5@. TLS is not required by either end (libpq's+default @sslmode=prefer@ will use it if the server offers it, and will not+mind if somebody in the middle says it does not), and @md5@ accepts a+password stored either way. For a network nobody else is on.++['TlsScram'] @hostssl ... scram-sha-256@. The server refuses a connection+that is not TLS, the client refuses one too (@sslmode=require@ or stricter,+see 'ServerCheck'), and the password is only ever proven, never sent. The+roles' passwords are re-stored as SCRAM verifiers on the way.++['TlsClientCert'] @hostssl ... cert@. As above for the transport, and the+/client/ is authenticated by a certificate whose @CN@ is the role's name --+what "SreBox.PostgresTls" sets up for application clients, here for the two+roles the pair itself connects as. The passfiles are still read (the roles+are still given their passwords), but no line this recipe writes accepts one.++Every file named in here is __pre-provisioned on both members, at the same+path__: this recipe does not issue certificates and does not move them, for+the same reason it does not move passwords.+-}+data ConnSecurity+    = PlainMd5+    | TlsScram Tls+    | TlsClientCert Tls ClientCerts+    deriving (Eq, Show, Generic)++instance FromJSON ConnSecurity+instance ToJSON ConnSecurity++-- | The transport half of a TLS choice: both ends of it.+data Tls+    = Tls+    { tls_check :: ServerCheck+    -- ^ how hard a connecting member checks the one it connects to.+    , tls_server :: Maybe ServerFiles+    -- ^ what each member serves TLS with. 'Nothing' leaves the cluster's+    -- TLS settings as they were found -- Debian's clusters are created with+    -- @ssl = on@ and a self-signed certificate, which is enough for+    -- 'Encrypted' and for nothing stricter. Either way the member node+    -- refuses to write a @hostssl@ line on a cluster that is not serving+    -- TLS, since such a line matches nothing.+    }+    deriving (Eq, Show, Generic)++instance FromJSON Tls+instance ToJSON Tls++{- | What the connecting side demands of the server's certificate.++'Encrypted' is @sslmode=require@: the connection is encrypted and that is+all -- whoever answers at the peer's address is believed, so it keeps out an+eavesdropper and not somebody who can redirect the connection. The other two+name a CA certificate (on each member) and are @verify-ca@ and+@verify-full@; the last also wants the certificate to name 'member_host',+which for members declared by address means a certificate with that address+among its subject alternative names.+-}+data ServerCheck+    = Encrypted+    | VerifyCa FilePath+    | VerifyFull FilePath+    deriving (Eq, Show, Generic)++instance FromJSON ServerCheck+instance ToJSON ServerCheck++{- | A member's own certificate and key, and the CA it accepts client+certificates from. The paths are the same on both members; the files are+not (each member's certificate is its own). The key must be readable by+@postgres@ and by nobody else, or the cluster does not load it.+-}+data ServerFiles+    = ServerFiles+    { server_cert :: FilePath+    , server_key :: FilePath+    , server_client_ca :: Maybe FilePath+    -- ^ @ssl_ca_file@. Needed by 'TlsClientCert' (set here, or already set+    -- on the cluster); left as found when absent.+    }+    deriving (Eq, Show, Generic)++instance FromJSON ServerFiles+instance ToJSON ServerFiles++{- | The certificates the pair's two roles present, on each member (either+may be the one connecting). Each certificate's @CN@ must be the role's name:+that equality is the whole authentication. Keys are read by @postgres@ (the+standby's own connection, @pg_rewind@) and by root (the first clone), and+libpq refuses one that group or others can read.+-}+data ClientCerts+    = ClientCerts+    { client_repl_cert :: FilePath+    , client_repl_key :: FilePath+    , client_rewind_cert :: FilePath+    , client_rewind_key :: FilePath+    }+    deriving (Eq, Show, Generic)++instance FromJSON ClientCerts+instance ToJSON ClientCerts++-- | The pair's choice, with the absent one spelled out.+connSecurity :: Pair -> ConnSecurity+connSecurity = fromMaybe PlainMd5 . pair_conn_security++{- | The two @pg_hba.conf@ lines a member needs for a choice: the peer's+replication connection, and the peer's rewind connection.+-}+hbaLinesFor :: Pair -> Side -> ConnSecurity -> [Text]+hbaLinesFor pair side sec = hbaShape pair side (shapeOf sec)++{- | The lines the /other/ choices would have written on this member, which+a member removes before it adds its own.++@pg_hba.conf@ is read top to bottom and the first line that matches decides,+and @host@ matches a TLS connection as well as a plain one: left in place,+the @host ... md5@ line of a pair that was 'PlainMd5' yesterday sits above+the @hostssl@ line appended today and goes on accepting everything it ever+did. So this one recipe, whose lines are otherwise append-if-missing,+removes -- exactly these lines, compared whole, and nothing else in the file.+-}+hbaLinesRetired :: Pair -> Side -> ConnSecurity -> [Text]+hbaLinesRetired pair side sec =+    concat [hbaShape pair side shape | shape <- hbaShapes, shape /= shapeOf sec]++hbaShapes :: [(Text, Text)]+hbaShapes = [("host", "md5"), ("hostssl", "scram-sha-256"), ("hostssl", "cert")]++shapeOf :: ConnSecurity -> (Text, Text)+shapeOf PlainMd5 = ("host", "md5")+shapeOf (TlsScram _) = ("hostssl", "scram-sha-256")+shapeOf (TlsClientCert _ _) = ("hostssl", "cert")++hbaShape :: Pair -> Side -> (Text, Text) -> [Text]+hbaShape pair side (kind, method) =+    [ Text.unwords [kind, "replication", pair.pair_repl_role, peer.member_host <> "/32", method]+    , Text.unwords [kind, "all", pair.pair_rewind_role, peer.member_host <> "/32", method]+    ]+  where+    peer = memberOn pair (other side)++{- | Everything wrong with the declared choice, not the first thing.++The paths end up inside a libpq connection string, inside a line of+@postgresql.auto.conf@, inside a shell script: a path that is not a plain+word would be read as something else by one of the three.+-}+securityProblems :: Pair -> [Text]+securityProblems pair = case connSecurity pair of+    PlainMd5 -> []+    TlsScram tls -> tlsProblems tls+    TlsClientCert tls cc ->+        tlsProblems tls+            <> path "replication client certificate" cc.client_repl_cert+            <> path "replication client key" cc.client_repl_key+            <> path "rewind client certificate" cc.client_rewind_cert+            <> path "rewind client key" cc.client_rewind_key+  where+    tlsProblems :: Tls -> [Text]+    tlsProblems tls =+        ( case tls.tls_check of+            Encrypted -> []+            VerifyCa ca -> path "CA certificate" ca+            VerifyFull ca -> path "CA certificate" ca+        )+            <> foldMap+                ( \(sf :: ServerFiles) ->+                    path "server certificate" sf.server_cert+                        <> path "server key" sf.server_key+                        <> foldMap (path "client CA certificate") sf.server_client_ca+                )+                tls.tls_server+    path :: Text -> FilePath -> [Text]+    path what p =+        [ what <> ": \"" <> Text.pack p <> "\" is not an absolute path made of plain characters"+        | not (take 1 p == "/" && all plain p)+        ]+    plain c = c `notElem` (" \t\n\r'\"\\$`" :: String)++data ConnAs = AsReplication | AsRewind++{- | What a connection string gains from the choice. Nothing at all for+'PlainMd5', so that pair's scripts are what they always were.+-}+sslParams :: Pair -> ConnAs -> [String]+sslParams pair who = case connSecurity pair of+    PlainMd5 -> []+    TlsScram tls -> check tls.tls_check+    TlsClientCert tls cc -> check tls.tls_check <> certs cc+  where+    check Encrypted = ["sslmode=require"]+    check (VerifyCa ca) = ["sslmode=verify-ca", "sslrootcert=" <> ca]+    check (VerifyFull ca) = ["sslmode=verify-full", "sslrootcert=" <> ca]+    certs :: ClientCerts -> [String]+    certs cc = case who of+        AsReplication -> ["sslcert=" <> cc.client_repl_cert, "sslkey=" <> cc.client_repl_key]+        AsRewind -> ["sslcert=" <> cc.client_rewind_cert, "sslkey=" <> cc.client_rewind_key]++{- | The same, as environment, for the one command here that builds its own+connection: 'Postgres.cloneFromPrimaryScript', whose @pg_basebackup@ and+identity query take a host and a user rather than a string. libpq reads+these wherever a connection string says nothing, and every other connection+in these scripts says it in the string.+-}+sslEnvironment :: Pair -> [String]+sslEnvironment pair =+    [ "export " <> var <> "=" <> shQuote (drop 1 value)+    | param <- sslParams pair AsReplication+    , let (key, value) = break (== '=') param+    , Just var <- [lookup key [("sslmode", "PGSSLMODE"), ("sslrootcert", "PGSSLROOTCERT"), ("sslcert", "PGSSLCERT"), ("sslkey", "PGSSLKEY")]]+    ]+ memberOn :: Pair -> Side -> Member memberOn pair A = pair.pair_a memberOn pair B = pair.pair_b@@ -509,9 +834,26 @@ bouncerProbeScript :: Bouncer -> String bouncerProbeScript b = consoleCommand b "-AX" "SHOW DATABASES" --- | Reads 'bouncerProbeScript''s output, for the one database this pair owns.+{- | Reads 'bouncerProbeScript''s output: one state per database the bouncer+routes, in 'routedDatabases'' order.++One per /database/ rather than one per bouncer, because 'nextStep' reads+these as a set and asks the same two things of each -- is it held, is it+pointed at the primary -- and a bouncer summed up as one state has to lie+about one of them whenever its databases disagree: a bouncer with one alias+moved and one left behind is neither at the primary nor at the peer, and one+with a single alias still paused is an outage for that alias's clients.+Every alias has to agree before a pair has arrived.+-}+parseBouncerStates :: Bouncer -> Text -> [BouncerState]+parseBouncerStates b out = [parseDatabaseState d.routed_alias out | d <- routedDatabases b]++-- | 'parseBouncerStates' for 'bouncer_alias' alone. parseBouncerState :: Bouncer -> Text -> BouncerState-parseBouncerState b out =+parseBouncerState b = parseDatabaseState b.bouncer_alias++parseDatabaseState :: Text -> Text -> BouncerState+parseDatabaseState alias out =     case (header, row) of         (Just hs, Just r) ->             BouncerState@@ -526,7 +868,7 @@     row = do         hs <- header         i <- lookupIndex hs "name"-        case [r | r <- rows, atIndex r i == Just b.bouncer_alias] of+        case [r | r <- rows, atIndex r i == Just alias] of             (r : _) -> Just r             _ -> Nothing @@ -867,13 +1209,19 @@     one -- and because "did the clients actually stop" is the question this     step exists to answer, not "did a command exit zero". -}     pauseScript b =+        unlines ["set -e"]+            <> concatMap (pauseOne b . Text.unpack . routed_alias) (routedDatabases b)++    {- Every database the bouncer routes, one after the other and each+    checked before the next: the step is over only once all of them are+    held, and 'nextStep' does not stop the old primary before that. -}+    pauseOne b alias =         unlines-            [ "set -e"-            , -- kept, rather than discarded: "it did not pause" is a+            [ -- kept, rather than discarded: "it did not pause" is a               -- symptom, and what the console said about it is the cause.-              "said=$(" <> consoleCommand b "-tAX" ("PAUSE " <> Text.unpack b.bouncer_alias) <> " 2>&1)" <> " || true"-            , "paused=$(" <> showDatabasesColumn b "paused" <> ")"-            , "[ \"$paused\" = 1 ] || { echo " <> shQuote ("bouncer " <> Text.unpack b.bouncer_name <> " did not pause " <> Text.unpack b.bouncer_alias <> ", and said:") <> " \"$said\" >&2; "+              "said=$(" <> consoleCommand b "-tAX" ("PAUSE " <> alias) <> " 2>&1)" <> " || true"+            , "paused=$(" <> showDatabasesColumn b alias "paused" <> ")"+            , "[ \"$paused\" = 1 ] || { echo " <> shQuote ("bouncer " <> Text.unpack b.bouncer_name <> " did not pause " <> alias <> ", and said:") <> " \"$said\" >&2; "                 <> consoleCommand b "-AX" "SHOW DATABASES" <> " >&2 2>&1 || true; exit 1; }"             ] @@ -881,29 +1229,34 @@     bouncer reads it, RESUME so the clients it has been holding go to the new     primary. The file is written here rather than reloaded from a     declaration, because between those two things there is a moment when the-    bouncer would send clients to a machine that is not the primary yet. -}+    bouncer would send clients to a machine that is not the primary yet.++    One file and one RELOAD whatever number of databases the bouncer routes,+    so they move together; then each is resumed and checked. A database that+    fails its check leaves the ones after it held, which the next pass sees+    (not every alias is ready) and repoints again. -}     repointScript side b =         unlines-            [ "set -e"-            , "cat > " <> shQuote b.bouncer_routing_path <> " <<'SALMON_ROUTING'"-            , "[databases]"-            , Text.unpack b.bouncer_alias-                <> " = host="-                <> Text.unpack (on side).member_host-                <> " port="-                <> port side-                <> " dbname="-                <> Text.unpack b.bouncer_dbname-            , "SALMON_ROUTING"-            , consoleCommand b "-tAX" "RELOAD" <> " >/dev/null"-            , "said=$(" <> consoleCommand b "-tAX" ("RESUME " <> Text.unpack b.bouncer_alias) <> " 2>&1)" <> " || true"-            , "host=$(" <> showDatabasesColumn b "host" <> ")"-            , "paused=$(" <> showDatabasesColumn b "paused" <> ")"+            ( [ "set -e"+              , "cat > " <> shQuote b.bouncer_routing_path <> " <<'SALMON_ROUTING'"+              ]+                <> routingLines (on side) b+                <> [ "SALMON_ROUTING"+                   , consoleCommand b "-tAX" "RELOAD" <> " >/dev/null"+                   ]+            )+            <> concatMap (resumeOne side b . Text.unpack . routed_alias) (routedDatabases b)++    resumeOne side b alias =+        unlines+            [ "said=$(" <> consoleCommand b "-tAX" ("RESUME " <> alias) <> " 2>&1)" <> " || true"+            , "host=$(" <> showDatabasesColumn b alias "host" <> ")"+            , "paused=$(" <> showDatabasesColumn b alias "paused" <> ")"             , "[ \"$host\" = " <> shQuote (Text.unpack (on side).member_host) <> " ] && [ \"$paused\" = 0 ]"-                <> " || { echo " <> shQuote ("bouncer " <> Text.unpack b.bouncer_name <> " is still sending clients to $host (paused=$paused), and said:") <> " \"$said\" >&2; exit 1; }"+                <> " || { echo " <> shQuote ("bouncer " <> Text.unpack b.bouncer_name <> " is still sending clients of " <> alias <> " to $host (paused=$paused), and said:") <> " \"$said\" >&2; exit 1; }"             ] -    {- One column of this pair's row of SHOW DATABASES, found by column+    {- One column of one database's row of SHOW DATABASES, found by column     /name/: the columns have changed between pgbouncer versions, and     counting them reads the wrong one. @@ -911,14 +1264,30 @@     program, because a shell-quoted string spliced into a single-quoted awk     program stops being a string: the shell eats the quotes, awk reads a bare     word, and a bare word in awk is an empty variable that equals nothing. -}-    showDatabasesColumn b col =+    showDatabasesColumn b alias col =         consoleCommand b "-AX" "SHOW DATABASES"             <> " | awk -F'|' -v col="             <> shQuote col             <> " -v want="-            <> shQuote (Text.unpack b.bouncer_alias)+            <> shQuote alias             <> " 'NR==1 { for (i=1;i<=NF;i++) { if ($i==\"name\") n=i; if ($i==col) c=i } } NR>1 && $n==want { print $c }'" +{- The routing file's contents: one line per database the bouncer routes,+all sent to the same member. Shared by the two writers of that file so that+they cannot come to disagree about its shape. -}+routingLines :: Member -> Bouncer -> [String]+routingLines m b =+    "[databases]"+        : [ Text.unpack d.routed_alias+            <> " = host="+            <> Text.unpack m.member_host+            <> " port="+            <> show m.member_port+            <> " dbname="+            <> Text.unpack d.routed_dbname+          | d <- routedDatabases b+          ]+ {- Everything that makes a member /this pair's/ standby, whatever brought it here -- a rewind, or a clone from nothing. It writes the recovery configuration rather than trusting @pg_rewind -R@ or @pg_basebackup -R@@@ -971,16 +1340,17 @@  primaryConninfo :: Pair -> Side -> String primaryConninfo pair side =-    unwords+    unwords $         [ "host=" <> Text.unpack (memberOn pair side).member_host         , "port=" <> show (memberOn pair side).member_port         , "user=" <> Text.unpack pair.pair_repl_role         , "passfile=" <> pair.pair_repl_passfile         ]+            <> sslParams pair AsReplication  replicationConn :: Pair -> Side -> String replicationConn pair side =-    unwords+    unwords $         [ "host=" <> Text.unpack (memberOn pair side).member_host         , "port=" <> show (memberOn pair side).member_port         , "user=" <> Text.unpack pair.pair_repl_role@@ -989,15 +1359,17 @@           -- and pg_hba matches that against the database name.           "replication=true"         ]+            <> sslParams pair AsReplication  sourceServer :: Pair -> Side -> String sourceServer pair side =-    unwords+    unwords $         [ "host=" <> Text.unpack (memberOn pair side).member_host         , "port=" <> show (memberOn pair side).member_port         , "user=" <> Text.unpack pair.pair_rewind_role         , "dbname=postgres"         ]+            <> sslParams pair AsRewind   shQuote :: String -> String@@ -1066,8 +1438,12 @@             , notes =                 [ "accepts replication and rewind connections from " <> peer.member_host                 , "cluster " <> m.member_cluster <> " on port " <> Text.pack (show m.member_port)+                , -- what a re-declaration with another choice changes, so+                  -- that `run serve` sees one+                  "those connections: " <> securityNote (connSecurity pair)                 ]             , up = do+                refuseBadSecurity pair                 (code, out, err) <- sshToTarget pair (OnMember m) (memberScript pair side)                 runReporter r (Acted m.member_host code (Text.strip (out <> err)))                 case code of@@ -1079,9 +1455,6 @@     m = memberOn pair side     peer = memberOn pair (other side) -{- | The script 'member' runs. Pure, like the steps, so that what it does to-a machine can be read without one.--} {- | Builds one member out of the other: @pg_basebackup@, then everything that makes it this pair's standby. @@ -1106,6 +1479,7 @@                 [ "clones only over a pristine or matching data directory, and refuses anything else"                 ]             , up = do+                refuseBadSecurity pair                 (code, out, err) <- sshToTarget pair (OnMember m) (seedScript pair side)                 runReporter r (Acted m.member_host code (Text.strip (out <> err)))                 case code of@@ -1128,13 +1502,14 @@ seedScriptWith :: [String] -> Pair -> Side -> String seedScriptWith between pair side =     unlines $-        [ Postgres.cloneFromPrimaryScript setup-        , -- the clone leaves it running and streaming with whatever+        sslEnvironment pair+            <> [ Postgres.cloneFromPrimaryScript setup+               , -- the clone leaves it running and streaming with whatever           -- pg_basebackup's -R wrote; from here it is this pair's standby,           -- with this pair's slot, which nothing else would give it.           "version=$(pg_lsclusters --no-header | awk -v c=" <> shQuote (Text.unpack m.member_cluster) <> " '$2==c {print $1}' | head -n1)"         , "datadir=/var/lib/postgresql/$version/" <> Text.unpack m.member_cluster-        ]+               ]             <> between             <> standbyTail pair side             <> [ unwords ["pg_ctlcluster", "\"$version\"", Text.unpack m.member_cluster, "restart"]@@ -1233,7 +1608,7 @@             { ref = mkRef "pg-pair-bouncer" (pair.pair_name <> "@" <> b.bouncer_ssh_host)             , help = Text.unwords ["pgbouncer", b.bouncer_name, "in front of", pair.pair_name]             , notes =-                [ "clients reach " <> b.bouncer_alias <> " on port " <> Text.pack (show b.bouncer_listen_port)+                [ "clients reach " <> Text.intercalate ", " (map routed_alias (routedDatabases b)) <> " on port " <> Text.pack (show b.bouncer_listen_port)                 , "routing is " <> Text.pack b.bouncer_routing_path <> ", which the role node owns"                 ]             , up = do@@ -1256,22 +1631,17 @@         , -- written only if absent: after that it is the role node's, and a           -- pass that rewrote it here would move clients without pausing them           "[ -e " <> shQuote b.bouncer_routing_path <> " ] || cat > " <> shQuote b.bouncer_routing_path <> " <<'SALMON_ROUTING'"-        , "[databases]"-        , Text.unpack b.bouncer_alias-            <> " = host="-            <> Text.unpack primary.member_host-            <> " port="-            <> show primary.member_port-            <> " dbname="-            <> Text.unpack b.bouncer_dbname-        , "SALMON_ROUTING"-        , "if ! cmp -s /tmp/salmon-pgbouncer.ini " <> shQuote ini <> "; then"-        , "  install -m 0644 /tmp/salmon-pgbouncer.ini " <> shQuote ini <> ""-        , "  systemctl restart pgbouncer"-        , "fi"-        , "rm -f /tmp/salmon-pgbouncer.ini"-        , "systemctl is-active --quiet pgbouncer || systemctl start pgbouncer"         ]+        <> unlines (routingLines primary b)+        <> unlines+            [ "SALMON_ROUTING"+            , "if ! cmp -s /tmp/salmon-pgbouncer.ini " <> shQuote ini <> "; then"+            , "  install -m 0644 /tmp/salmon-pgbouncer.ini " <> shQuote ini <> ""+            , "  systemctl restart pgbouncer"+            , "fi"+            , "rm -f /tmp/salmon-pgbouncer.ini"+            , "systemctl is-active --quiet pgbouncer || systemctl start pgbouncer"+            ]   where     primary = memberOn pair pair.pair_primary     ini = b.bouncer_config_dir <> "/pgbouncer.ini"@@ -1294,6 +1664,71 @@             , PgBouncer.bouncer_routing_file = Just b.bouncer_routing_path             } +{- | A declaration whose paths would not survive being spliced into a script+runs nothing at all, and says everything that is wrong with it.+-}+refuseBadSecurity :: Pair -> IO ()+refuseBadSecurity pair =+    case (securityProblems pair, bouncerProblems pair) of+        ([], []) -> pure ()+        ([], problems) ->+            throwIO (userError (Text.unpack ("pair " <> pair.pair_name <> ": pair_bouncers: " <> Text.intercalate "; " problems)))+        (problems, _) ->+            throwIO (userError (Text.unpack ("pair " <> pair.pair_name <> ": pair_conn_security: " <> Text.intercalate "; " problems)))++-- | The choice in words, for a node's @notes@: paths, never contents.+securityNote :: ConnSecurity -> Text+securityNote PlainMd5 = "host, md5 (TLS not required)"+securityNote (TlsScram tls) = "hostssl, scram-sha-256, " <> tlsNote tls+securityNote (TlsClientCert tls cc) =+    "hostssl, client certificates ("+        <> Text.pack cc.client_repl_cert+        <> ", "+        <> Text.pack cc.client_rewind_cert+        <> "), "+        <> tlsNote tls++tlsNote :: Tls -> Text+tlsNote tls =+    Text.intercalate+        ", "+        [ case tls.tls_check of+            Encrypted -> "server not verified (sslmode=require)"+            VerifyCa ca -> "server verified against " <> Text.pack ca <> " (verify-ca)"+            VerifyFull ca -> "server and its name verified against " <> Text.pack ca <> " (verify-full)"+        , case tls.tls_server of+            Nothing -> "the cluster's TLS settings left as found"+            Just sf -> "served with " <> Text.pack sf.server_cert+        ]++{- | The script 'member' runs. Pure, like the steps, so that what it does to+a machine can be read without one.++= What it does to @pg_hba.conf@++It removes the lines the pair's /other/ 'ConnSecurity' choices would have+written for this peer ('hbaLinesRetired'), and appends this choice's two if+they are missing ('hbaLinesFor'). Removing is what makes a tightened choice+mean anything -- see 'hbaLinesRetired' -- and it touches those exact lines+only. A line somebody else wrote above them that also matches the peer still+wins: this recipe owns its lines, not the file.++For the TLS choices, in this order: the cluster is made to serve TLS (if+'tls_server' says with what), it is /asked/ whether it does, and only then+is the file touched. A @hostssl@ line on a cluster with @ssl = off@ matches+nothing, so a pass that wrote one first would cut the pair's replication to+announce that TLS is not set up. Afterwards the server is asked whether it+could read the file it was given (@pg_hba_file_rules@), since a file it+cannot parse is silently not loaded.++= What it does on a standby++Aligns @primary_conninfo@ with the declared choice, if and only if the+standby is already pointed at its peer: a choice changed on a running pair+otherwise reaches the standby's own connection at its next rejoin, and with+client certificates that connection would fail at its next reconnect. It+never points a standby anywhere -- that is the role node's, through a rejoin.+-} memberScript :: Pair -> Side -> String memberScript pair side =     unlines $@@ -1302,10 +1737,9 @@         , "hba=/etc/postgresql/$version/" <> cluster <> "/pg_hba.conf"         ]             <> map setting (Postgres.replicationSettings Postgres.defaultReplicationTuning)-            <> map hbaLine-                [ "host replication " <> Text.unpack pair.pair_repl_role <> " " <> Text.unpack peer.member_host <> "/32 md5"-                , "host all " <> Text.unpack pair.pair_rewind_role <> " " <> Text.unpack peer.member_host <> "/32 md5"-                ]+            <> servesTls+            <> map (retireHbaLine . Text.unpack) (hbaLinesRetired pair side security)+            <> map (hbaLine . Text.unpack) (hbaLinesFor pair side security)             <> -- roles are catalog rows: they reach the other machine through                -- the WAL like any other write, so only a primary makes them,                -- and a standby that is one tomorrow already has them.@@ -1314,10 +1748,22 @@                , "  rewindpw=$(awk -F: 'NR==1 {print $5}' " <> shQuote pair.pair_rewind_passfile <> ")"                , "  " <> heredoc (roleSql "REPLICATION LOGIN" pair.pair_repl_role "$replpw")                , "  " <> heredoc (roleSql "LOGIN" pair.pair_rewind_role "$rewindpw" <> " " <> grants)+               , "else"+               , -- a standby already pointed at its peer connects the way the+                 -- pair now says to. Compared first: for a pair whose choice+                 -- did not change this is one query and nothing else.+                 "  conninfo=$(" <> query "SELECT setting FROM pg_settings WHERE name = 'primary_conninfo'" <> ")"+               , "  case \"$conninfo\" in"+               , "    " <> shQuote ("host=" <> Text.unpack peer.member_host <> " ") <> "*)"+               , "      [ \"$conninfo\" = " <> shQuote wantedConninfo <> " ] || "+                    <> psql' ("ALTER SYSTEM SET primary_conninfo = " <> Text.unpack (quoteSql (Text.pack wantedConninfo)))+                    <> " ;;"+               , "  esac"                , "fi"                ]-            <> [ psql' "SELECT pg_reload_conf()"-               , -- the same shape as systemd's NeedDaemonReload: the file has+            <> [psql' "SELECT pg_reload_conf()"]+            <> hbaLoaded+            <> [ -- the same shape as systemd's NeedDaemonReload: the file has                  -- changed, and only the running server knows whether what                  -- changed needs it to come back.                  "pending=$(" <> query "SELECT count(*) FROM pg_settings WHERE pending_restart" <> ")"@@ -1328,12 +1774,62 @@     peer = memberOn pair (other side)     cluster = Text.unpack m.member_cluster     port = show m.member_port+    security = connSecurity pair+    wantedConninfo = primaryConninfo pair (other side)      setting (k, v) = psql' ("ALTER SYSTEM SET " <> Text.unpack k <> " = " <> Text.unpack (quoteSql v))     quoteSql v = "'" <> Text.replace "'" "''" v <> "'"      hbaLine l = "grep -qxF " <> shQuote l <> " \"$hba\" || echo " <> shQuote l <> " >> \"$hba\"" +    {- Written back with `cat >`, not moved into place: the file keeps its+    owner, its mode and its inode, which are postgresql-common's. -}+    retireHbaLine l =+        "if grep -qxF " <> shQuote l <> " \"$hba\"; then awk -v l=" <> shQuote l+            <> " '$0 != l' \"$hba\" > \"$hba.salmon\" && cat \"$hba.salmon\" > \"$hba\"; rm -f \"$hba.salmon\"; fi"++    servesTls = case security of+        PlainMd5 -> []+        TlsScram tls -> serving tls False+        TlsClientCert tls _ -> serving tls True++    serving :: Tls -> Bool -> [String]+    serving tls needsClientCa =+        foldMap serverFiles tls.tls_server+            <> [ psql' "SELECT pg_reload_conf()"+               , "[ \"$(" <> query "SHOW ssl" <> ")\" = on ] || { echo "+                    <> shQuote ("cluster " <> cluster <> " is not serving TLS (ssl is off), so a hostssl line would match nothing: pg_hba.conf left as it was")+                    <> " >&2; exit 1; }"+               ]+            <> [ "[ -n \"$(" <> query "SHOW ssl_ca_file" <> ")\" ] || { echo "+                    <> shQuote ("cluster " <> cluster <> " has no ssl_ca_file, so it cannot check a client certificate: pg_hba.conf left as it was")+                    <> " >&2; exit 1; }"+               | needsClientCa+               ]++    serverFiles :: ServerFiles -> [String]+    serverFiles sf =+        map readable ([sf.server_cert, sf.server_key] <> foldMap pure sf.server_client_ca)+            <> [ setting ("ssl_cert_file", Text.pack sf.server_cert)+               , setting ("ssl_key_file", Text.pack sf.server_key)+               ]+            <> foldMap (\ca -> [setting ("ssl_ca_file", Text.pack ca)]) sf.server_client_ca+            <> [setting ("ssl", "on")]++    readable f =+        "sudo -u postgres test -r " <> shQuote f <> " || { echo "+            <> shQuote ("pre-provisioned file missing or unreadable by postgres: " <> f)+            <> " >&2; exit 1; }"++    hbaLoaded = case security of+        PlainMd5 -> []+        _ ->+            [ "broken=$(" <> query "SELECT count(*) FROM pg_hba_file_rules WHERE error IS NOT NULL" <> ")"+            , "[ \"$broken\" = 0 ] || { echo "+                <> shQuote "the server could not read pg_hba.conf as it now stands (see pg_hba_file_rules), so it is still using the previous one"+                <> " >&2; exit 1; }"+            ]+     psql' sql = "sudo -u postgres psql -p " <> port <> " -tAX -d postgres -c " <> shQuote sql     query = psql' @@ -1346,9 +1842,13 @@         "sudo -u postgres psql -p " <> port <> " -tAX -d postgres >/dev/null <<PAIR_SQL\n" <> sql <> "\nPAIR_SQL"      {- Created if missing, and its password set either way, so that rotating-    the passfile is enough to rotate the role. -}+    the passfile is enough to rotate the role -- and, under a TLS choice,+    stored as a SCRAM verifier whatever the cluster's own default is, since+    @scram-sha-256@ in pg_hba.conf refuses a role whose password is stored+    as md5. -}     roleSql attrs role pw =-        "DO \\$do\\$ BEGIN CREATE ROLE "+        storedAs+            <> "DO \\$do\\$ BEGIN CREATE ROLE "             <> Text.unpack role             <> " "             <> attrs@@ -1361,6 +1861,10 @@             <> pw             <> "';" +    storedAs = case security of+        PlainMd5 -> ""+        _ -> "SET password_encryption = 'scram-sha-256'; "+     grants =         unwords             [ "GRANT EXECUTE ON FUNCTION pg_ls_dir(text, boolean, boolean) TO " <> Text.unpack pair.pair_rewind_role <> ";"@@ -1381,7 +1885,7 @@                 , maybe "no side may be re-seeded" (\s -> "may be re-seeded, if its slot is lost: " <> Text.pack (show s)) pair.pair_reseed                 ]             , check = verdict <$> decide pair-            , up = converge r pair+            , up = refuseBadSecurity pair >> converge r pair             }  {- | The whole pair as one declaration: both machines configured to be@@ -1430,7 +1934,7 @@     obsA <- observe pair A     obsB <- observe pair B     bouncers <- traverse (observeBouncer pair) pair.pair_bouncers-    pure (nextStep pair obsA obsB bouncers)+    pure (nextStep pair obsA obsB (concat bouncers))  {- | Asks a bouncer where it is sending clients, and whether it is holding them.@@ -1440,12 +1944,12 @@ cannot. That is the right way round: a pair whose clients are going somewhere unknown has not arrived. -}-observeBouncer :: Pair -> Bouncer -> IO BouncerState+observeBouncer :: Pair -> Bouncer -> IO [BouncerState] observeBouncer pair b = do     (code, out, _) <- sshToTarget pair (OnBouncer b) (bouncerProbeScript b)     pure $ case code of-        ExitSuccess -> parseBouncerState b out-        ExitFailure _ -> BouncerState Nothing False+        ExitSuccess -> parseBouncerStates b out+        ExitFailure _ -> [BouncerState Nothing False | _ <- routedDatabases b]  -- | Runs 'probeScript' on a member and reads what comes back. observe :: Pair -> Side -> IO Observed@@ -1529,20 +2033,58 @@ sshTo :: Pair -> Side -> String -> IO (ExitCode, Text, Text) sshTo pair side = sshToTarget pair (OnMember (memberOn pair side)) --- | The same, for whichever kind of machine a step addresses.-sshToTarget :: Pair -> Target -> String -> IO (ExitCode, Text, Text)-sshToTarget pair target script = do-    (code, out, err) <- readCreateProcessWithExitCode (proc "ssh" args) ""-    pure (code, decode out, decode err)+{- | The @user\@host@ the controller's ssh is given for a machine: the one+place a member's ssh address is read. Everything a script /contains/ about a+member is its 'member_host'.+-}+sshLogin :: Target -> Text+sshLogin target = sshUser target <> "@" <> case target of+    OnMember m -> memberSshHost m+    OnBouncer b -> b.bouncer_ssh_host++-- | The user the controller logs in as on a machine.+sshUser :: Target -> Text+sshUser (OnMember m) = m.member_ssh_user+sshUser (OnBouncer b) = b.bouncer_ssh_user++{- | The words ssh is given after the login: the script, under one @bash -c@,+and under one @sudo@ when the login is not root.++Every script this recipe sends was written to be run by root -- it appends to+@pg_hba.conf@, calls @pg_ctlcluster@, installs into @\/etc\/pgbouncer@,+restarts units, and reaches postgres through @sudo -u postgres@ -- and stock+cloud images refuse root logins. So the privilege is taken once, around the+whole script, rather than line by line: one wrapper cannot miss a line, and a+script reads the same whoever logged in. A root login is sent what it always+was, with no @sudo@ in front, so a machine without sudo installed still works+as root.++@-n@ because nobody is there to type a password: a login whose sudo wants one+fails at once, saying so, rather than waiting on a prompt. The locale is set+/inside/ the script, since sudo resets the environment.+-}+remoteCommand :: Target -> String -> [String]+remoteCommand target script = escalate <> ["bash", "-c", shQuote quieted]   where-    (login, identity) = case target of-        OnMember m -> (m.member_ssh_user <> "@" <> m.member_host, m.member_ssh_identity)-        OnBouncer b -> (b.bouncer_ssh_user <> "@" <> b.bouncer_ssh_host, b.bouncer_ssh_identity)+    escalate+        | sshUser target == "root" = []+        | otherwise = ["sudo", "-n"]     {- A neutral locale, because ssh forwards the caller's and Debian's psql     is a perl wrapper that complains about every locale the guest does not     have -- fifteen lines of it, per invocation, into the report of a node     that did nothing wrong. -}     quieted = "export LANG=C LC_ALL=C\n" <> script++-- | The same, for whichever kind of machine a step addresses.+sshToTarget :: Pair -> Target -> String -> IO (ExitCode, Text, Text)+sshToTarget pair target script = do+    (code, out, err) <- readCreateProcessWithExitCode (proc "ssh" args) ""+    pure (code, decode out, decode err)+  where+    login = sshLogin target+    identity = case target of+        OnMember m -> m.member_ssh_identity+        OnBouncer b -> b.bouncer_ssh_identity     args =         concat             [ maybe [] (\key -> ["-i", key, "-o", "IdentitiesOnly=yes"]) identity@@ -1556,6 +2098,6 @@               -- the probe is already running.               ["-o", "ConnectTimeout=10", "-o", "ServerAliveInterval=5", "-o", "ServerAliveCountMax=2"]             , [Text.unpack login]-            , ["bash", "-c", shQuote quieted]+            , remoteCommand target script             ]     decode = Text.decodeUtf8With TextError.lenientDecode
+ src/SreBox/PostgresPairPrereqs.hs view
@@ -0,0 +1,558 @@+{-# LANGUAGE DeriveGeneric #-}+{-# LANGUAGE OverloadedRecordDot #-}+{-# LANGUAGE OverloadedStrings #-}++{- | What "SreBox.PostgresPair" assumes was done before it ran, as nodes.++The pair is handed paths: two @.pgpass@ files on each member, a console+@.pgpass@ and a @userlist.txt@ on each bouncer, and it expects the packages+to be there and the application to have a role, a database and a way in.+This module declares that layer, so that a consumer of the pair does not+write it again -- it is the part where mistakes are quiet (a @userlist.txt@+written under a running pgbouncer is a correct password that does not work).++= Secrets are still somebody else's files++Nothing here carries a password and nothing here decides how one travels.+Every secret is a file that is /already on the machine it is for/, put there+by whatever the deployment uses for that; a 'SecretFile' says where it was+left ('secret_from') and this recipe installs it where the pair reads it,+with the owner and mode the pair needs. A file left at its destination+already ('secret_from' @= Nothing@) only has its owner and mode set.++The scripts contain paths, never contents, and say nothing about a file but+its name: a missing file is named, a password is read on the machine and fed+to @psql@ on standard input, and the one statement that holds it has its+output withheld from the report.++= What it declares++* 'memberPrereqs', per member: the packages, a cluster of the declared name+  to exist, and the replication and rewind passfiles.+* 'bouncerPrereqs', per bouncer: the packages, the console passfile, and the+  auth file -- installed only when it differs, and pgbouncer restarted only+  then, because pgbouncer reads it when it starts and a restart drops the+  clients it is holding.+* 'applications', per member: a @pg_hba.conf@ line per application and+  client (on both members: that file is not replicated), and, on whichever+  member is the primary, the role, its password and its database.++'pairWithPrereqs' is the pair with all of it, in order: a machine's+prerequisites before the pair's own node for that machine, and the+applications after the role node -- once there is exactly one primary to+create a role on. Before it, a member that is about to be cloned is a+pristine cluster not in recovery, and a database created there is what makes+the clone refuse to run over it.+-}+module SreBox.PostgresPairPrereqs (+    -- * Declaring them+    SecretFile (..),+    postgresOwned,+    inPlace,+    Application (..),+    Prereqs (..),+    defaultPrereqs,+    validate,++    -- * The nodes+    memberPrereqs,+    bouncerPrereqs,+    applications,+    pairWithPrereqs,++    -- * The scripts they run+    memberPrereqsScript,+    bouncerPrereqsScript,+    applicationsScript,+    hbaLines,+) where++import Control.Exception (throwIO)+import Control.Monad (unless)+import Data.Aeson (FromJSON, ToJSON)+import Data.Char (isAsciiLower, isDigit, isOctDigit)+import Data.Text (Text)+import qualified Data.Text as Text+import GHC.Generics (Generic)+import System.Exit (ExitCode (..))+import System.FilePath (takeDirectory, (</>))++import Salmon.Builtin.Extension+import Salmon.Op.OpGraph (inject)+import Salmon.Op.Ref (mkRef)+import Salmon.Reporter++import SreBox.PostgresPair (Bouncer (..), Member (..), Pair (..), Side (..), Target (..), memberOn)+import qualified SreBox.PostgresPair as Pair++-------------------------------------------------------------------------------+-- Declaring them++{- | A secret somebody else put on the machine, and how it should be held+once it is where the pair reads it. The destination is not here: the pair+already names it.+-}+data SecretFile+    = SecretFile+    { secret_from :: Maybe FilePath+    -- ^ where it was provisioned, on the machine it is for. 'Nothing' (or+    -- the destination itself) is a file left in place: only its owner and+    -- mode are set.+    , secret_owner :: Text+    , secret_group :: Text+    , secret_mode :: Text+    -- ^ octal, as @install -m@ takes it.+    }+    deriving (Eq, Show, Generic)++instance FromJSON SecretFile+instance ToJSON SecretFile++{- | @postgres:postgres@, @0600@: what a passfile must be, since libpq+ignores one that anybody else can read and the pair's commands run as+@postgres@.+-}+postgresOwned :: FilePath -> SecretFile+postgresOwned from = SecretFile (Just from) "postgres" "postgres" "0600"++-- | The same, for a file provisioned straight to where the pair reads it.+inPlace :: SecretFile+inPlace = SecretFile Nothing "postgres" "postgres" "0600"++{- | One application's way into the pair: a role, the database it owns, and+who may connect as it.++The pair knows nothing of this -- it routes a database, it does not own one+-- which is why it is declared here and not there.+-}+data Application+    = Application+    { app_role :: Text+    , app_database :: Text+    , app_passfile :: FilePath+    -- ^ the role's password, in @.pgpass@ format, on /each member/ (either+    -- may be the primary). Read where it lies, by the login the controller+    -- uses; the password is the fifth field of its first line, as for the+    -- pair's own passfiles.+    , app_clients :: [Text]+    -- ^ the addresses that connect as this role -- the bouncers, as the+    -- members see them, which is not necessarily 'bouncer_ssh_host'. A bare+    -- address is one host; anything with a @\/@ is taken as written.+    , app_hba_method :: Text+    -- ^ @md5@ (which also accepts a SCRAM-stored password) or+    -- @scram-sha-256@.+    }+    deriving (Eq, Show, Generic)++instance FromJSON Application+instance ToJSON Application++data Prereqs+    = Prereqs+    { prereq_member_packages :: [Text]+    , prereq_bouncer_packages :: [Text]+    -- ^ Debian packages, installed only if missing. Empty for machines+    -- whose packages are somebody else's (an image, another recipe).+    , prereq_repl_passfile :: SecretFile+    -- ^ becomes 'pair_repl_passfile', on both members.+    , prereq_rewind_passfile :: SecretFile+    -- ^ becomes 'pair_rewind_passfile', on both members.+    , prereq_console_passfile :: SecretFile+    -- ^ becomes 'bouncer_console_passfile', on every bouncer.+    , prereq_userlist :: SecretFile+    -- ^ becomes @userlist.txt@ in 'bouncer_config_dir', on every bouncer: a+    -- file already in pgbouncer's @auth_file@ format, whatever hashing its+    -- author chose. Left in place ('secret_from' @= Nothing@) nothing can+    -- tell that it changed, so nothing restarts pgbouncer: whoever writes+    -- it there owns that.+    , prereq_applications :: [Application]+    }+    deriving (Eq, Show, Generic)++instance FromJSON Prereqs+instance ToJSON Prereqs++{- | Debian's packages, every secret left in place, and no application: the+smallest thing to amend.+-}+defaultPrereqs :: Prereqs+defaultPrereqs =+    Prereqs+        { prereq_member_packages = ["postgresql", "sudo"]+        , prereq_bouncer_packages = ["pgbouncer", "postgresql-client"]+        , prereq_repl_passfile = inPlace+        , prereq_rewind_passfile = inPlace+        , prereq_console_passfile = inPlace+        , -- pgbouncer runs as postgres on Debian, and nobody else needs it+          prereq_userlist = SecretFile Nothing "postgres" "postgres" "0640"+        , prereq_applications = []+        }++{- | Everything wrong with a declaration, not the first thing.++These values are spliced into shell scripts and SQL run as root on three+machines, so what is checked is that each is the kind of word it claims to+be. A node whose declaration does not pass refuses to run and says all of+this; a binary can ask earlier, where the directive is built.+-}+validate :: Pair -> Prereqs -> [Text]+validate pair pre =+    concat+        [ concatMap (package "member") pre.prereq_member_packages+        , concatMap (package "bouncer") pre.prereq_bouncer_packages+        , secret "replication passfile" pre.prereq_repl_passfile+        , secret "rewind passfile" pre.prereq_rewind_passfile+        , secret "console passfile" pre.prereq_console_passfile+        , secret "userlist" pre.prereq_userlist+        , concatMap application pre.prereq_applications+        ]+  where+    package whose p =+        [ whose <> " package " <> quoted p <> " is not a Debian package name"+        | not (packageName p)+        ]+    secret :: Text -> SecretFile -> [Text]+    secret what s =+        concat+            [ [what <> ": " <> quoted (Text.pack from) <> " is not an absolute path" | Just from <- [s.secret_from], not (plainPath from)]+            , [what <> ": owner " <> quoted s.secret_owner <> " is not a user name" | not (accountName s.secret_owner)]+            , [what <> ": group " <> quoted s.secret_group <> " is not a group name" | not (accountName s.secret_group)]+            , [what <> ": mode " <> quoted s.secret_mode <> " is not an octal mode" | not (octalMode s.secret_mode)]+            ]+    application :: Application -> [Text]+    application app =+        concat+            [ [who <> ": role " <> quoted app.app_role <> " is not a plain identifier" | not (identifier app.app_role)]+            , [who <> ": database " <> quoted app.app_database <> " is not a plain identifier" | not (identifier app.app_database)]+            , [ who <> ": role " <> quoted app.app_role <> " is the pair's own, whose password and attributes the pair sets"+              | app.app_role `elem` [pair.pair_repl_role, pair.pair_rewind_role]+              ]+            , [who <> ": passfile " <> quoted (Text.pack app.app_passfile) <> " is not an absolute path" | not (plainPath app.app_passfile)]+            , [who <> ": client " <> quoted c <> " is not an address" | c <- app.app_clients, not (address c)]+            , [ who <> ": " <> quoted app.app_hba_method <> " is not md5 or scram-sha-256"+              | app.app_hba_method `notElem` ["md5", "scram-sha-256"]+              ]+            ]+      where+        who = "application " <> quoted app.app_role++    quoted t = "\"" <> t <> "\""++    identifier t =+        not (Text.null t)+            && Text.length t <= 63+            && Text.all (\c -> isAsciiLower c || isDigit c || c == '_') t+            && not (isDigit (Text.head t))+    accountName t =+        not (Text.null t)+            && Text.all (\c -> isAsciiLower c || isDigit c || c `elem` ("_-" :: String)) t+            && not (Text.head t == '-')+    packageName t =+        not (Text.null t)+            && Text.all (\c -> isAsciiLower c || isDigit c || c `elem` ("+.-" :: String)) t+            && (isAsciiLower (Text.head t) || isDigit (Text.head t))+    octalMode t = Text.length t `elem` [3, 4] && Text.all isOctDigit t+    plainPath p = take 1 p == "/" && all (`notElem` ("\n\r" :: String)) p+    address t =+        not (Text.null t)+            && Text.all (\c -> isAsciiLower c || isDigit c || c `elem` ("ABCDEF.:/" :: String)) t++-------------------------------------------------------------------------------+-- The scripts++{- | The script 'memberPrereqs' runs: packages, the cluster the pair names,+and the two passfiles.+-}+memberPrereqsScript :: Pair -> Prereqs -> Side -> String+memberPrereqsScript pair pre side =+    unlines $+        ["set -e"]+            <> packagesFragment pre.prereq_member_packages+            <> [ -- the pair's every script starts by finding this cluster's+                 -- version, and an empty answer there reads as a path with a+                 -- hole in it; here it reads as what it is.+                 "pg_lsclusters --no-header | awk -v c=" <> shQuote cluster <> " '$2==c' | grep -q . || { echo "+                    <> shQuote ("no Postgres cluster named " <> cluster <> " on this machine")+                    <> " >&2; exit 1; }"+               ]+            <> installFragment pre.prereq_repl_passfile pair.pair_repl_passfile+            <> installFragment pre.prereq_rewind_passfile pair.pair_rewind_passfile+  where+    cluster = Text.unpack (memberOn pair side).member_cluster++{- | The script 'bouncerPrereqs' runs: packages, the console passfile, and+the auth file.++pgbouncer reads its auth file when it starts and not again, so a userlist+written under a running process is a password that does not work yet -- and+the symptom is an authentication failure with a correct password in a+correct file. Hence the restart; and hence only when the file changed,+because on every later pass a restart would drop the very clients the+bouncer is there to hold. @try-restart@, so that a bouncer that is not+running yet is left for the pair's own node to start, on its own ini.+-}+bouncerPrereqsScript :: Prereqs -> Bouncer -> String+bouncerPrereqsScript pre b =+    unlines $+        ["set -e"]+            <> packagesFragment pre.prereq_bouncer_packages+            <> installFragment pre.prereq_console_passfile b.bouncer_console_passfile+            <> ["salmon_changed=0"]+            <> installFragment pre.prereq_userlist (b.bouncer_config_dir </> "userlist.txt")+            <> [ "if [ \"$salmon_changed\" = 1 ]; then systemctl try-restart pgbouncer; fi"+               ]++{- | Installs one pre-provisioned file where the pair reads it.++Compared before it is copied, so an unchanged file is not rewritten (and+@salmon_changed@, which the auth file's restart reads, stays as it was);+owner and mode are set either way, since they are sets rather than inserts.+What it says on failure is the file's /name/.+-}+installFragment :: SecretFile -> FilePath -> [String]+installFragment s dest =+    case s.secret_from of+        Just from+            | from /= dest ->+                [ "[ -r " <> shQuote from <> " ] || { echo " <> shQuote ("pre-provisioned file missing or unreadable: " <> from) <> " >&2; exit 1; }"+                , "mkdir -p " <> shQuote (takeDirectory dest)+                , "if ! cmp -s " <> shQuote from <> " " <> shQuote dest <> "; then"+                , "  install -m " <> mode <> " -o " <> owner <> " -g " <> group <> " " <> shQuote from <> " " <> shQuote dest+                , "  salmon_changed=1"+                , "fi"+                ]+                    <> hold+        _ ->+            ["[ -e " <> shQuote dest <> " ] || { echo " <> shQuote ("pre-provisioned file missing: " <> dest) <> " >&2; exit 1; }"]+                <> hold+  where+    owner = shQuote (Text.unpack s.secret_owner)+    group = shQuote (Text.unpack s.secret_group)+    mode = shQuote (Text.unpack s.secret_mode)+    hold =+        [ "chown " <> shQuote (Text.unpack (s.secret_owner <> ":" <> s.secret_group)) <> " " <> shQuote dest+        , "chmod " <> mode <> " " <> shQuote dest+        ]++{- | Installs what is missing and nothing else: a machine whose packages are+all there -- baked into an image, say, with no route to a mirror -- never+reaches @apt-get@.+-}+packagesFragment :: [Text] -> [String]+packagesFragment [] = []+packagesFragment packages =+    [ "salmon_missing=''"+    , "for p in " <> unwords (map (shQuote . Text.unpack) packages) <> "; do"+    , "  dpkg-query -W -f='${Status}' \"$p\" 2>/dev/null | grep -q 'install ok installed' || salmon_missing=\"$salmon_missing $p\""+    , "done"+    , "if [ -n \"$salmon_missing\" ]; then DEBIAN_FRONTEND=noninteractive apt-get install -y $salmon_missing; fi"+    ]++-- | The @pg_hba.conf@ lines the applications need, on either member.+hbaLines :: Prereqs -> [Text]+hbaLines pre =+    [ Text.unwords ["host", app.app_database, app.app_role, cidr client, app.app_hba_method]+    | app <- pre.prereq_applications+    , client <- app.app_clients+    ]+  where+    cidr c+        | "/" `Text.isInfixOf` c = c+        | ":" `Text.isInfixOf` c = c <> "/128"+        | otherwise = c <> "/32"++{- | The script 'applications' runs on a member.++The @pg_hba.conf@ lines go on whichever half this is, because that file+lives outside the data directory and reaches the other machine through+nothing. Roles and databases are catalog rows and do reach it, through the+WAL, so only a primary makes them -- the same rule as the pair's own roles.++The password is read here, from the file the declaration names, with its+quotes doubled, and reaches @psql@ on standard input: never an argument,+which is in @ps@. That one statement's output is withheld, because psql+quotes the line it failed on and this is the line with the password in it;+the failure is reported in words instead.+-}+applicationsScript :: Pair -> Prereqs -> Side -> String+applicationsScript pair pre side =+    unlines $+        [ "set -e"+        , "version=$(pg_lsclusters --no-header | awk -v c=" <> shQuote cluster <> " '$2==c {print $1}' | head -n1)"+        , "[ -n \"$version\" ] || { echo " <> shQuote ("no Postgres cluster named " <> cluster <> " on this machine") <> " >&2; exit 1; }"+        , "hba=/etc/postgresql/$version/" <> cluster <> "/pg_hba.conf"+        ]+            <> map hbaLine (hbaLines pre)+            <> [ psql "SELECT pg_reload_conf()" <> " >/dev/null"+               , "if [ \"$(" <> psql "SELECT pg_is_in_recovery()" <> ")\" = f ]; then"+               ]+            <> concatMap (map ("  " <>) . application) pre.prereq_applications+            <> [ "  :"+               , "fi"+               ]+  where+    m = memberOn pair side+    cluster = Text.unpack m.member_cluster+    port = show m.member_port++    hbaLine l = "grep -qxF " <> shQuote (Text.unpack l) <> " \"$hba\" || echo " <> shQuote (Text.unpack l) <> " >> \"$hba\""++    psqlBase = "sudo -u postgres psql -p " <> port <> " -tAX -v ON_ERROR_STOP=1 -d postgres"+    psql sql = psqlBase <> " -c " <> shQuote sql++    application :: Application -> [String]+    application app =+        [ "[ -r " <> shQuote app.app_passfile <> " ] || { echo " <> shQuote ("pre-provisioned file missing or unreadable: " <> app.app_passfile) <> " >&2; exit 1; }"+        , "salmon_pw=$(awk -F: 'NR==1 {print $5}' " <> shQuote app.app_passfile <> " | sed \"s/'/''/g\")"+        , "[ -n \"$salmon_pw\" ] || { echo " <> shQuote ("no password on the first line of " <> app.app_passfile) <> " >&2; exit 1; }"+        , psql ("DO $do$ BEGIN CREATE ROLE " <> role <> " LOGIN; EXCEPTION WHEN duplicate_object THEN NULL; END $do$") <> " >/dev/null"+        , -- created if missing and its password set either way, so that+          -- rotating the passfile is enough to rotate the role. The heredoc's+          -- delimiter is unquoted on purpose: the shell substitutes the+          -- password it just read.+          psqlBase+            <> " >/dev/null 2>&1 <<SALMON_APP_SQL || { echo "+            <> shQuote ("could not set the password of role " <> role <> " (psql's output is withheld: it quotes the statement)")+            <> " >&2; exit 1; }\nALTER ROLE "+            <> role+            <> " LOGIN PASSWORD '$salmon_pw';\nSALMON_APP_SQL"+        , "unset salmon_pw"+        , -- CREATE DATABASE runs in no transaction and has no IF NOT EXISTS+          psql ("SELECT 1 FROM pg_database WHERE datname = '" <> db <> "'")+            <> " | grep -q 1 || "+            <> psql ("CREATE DATABASE " <> db <> " OWNER " <> role)+            <> " >/dev/null"+        ]+      where+        role = Text.unpack app.app_role+        db = Text.unpack app.app_database++shQuote :: String -> String+shQuote s = "'" <> concatMap (\c -> if c == '\'' then "'\\''" else [c]) s <> "'"++-------------------------------------------------------------------------------+-- The nodes++{- | What a machine needs before it can be a member: the packages, a cluster+of the declared name, and the two passfiles where the pair reads them.++Like the pair's own member node it says nothing about which half this is,+and it has no @check@: everything it does is compare-then-set, and asking a+machine whether all of it holds costs the round trip that doing it does.+-}+memberPrereqs :: Reporter Pair.Report -> Pair -> Prereqs -> Side -> Op+memberPrereqs r pair pre side =+    op "pg-pair-member-prereqs" nodeps $ \actions ->+        actions+            { ref = mkRef "pg-pair-member-prereqs" (pair.pair_name <> "@" <> m.member_host)+            , help = Text.unwords ["what", m.member_host, "needs to be a member of", pair.pair_name]+            , notes =+                [ "packages: " <> packagesNote pre.prereq_member_packages+                , secretNote "replication passfile" pre.prereq_repl_passfile pair.pair_repl_passfile+                , secretNote "rewind passfile" pre.prereq_rewind_passfile pair.pair_rewind_passfile+                ]+            , up = runOn r pair pre (OnMember m) m.member_host (memberPrereqsScript pair pre side)+            }+  where+    m = memberOn pair side++{- | What a machine needs before it can be this pair's bouncer: the+packages, the console passfile, and the auth file -- see+'bouncerPrereqsScript' for when pgbouncer is restarted and when not.+-}+bouncerPrereqs :: Reporter Pair.Report -> Pair -> Prereqs -> Bouncer -> Op+bouncerPrereqs r pair pre b =+    op "pg-pair-bouncer-prereqs" nodeps $ \actions ->+        actions+            { ref = mkRef "pg-pair-bouncer-prereqs" (pair.pair_name <> "@" <> b.bouncer_ssh_host)+            , help = Text.unwords ["what", b.bouncer_name, "needs to be a bouncer of", pair.pair_name]+            , notes =+                [ "packages: " <> packagesNote pre.prereq_bouncer_packages+                , secretNote "console passfile" pre.prereq_console_passfile b.bouncer_console_passfile+                , secretNote "auth file" pre.prereq_userlist (b.bouncer_config_dir </> "userlist.txt")+                , "pgbouncer is restarted only when the auth file changed"+                ]+            , up = runOn r pair pre (OnBouncer b) b.bouncer_name (bouncerPrereqsScript pre b)+            }++{- | The applications' roles, databases and @pg_hba.conf@ lines, on one+member. Declared for both: the lines are per machine, and the rest is done+by whichever is the primary when the node runs.+-}+applications :: Reporter Pair.Report -> Pair -> Prereqs -> Side -> Op+applications r pair pre side =+    op "pg-pair-applications" nodeps $ \actions ->+        actions+            { ref = mkRef "pg-pair-applications" (pair.pair_name <> "@" <> m.member_host)+            , help = Text.unwords ["applications of", pair.pair_name, "on", m.member_host]+            , notes =+                [ Text.unwords+                    [ "role"+                    , app.app_role+                    , "owns database"+                    , app.app_database <> ","+                    , "password from"+                    , Text.pack app.app_passfile <> ","+                    , "reached from"+                    , if null app.app_clients then "nowhere" else Text.intercalate ", " app.app_clients+                    ]+                | app <- pre.prereq_applications+                ]+            , up = runOn r pair pre (OnMember m) m.member_host (applicationsScript pair pre side)+            }+  where+    m = memberOn pair side++packagesNote :: [Text] -> Text+packagesNote [] = "none (somebody else's)"+packagesNote ps = Text.unwords ps++-- | Paths, owner and mode: what a reader of @run tree@ may know of a secret.+secretNote :: Text -> SecretFile -> FilePath -> Text+secretNote what s dest =+    Text.unwords+        [ what+        , Text.pack dest+        , s.secret_owner <> ":" <> s.secret_group+        , s.secret_mode <> ","+        , case s.secret_from of+            Just from | from /= dest -> "installed from " <> Text.pack from+            _ -> "provisioned in place"+        ]++runOn :: Reporter Pair.Report -> Pair -> Prereqs -> Target -> Text -> String -> IO ()+runOn r pair pre target name script = do+    let refusals = validate pair pre+    unless (null refusals) $+        throwIO (userError (Text.unpack ("prerequisites of " <> pair.pair_name <> ": " <> Text.intercalate "; " refusals)))+    (code, out, err) <- Pair.sshToTarget pair target script+    runReporter r (Pair.Acted name code (Text.strip (out <> err)))+    case code of+        ExitSuccess -> pure ()+        ExitFailure _ ->+            throwIO (userError (Text.unpack ("prerequisites on " <> name <> ": " <> Text.strip (err <> out))))++{- | The pair, and everything it assumed.++The edges are the point, and there are three kinds. A machine's+prerequisites come before the pair's own node for that machine -- declared+by naming that node again with one more dependency, which the walk merges+with the pair's own declaration of it, since they are the same node. The+applications come after 'Pair.pairOp', so after the role node. And nothing+else is ordered: one member's packages do not wait for the other's.+-}+pairWithPrereqs :: Reporter Pair.Report -> Pair -> Prereqs -> Op+pairWithPrereqs r pair pre =+    op "pg-pair-with-prereqs" (deps (apps <> [whole] <> machines)) $ \actions ->+        actions+            { ref = mkRef "pg-pair-with-prereqs" pair.pair_name+            , help = Text.unwords ["the pair", pair.pair_name, "and what it needs first"]+            }+  where+    whole = Pair.pairOp r pair+    machines =+        [Pair.member r pair side `inject` memberPrereqs r pair pre side | side <- [A, B]]+            <> [Pair.bouncerSetup r pair b `inject` bouncerPrereqs r pair pre b | b <- pair.pair_bouncers]+    apps+        | null pre.prereq_applications = []+        | otherwise = [applications r pair pre side `inject` whole | side <- [A, B]]
src/SreBox/PostgresTls.hs view
@@ -157,9 +157,14 @@             client.ca_cidr             `inject` tls +    -- The material goes under the settings and the reload themselves:+    -- @pg-server-tls@ has no effect of its own, and injected onto it alone+    -- the material was their sibling, so @ssl = on@ and a reload could come+    -- before the key was in place. The injection onto it is kept, it+    -- leaves that node's edges as they were.     tls :: Op     tls =-        Postgres.serverTls rPg psql cfg.pgcc_port cfg.pgcc_tls+        Postgres.serverTlsAfter [ownedMaterial] rPg psql cfg.pgcc_port cfg.pgcc_tls             `inject` ownedMaterial      -- Postgres refuses to start with a group- or world-readable key, and
src/SreBox/Postgrest.hs view
@@ -335,8 +335,15 @@                 ]             ) +    -- 'prest', the @migrate-remotely@ node and 'initDB' only group the+    -- nodes doing the work, and what is injected onto one of them is a+    -- sibling of that work: the order is said under the working nodes+    -- instead (the migration waits for the database and roles, the service+    -- for the migration). The injections onto the grouping nodes are kept;+    -- they leave those nodes' edges as they were.     migrateDBWithUser =-        PGMigrate.remoteMigrateOpaqueSetup+        PGMigrate.remoteMigrateOpaqueSetupAfter+            [initDB]             cfg.pma_serviceName             (contramap Migration r)             simulate@@ -352,7 +359,7 @@                 []             ) -    prest = op "postgrest" (deps [setupPrest]) $ \actions ->+    prest = op "postgrest" (deps [setupPrest `inject` migrateDBWithUser]) $ \actions ->         actions             { ref = mkRef "prest-service" cfg.pma_serviceName             }
+ src/SreBox/WireGuardMesh.hs view
@@ -0,0 +1,561 @@+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE DeriveGeneric #-}+{-# LANGUAGE OverloadedRecordDot #-}+{-# LANGUAGE OverloadedStrings #-}++{- | A WireGuard mesh among hosts that have stable endpoints, declared once and+unfolded per host. See @specs\/wireguard-mesh.md@ for the whole design.++= Shape++The /seed/ ('MeshSeed') is the one declaration: peers (a declared address, a+public key collected out of band, an optional endpoint, groups), policies+(group to group, protocol, ports) and routers (a peer that also carries a+network, or the default route). 'genHost' is a __pure__ fan-out of that seed+to one host's view ('HostSpec'): its interface, its peers with their+allowed-ips, its routes and its nft rules. 'hostOp' turns a 'HostSpec' into+an 'Op'. Nothing in between touches the network or the filesystem, so the+table of seeds to host specs is a Layer 0 test.++= Delivery++'hostDocument' wraps a host's 'HostSpec' as a pull-mode document (one inline+directive), to be signed and published under a label named for the host and+followed with @run serve --follow ... --label HOST@. A peer added to or+removed from the seed is then a small diff of nodes the ledger brings up and+down, which is why "Salmon.Builtin.Nodes.WireGuard"'s @peer@ has a @down@.++= What this recipe does not do++* It ships no secret. Each host generates its own private key where it is+  (@WG.privateKey@, kept, never sent); a public key is not a secret and is+  carried inline in the seed.+* A peer with no endpoint dials out and sets a persistent keepalive; it is+  reached only through peers that have one. Two peers that both lack an+  endpoint have no link. There is no NAT traversal and no roaming.+* An endpoint given as a host name is resolved by WireGuard when the peer is+  configured. Following a changed address is left to the peer node's+  @check@ and the tending loop, not done here.+* Policies are enforced on the input of the mesh interface (traffic addressed+  to the host). Traffic a router forwards between the mesh and elsewhere is+  governed by the router's forward chain (mesh-interface traffic only), not+  by policies.+* IPv4 only. An exit router's endpoint must be an IPv4 literal, because the+  route pinning it off the tunnel needs an address, not a name.+-}+module SreBox.WireGuardMesh (+    -- * The declaration+    MeshSeed (..),+    PeerDecl (..),+    Policy (..),+    Proto (..),+    PortRange (..),+    RouterDecl (..),++    -- * The per-host view+    HostSpec (..),+    PeerLink (..),+    NftRuleSpec (..),+    MeshError (..),+    renderError,+    validate,+    genHost,+    genAll,++    -- * Delivery+    hostDocument,++    -- * The node+    hostOp,+    Report,+    Binaries (..),++    -- * Exposed for tests+    parseIpv4,+    parseCidr,+    inSubnet,+) where++import Data.Aeson (FromJSON, ToJSON, toJSON)+import Data.Bits (complement, shiftL, (.&.), (.|.))+import Data.List (nub, sort)+import qualified Data.Map.Strict as Map+import Data.Maybe (fromMaybe)+import Data.Text (Text)+import qualified Data.Text as Text+import Data.Word (Word32)+import GHC.Generics (Generic)+import Text.Read (readMaybe)++import Salmon.Actions.Follow (Document (..), Entry (..))+import Salmon.Builtin.Extension+import qualified Salmon.Builtin.Nodes.Netfilter as Nft+import qualified Salmon.Builtin.Nodes.Routes as IpRoute+import qualified Salmon.Builtin.Nodes.Sysctl as Sysctl+import qualified Salmon.Builtin.Nodes.WireGuard as WG+import Salmon.Op.OpGraph (inject)+import Salmon.Op.Track+import Salmon.Reporter++import SreBox.WireGuardVpn (Binaries (..), Report (..), pinHostRoute)++-------------------------------------------------------------------------------+-- the declaration++data MeshSeed = MeshSeed+    { mesh_subnet :: Text+    -- ^ the mesh's network, e.g. @10.66.0.0/24@+    , mesh_iface :: Text+    , mesh_port :: Int+    -- ^ the UDP port every host listens on+    , mesh_privkey_path :: FilePath+    -- ^ where each host keeps (and, if absent, generates) its private key+    , mesh_peers :: [PeerDecl]+    , mesh_policies :: [Policy]+    , mesh_routers :: [RouterDecl]+    }+    deriving (Eq, Show, Generic)++instance FromJSON MeshSeed+instance ToJSON MeshSeed++data PeerDecl = PeerDecl+    { peer_name :: Text+    , peer_addr :: Text+    -- ^ a bare IPv4 address inside the subnet, declared and never derived from+    -- position, so adding or removing a peer renumbers nobody+    , peer_pubkey :: Text+    -- ^ base64, collected out of band+    , peer_endpoint :: Maybe Text+    -- ^ @host:port@, host a literal or a name+    , peer_groups :: [Text]+    }+    deriving (Eq, Show, Generic)++instance FromJSON PeerDecl+instance ToJSON PeerDecl++data Proto = Tcp | Udp | Icmp | AnyProto+    deriving (Eq, Show, Generic)++instance FromJSON Proto+instance ToJSON Proto++data PortRange = PortRange {port_from :: Int, port_to :: Int}+    deriving (Eq, Show, Generic)++instance FromJSON PortRange+instance ToJSON PortRange++data Policy = Policy+    { policy_from :: [Text]+    -- ^ groups+    , policy_to :: [Text]+    -- ^ groups+    , policy_protocol :: Proto+    , policy_ports :: [PortRange]+    -- ^ only for 'Tcp' and 'Udp'; none means every port+    }+    deriving (Eq, Show, Generic)++instance FromJSON Policy+instance ToJSON Policy++{- | A peer that also reaches a network. @0.0.0.0/0@ makes it the mesh's exit+node; at most one router may be that.+-}+data RouterDecl = RouterDecl+    { router_peer :: Text+    , router_network :: Text+    }+    deriving (Eq, Show, Generic)++instance FromJSON RouterDecl+instance ToJSON RouterDecl++-------------------------------------------------------------------------------+-- the per-host view++-- | One peer as a host configures it.+data PeerLink = PeerLink+    { link_name :: Text+    , link_pubkey :: Text+    , link_endpoint :: Maybe Text+    , link_allowed_ips :: [Text]+    , link_keepalive :: Maybe Int+    }+    deriving (Eq, Show, Generic)++instance FromJSON PeerLink+instance ToJSON PeerLink++-- | A rule in the host's mesh input chain, as the words handed to @nft@.+newtype NftRuleSpec = NftRuleSpec {rule_words :: [Text]}+    deriving (Eq, Show, Generic)++instance FromJSON NftRuleSpec+instance ToJSON NftRuleSpec++data HostSpec = HostSpec+    { host_name :: Text+    , host_iface :: Text+    , host_addr :: Text+    -- ^ CIDR, the host's address with the mesh's prefix length+    , host_port :: Int+    , host_privkey_path :: FilePath+    , host_peers :: [PeerLink]+    , host_routes :: [Text]+    -- ^ networks to send over the interface (besides the mesh itself)+    , host_exit_pins :: [Text]+    -- ^ addresses kept off the tunnel (the endpoints of exit routers)+    , host_forwards :: Maybe Text+    -- ^ for a router, the mesh subnet it masquerades when it leaves elsewhere+    , host_input_rules :: [NftRuleSpec]+    -- ^ accepted traffic on the mesh interface, in order, after+    -- established/related and before the final drop+    }+    deriving (Eq, Show, Generic)++instance FromJSON HostSpec+instance ToJSON HostSpec++data MeshError+    = BadSubnet Text+    | BadAddress Text Text+    | AddressOutsideSubnet Text Text+    | DuplicatePeerName Text+    | DuplicateAddress Text [Text]+    | DuplicatePubkey [Text]+    | BadEndpoint Text Text+    | UnknownGroup Int Text+    | BadPorts Int Text+    | UnknownRouterPeer Text+    | BadNetwork Text Text+    | DuplicateRouterNetwork Text+    | SeveralExitRouters [Text]+    | ExitEndpointNotLiteral Text+    | UnknownHost Text+    deriving (Eq, Show)++renderError :: MeshError -> Text+renderError e = case e of+    BadSubnet s -> "mesh subnet is not an IPv4 CIDR: " <> s+    BadAddress p a -> "peer " <> p <> " has an address that is not IPv4: " <> a+    AddressOutsideSubnet p a -> "peer " <> p <> " has address " <> a <> " outside the mesh subnet"+    DuplicatePeerName p -> "two peers are named " <> p+    DuplicateAddress a ps -> "address " <> a <> " is declared by several peers: " <> Text.intercalate ", " ps+    DuplicatePubkey ps -> "peers share one public key: " <> Text.intercalate ", " ps+    BadEndpoint p ep -> "peer " <> p <> " has an endpoint that is not host:port: " <> ep+    UnknownGroup i g -> "policy #" <> tshow i <> " names a group no peer belongs to: " <> g+    BadPorts i why -> "policy #" <> tshow i <> ": " <> why+    UnknownRouterPeer p -> "router names an unknown peer: " <> p+    BadNetwork p n -> "router " <> p <> " has a network that is not an IPv4 CIDR: " <> n+    DuplicateRouterNetwork n -> "network " <> n <> " is carried by several routers"+    SeveralExitRouters ps -> "several peers are the exit node: " <> Text.intercalate ", " ps+    ExitEndpointNotLiteral p -> "exit router " <> p <> " needs an endpoint that is an IPv4 literal (its route is pinned by address)"+    UnknownHost h -> "no such peer in the seed: " <> h++tshow :: Show a => a -> Text+tshow = Text.pack . show++-------------------------------------------------------------------------------+-- pure IPv4++parseIpv4 :: Text -> Maybe Word32+parseIpv4 t = case traverse readOctet (Text.splitOn "." t) of+    Just [a, b, c, d] -> Just ((a `shiftL` 24) .|. (b `shiftL` 16) .|. (c `shiftL` 8) .|. d)+    _ -> Nothing+  where+    readOctet o+        | Text.null o || Text.any (`notElem` ['0' .. '9']) o || Text.length o > 3 = Nothing+        | otherwise = case readMaybe (Text.unpack o) :: Maybe Word32 of+            Just n | n <= 255 -> Just n+            _ -> Nothing++-- | An address and a prefix length.+parseCidr :: Text -> Maybe (Word32, Int)+parseCidr t = case Text.splitOn "/" t of+    [a, p] -> do+        addr <- parseIpv4 a+        n <- if Text.all (`elem` ['0' .. '9']) p && not (Text.null p) then readMaybe (Text.unpack p) else Nothing+        if n >= 0 && n <= 32 then Just (addr, n) else Nothing+    _ -> Nothing++mask :: Int -> Word32+mask 0 = 0+mask n = complement 0 `shiftL` (32 - n)++-- | Is the address inside the (address, prefix) network?+inSubnet :: (Word32, Int) -> Word32 -> Bool+inSubnet (net, n) a = (a .&. mask n) == (net .&. mask n)++-- | @host:port@ with a port in range.+parseEndpoint :: Text -> Maybe (Text, Int)+parseEndpoint t =+    let (h, rest) = Text.breakOnEnd ":" t+        host = Text.dropEnd 1 h+     in do+            port <- if Text.all (`elem` ['0' .. '9']) rest && not (Text.null rest) then readMaybe (Text.unpack rest) else Nothing+            if not (Text.null host) && port >= 1 && port <= 65535 then Just (host, port) else Nothing++-------------------------------------------------------------------------------+-- validation++duplicates :: Ord a => [a] -> [a]+duplicates xs = Map.keys (Map.filter (> (1 :: Int)) (Map.fromListWith (+) [(x, 1) | x <- xs]))++-- | Every refusal at once; empty when the seed is sound.+validate :: MeshSeed -> [MeshError]+validate s = subnetErrs <> nameErrs <> addrErrs <> keyErrs <> endpointErrs <> policyErrs <> routerErrs+  where+    peers = s.mesh_peers+    msubnet = parseCidr s.mesh_subnet+    subnetErrs = [BadSubnet s.mesh_subnet | Nothing <- [msubnet]]+    nameErrs = DuplicatePeerName <$> duplicates (peer_name <$> peers)+    addrErrs =+        concat+            [ case parseIpv4 p.peer_addr of+                Nothing -> [BadAddress p.peer_name p.peer_addr]+                Just a -> [AddressOutsideSubnet p.peer_name p.peer_addr | Just net <- [msubnet], not (inSubnet net a)]+            | p <- peers+            ]+            <> [DuplicateAddress a [p.peer_name | p <- peers, p.peer_addr == a] | a <- duplicates (peer_addr <$> peers)]+    keyErrs = [DuplicatePubkey [p.peer_name | p <- peers, p.peer_pubkey == k] | k <- duplicates (peer_pubkey <$> peers)]+    endpointErrs = [BadEndpoint p.peer_name ep | p <- peers, Just ep <- [p.peer_endpoint], Nothing <- [parseEndpoint ep]]+    groups = nub (concatMap peer_groups peers)+    policyErrs =+        concat+            [ [UnknownGroup i g | g <- pol.policy_from <> pol.policy_to, g `notElem` groups]+                <> portErrs i pol+            | (i, pol) <- zip [1 ..] s.mesh_policies+            ]+    portErrs i pol =+        [BadPorts i "ports given for a protocol without ports" | pol.policy_protocol `elem` [Icmp, AnyProto], not (null pol.policy_ports)]+            <> [ BadPorts i ("bad port range " <> tshow lo <> "-" <> tshow hi)+               | PortRange lo hi <- pol.policy_ports+               , lo < 1 || hi > 65535 || lo > hi+               ]+            <> [BadPorts i "a policy needs at least one group on each side" | null pol.policy_from || null pol.policy_to]+    routerErrs =+        concat+            [ [UnknownRouterPeer r.router_peer | r.router_peer `notElem` (peer_name <$> peers)]+                <> [BadNetwork r.router_peer r.router_network | Nothing <- [parseCidr r.router_network]]+            | r <- s.mesh_routers+            ]+            <> (DuplicateRouterNetwork <$> duplicates (router_network <$> s.mesh_routers))+            <> [SeveralExitRouters exits | let exits = [r.router_peer | r <- s.mesh_routers, isExit r.router_network], length exits > 1]+            <> [ ExitEndpointNotLiteral r.router_peer+               | r <- s.mesh_routers+               , isExit r.router_network+               , Just p <- [lookupPeer r.router_peer]+               , not (maybe False (isLiteral . fst) (p.peer_endpoint >>= parseEndpoint))+               ]+    lookupPeer n = case [p | p <- peers, p.peer_name == n] of (p : _) -> Just p; [] -> Nothing+    isLiteral h = parseIpv4 h /= Nothing++isExit :: Text -> Bool+isExit n = n == "0.0.0.0/0"++-------------------------------------------------------------------------------+-- fan-out++-- | The view of every host, or every refusal.+genAll :: MeshSeed -> Either [MeshError] [HostSpec]+genAll s = case validate s of+    [] -> Right [genChecked s p | p <- s.mesh_peers]+    errs -> Left errs++-- | One host's view, or every refusal (an unknown host included).+genHost :: MeshSeed -> Text -> Either [MeshError] HostSpec+genHost s name = case validate s of+    [] -> case [p | p <- s.mesh_peers, p.peer_name == name] of+        (p : _) -> Right (genChecked s p)+        [] -> Left [UnknownHost name]+    errs -> Left errs++-- | Fan-out for a seed already validated.+genChecked :: MeshSeed -> PeerDecl -> HostSpec+genChecked s me =+    HostSpec+        { host_name = me.peer_name+        , host_iface = s.mesh_iface+        , host_addr = me.peer_addr <> "/" <> prefixLen+        , host_port = s.mesh_port+        , host_privkey_path = s.mesh_privkey_path+        , host_peers = links+        , host_routes = routes+        , host_exit_pins = pins+        , host_forwards = if myRouters /= [] then Just s.mesh_subnet else Nothing+        , host_input_rules = inputRules+        }+  where+    prefixLen = maybe "32" (tshow . snd) (parseCidr s.mesh_subnet)+    others = [p | p <- s.mesh_peers, p.peer_name /= me.peer_name]+    routerNets p = [r.router_network | r <- s.mesh_routers, r.router_peer == p.peer_name]+    myRouters = routerNets me++    -- a link exists when at least one side has an endpoint to dial+    linked p = me.peer_endpoint /= Nothing || p.peer_endpoint /= Nothing+    links =+        [ PeerLink+            { link_name = p.peer_name+            , link_pubkey = p.peer_pubkey+            , link_endpoint = p.peer_endpoint+            , link_allowed_ips = (p.peer_addr <> "/32") : routerNets p+            , -- whoever has no endpoint is the one dialling, and keeps the path open+              link_keepalive = if me.peer_endpoint == Nothing then Just 25 else Nothing+            }+        | p <- others+        , linked p+        ]++    reachedRouters = [(p, n) | l <- links, Just p <- [byName l.link_name], n <- routerNets p]+    byName n = case [p | p <- s.mesh_peers, p.peer_name == n] of (p : _) -> Just p; [] -> Nothing+    routes =+        concat+            [ if isExit n then ["0.0.0.0/1", "128.0.0.0/1"] else [n]+            | (_, n) <- reachedRouters+            ]+    pins = [h | (p, n) <- reachedRouters, isExit n, Just (h, _) <- [p.peer_endpoint >>= parseEndpoint]]++    -- policies whose destination includes this host+    inputRules =+        [ NftRuleSpec (policyWords srcs pol)+        | pol <- s.mesh_policies+        , any (`elem` me.peer_groups) pol.policy_to+        , let srcs = sort (nub [p.peer_addr | p <- s.mesh_peers, any (`elem` p.peer_groups) pol.policy_from])+        ]++    ifaceQuoted = "\"" <> s.mesh_iface <> "\""+    policyWords srcs pol =+        ["iifname", ifaceQuoted, "ip", "saddr", set srcs]+            <> protoWords pol++    protoWords pol = case pol.policy_protocol of+        AnyProto -> ["accept"]+        Icmp -> ["ip", "protocol", "icmp", "accept"]+        Tcp -> portWords "tcp" pol.policy_ports+        Udp -> portWords "udp" pol.policy_ports+    portWords proto [] = ["meta", "l4proto", proto, "accept"]+    portWords proto ranges = [proto, "dport", set (renderRange <$> ranges), "accept"]+    renderRange (PortRange lo hi) = if lo == hi then tshow lo else tshow lo <> "-" <> tshow hi++    -- nft prints a one-element set without braces, and the rule's check+    -- matches the rendered text, so render it the way nft lists it+    set [x] = x+    set xs = "{ " <> Text.intercalate ", " xs <> " }"++-------------------------------------------------------------------------------+-- delivery++{- | A host's pull-mode document: one inline directive. @docid@ is the+publisher's name for this revision (it is what @history@ records).+-}+hostDocument :: Text -> HostSpec -> Document+hostDocument docid h = Document{docId = docid, docSeeds = [SeedDirective (toJSON h)], docPublished = Nothing}++-------------------------------------------------------------------------------+-- the node++{- | The Op for one host: the interface with its private key (generated here+if absent, never sent anywhere), the peers, the routes, and the forwarding+and nft rules the 'HostSpec' asks for.+-}+hostOp :: Reporter Report -> Binaries -> HostSpec -> Op+hostOp r bins h =+    op "wireguard-mesh-host" (deps [routing, forwarding, filtering]) $ \actions ->+        actions{help = "wireguard mesh host " <> h.host_name}+  where+    wg_r = contramap RunWireGuard r+    nft_r = contramap RunNft r+    sysctl_r = contramap RunSysctl r+    ip_r = contramap RunIpRoute r++    (_, plen) = fromMaybe (0, 32) (parseCidr h.host_addr)+    netCidr = WG.Ipv4Cidr (Text.takeWhile (/= '/') h.host_addr) plen++    ifaceTrack :: Track' WG.WgName+    ifaceTrack = Track $ \name -> WG.iface wg_r bins.binIp name netCidr++    privkeyTrack :: Track' FilePath+    privkeyTrack = Track $ WG.privateKey bins.binWg++    serverIface :: Op+    serverIface = WG.server wg_r bins.binWg privkeyTrack ifaceTrack h.host_iface h.host_privkey_path h.host_port++    peerOp :: PeerLink -> Op+    peerOp l =+        WG.peerWith+            wg_r+            bins.binWg+            ifaceTrack+            ignoreTrack+            h.host_iface+            (WG.PeerKeyValue l.link_pubkey)+            l.link_endpoint+            (Text.intercalate "," l.link_allowed_ips)+            l.link_keepalive++    -- Each peer is injected the interface's key and port, not only the+    -- grouping node: that one has no effect, and what is injected onto it+    -- alone is a sibling of the peers, not something they wait for.+    peers :: Op+    peers = op "wireguard-mesh-peers" (deps ((`inject` serverIface) . peerOp <$> h.host_peers)) id `inject` serverIface++    pinOps = [pinHostRoute ip_r bins a `inject` peers | a <- h.host_exit_pins]++    -- every pin precedes every route, or the tunnel's own traffic loops+    routing :: Op+    routing =+        op "wireguard-mesh-routes" (deps routeOps) id+            `inject` peers+      where+        routeOps =+            [ foldl inject (IpRoute.route ip_r bins.binIp (IpRoute.Route (IpRoute.RawNetwork n) h.host_iface Nothing)) (peers : pinOps)+            | n <- h.host_routes+            ]++    table :: Nft.Table+    table = Nft.Table "filter" Nft.Inet++    forwarding :: Op+    forwarding = case h.host_forwards of+        Nothing -> noop "no forwarding"+        Just subnet ->+            Sysctl.set sysctl_r bins.binSysctl (Sysctl.Setting "net.ipv4.ip_forward" "1")+                `inject` forwardChain+                `inject` natRule subnet++    ifaceQuoted = "\"" <> h.host_iface <> "\""++    forwardChain :: Op+    forwardChain =+        Nft.rule nft_r bins.binNft fchain (Nft.RawRule ["ct", "state", "established,related", "accept"])+            `inject` Nft.rule nft_r bins.binNft fchain (Nft.RawRule ["iifname", ifaceQuoted, "accept"])+      where+        fchain = Nft.baseChain "forward" table (Nft.BaseChainSpec Nft.FilterChain Nft.Forward 0 Nft.Drop)++    natRule subnet =+        Nft.rule+            nft_r+            bins.binNft+            (Nft.baseChain "postrouting" (Nft.Table "nat" Nft.Inet) (Nft.BaseChainSpec Nft.NatChain Nft.PostRouting 100 Nft.Accept))+            (Nft.RawRule ["ip", "saddr", subnet, "oifname", "!=", ifaceQuoted, "masquerade"])++    -- an input chain of its own (policy accept: only the mesh interface is+    -- restricted, ssh and everything else is untouched): established, then one+    -- accept per policy, then drop whatever else arrives over the mesh+    filtering :: Op+    filtering = case h.host_input_rules of+        [] | null h.host_peers -> noop "no mesh filter"+        rules ->+            let rs =+                    [Nft.RawRule ["iifname", ifaceQuoted, "ct", "state", "established,related", "accept"]]+                        <> [Nft.RawRule w | NftRuleSpec w <- rules]+                        <> [Nft.RawRule ["iifname", ifaceQuoted, "drop"]]+                -- `x inject y` puts y before x, so each rule injects its predecessor+                ops = Nft.rule nft_r bins.binNft inchain <$> rs+             in foldl1 (\prev next -> next `inject` prev) ops+      where+        inchain = Nft.baseChain ("mesh-" <> h.host_iface) (Nft.Table ("mesh_" <> h.host_iface) Nft.Inet) (Nft.BaseChainSpec Nft.FilterChain Nft.Input 0 Nft.Accept)+
src/SreBox/WireGuardVpn.hs view
@@ -94,9 +94,12 @@     privkeyTrack :: Track' FilePath     privkeyTrack = Track $ WG.privateKey bins.binWg +    -- Each peer is injected the interface's key and port, not only the+    -- grouping node: that one has no effect, and what is injected onto it+    -- alone is a sibling of the peers, not something they wait for.     wgSetup :: Op     wgSetup =-        op "wireguard-vpn-server-peers" (deps $ fmap addPeer spec.server_clients) id+        op "wireguard-vpn-server-peers" (deps $ fmap ((`inject` serverIface) . addPeer) spec.server_clients) id             `inject` serverIface      serverIface :: Op@@ -208,18 +211,7 @@     -- pin a host route to the server's own IP via whatever the kernel currently     -- uses (the physical uplink), so the tunnel's own traffic doesn't loop over itself     pinServerRoute :: Op-    pinServerRoute =-        ( op "wireguard-vpn-client-pin-server-route" (deps [justInstall bins.binIp]) $ \actions ->-            actions-                { help = "keeps the route to the VPN server itself off the tunnel"-                , ref = mkRef "wg-vpn-pin-server-route" spec.client_server_ip-                , up = do-                    (via, dev) <- IpRoute.discoverGatewayFor spec.client_server_ip-                    let cmd = IpRoute.ReplaceRoute (IpRoute.Route (IpRoute.RawNetwork (spec.client_server_ip <> "/32")) dev via)-                    Binary.untrackedExec IpRoute.ipcommand cmd "" (contramap (IpRoute.RunIp cmd) ip_r)-                }-        )-            `inject` serverPeer+    pinServerRoute = pinHostRoute ip_r bins spec.client_server_ip `inject` serverPeer      -- classic wg-quick "route all traffic" trick: two more-specific-than-default     -- routes over the VPN interface, leaving the real default route (and every@@ -229,3 +221,19 @@         IpRoute.route ip_r bins.binIp (IpRoute.Route (IpRoute.RawNetwork "128.0.0.0/1") spec.client_wg_iface Nothing)             `inject` IpRoute.route ip_r bins.binIp (IpRoute.Route (IpRoute.RawNetwork "0.0.0.0/1") spec.client_wg_iface Nothing)             `inject` pinServerRoute++{- | Keeps the route to one host off the tunnel: a @\/32@ via the gateway the+kernel currently uses for it. Shared with "SreBox.WireGuardMesh", whose exit+routers need the same pin. The address must be an IPv4 literal.+-}+pinHostRoute :: Reporter IpRoute.Report -> Binaries -> Text -> Op+pinHostRoute ip_r bins addr =+    op "wireguard-vpn-client-pin-server-route" (deps [justInstall bins.binIp]) $ \actions ->+        actions+            { help = "keeps the route to the VPN server itself off the tunnel"+            , ref = mkRef "wg-vpn-pin-server-route" addr+            , up = do+                (via, dev) <- IpRoute.discoverGatewayFor addr+                let cmd = IpRoute.ReplaceRoute (IpRoute.Route (IpRoute.RawNetwork (addr <> "/32")) dev via)+                Binary.untrackedExec IpRoute.ipcommand cmd "" (contramap (IpRoute.RunIp cmd) ip_r)+            }
test/Main.hs view
@@ -1,18 +1,40 @@ module Main (main) where  import qualified Test.AptRepositorySpec as AptRepositorySpec+import qualified Test.AmendNodeSpec as AmendNodeSpec+import qualified Test.BuiltinCardsSpec as BuiltinCardsSpec+import qualified Test.CardTemplateSpec as CardTemplateSpec import qualified Test.CheckSpec as CheckSpec+import qualified Test.HttpServerFlagSpec as HttpServerFlagSpec import qualified Test.ClientModelSpec as ClientModelSpec+import qualified Test.ClientModelStepSpec as ClientModelStepSpec import qualified Test.ConcurrentSpec as ConcurrentSpec+import qualified Test.BinaryOutputSpec as BinaryOutputSpec+import qualified Test.BinarySpec as BinarySpec import qualified Test.DaemonSpec as DaemonSpec+import qualified Test.DetachedSpec as DetachedSpec+import qualified Test.DagPerNodeSpec as DagPerNodeSpec+import qualified Test.DagStuckSpec as DagStuckSpec+import qualified Test.EdgeListSpec as EdgeListSpec import qualified Test.DagSpec as DagSpec+import qualified Test.GraphFixtureSpec as GraphFixtureSpec+import qualified Test.GraphScaleSpec as GraphScaleSpec+import qualified Test.ListedPathsSpec as ListedPathsSpec import qualified Test.DebianPackageSpec as DebianPackageSpec+import qualified Test.ClipCppSpec as ClipCppSpec import qualified Test.LlamaServerSpec as LlamaServerSpec import qualified Test.ServeApiSpec as ServeApiSpec+import qualified Test.PgTextSearchSpec as PgTextSearchSpec import qualified Test.PgVectorSpec as PgVectorSpec+import qualified Test.PgVectorDemoSpec as PgVectorDemoSpec import qualified Test.PlakarSpec as PlakarSpec+import qualified Test.WireGuardMeshSpec as WireGuardMeshSpec import qualified Test.WireGuardSpec as WireGuardSpec+import qualified Test.NetbirdSpec as NetbirdSpec+import qualified Test.PortMappingSpec as PortMappingSpec+import qualified Test.RsyncSpec as RsyncSpec import qualified Test.DebootstrapSpec as DebootstrapSpec+import qualified Test.DeferredSpec as DeferredSpec import qualified Test.DownTreeSpec as DownTreeSpec import qualified Test.FilesystemSpec as FilesystemSpec import qualified Test.FollowCacheSpec as FollowCacheSpec@@ -21,10 +43,12 @@ import qualified Test.FollowSignatureSpec as FollowSignatureSpec import qualified Test.FollowSpec as FollowSpec import qualified Test.GcpSpec as GcpSpec+import qualified Test.SecretDeliverySpec as SecretDeliverySpec import qualified Test.JWTSigningSpec as JWTSigningSpec import qualified Test.LedgerSpec as LedgerSpec import qualified Test.PodmanCommandSpec as PodmanCommandSpec import qualified Test.MigratorTemplateSpec as MigratorTemplateSpec+import qualified Test.PgBackRestSpec as PgBackRestSpec import qualified Test.PgBackupSpec as PgBackupSpec import qualified Test.PodmanSpec as PodmanSpec import qualified Test.PostgresBackupSpec as PostgresBackupSpec@@ -32,18 +56,27 @@ import qualified Test.PostgresReplicationSpec as PostgresReplicationSpec import qualified Test.PostgresClusterSpec as PostgresClusterSpec import qualified Test.PgBouncerSpec as PgBouncerSpec+import qualified Test.HaproxySpec as HaproxySpec import qualified Test.EtcdSpec as EtcdSpec+import qualified Test.PatroniSpec as PatroniSpec+import qualified Test.PatroniPreferredLeaderSpec as PatroniPreferredLeaderSpec+import qualified Test.PatroniRollingMaintenanceSpec as PatroniRollingMaintenanceSpec+import qualified Test.PatroniArchiveVmsSpec as PatroniArchiveVmsSpec import qualified Test.PatroniHarnessSpec as PatroniHarnessSpec import qualified Test.PgPairDemoSpec as PgPairDemoSpec import qualified Test.PostgresPairSpec as PostgresPairSpec+import qualified Test.PostgresPairPrereqsSpec as PostgresPairPrereqsSpec import qualified Test.PostgresSwitchoverSpec as PostgresSwitchoverSpec import qualified Test.PostgresTemplateSpec as PostgresTemplateSpec+import qualified Test.PostgresUserSpec as PostgresUserSpec import qualified Test.PostgresTlsSpec as PostgresTlsSpec import qualified Test.PostgrestCloudRunSpec as PostgrestCloudRunSpec import qualified Test.QemuResolveKernelSpec as QemuResolveKernelSpec import qualified Test.QemuShutdownSpec as QemuShutdownSpec import qualified Test.QemuSmokeSpec as QemuSmokeSpec import qualified Test.QuerySpec as QuerySpec+import qualified Test.ReadinessSpec as ReadinessSpec+import qualified Test.RemoteOutputSpec as RemoteOutputSpec import qualified Test.ReportJsonSpec as ReportJsonSpec import qualified Test.RewriteSpec as RewriteSpec import qualified Test.ServeEventsSpec as ServeEventsSpec@@ -53,10 +86,18 @@ import qualified Test.ServeSpec as ServeSpec import qualified Test.ServeTlsSpec as ServeTlsSpec import qualified Test.StatusSinkSpec as StatusSinkSpec+import qualified Test.QuadletSpec as QuadletSpec+import qualified Test.QuadletUserSpec as QuadletUserSpec+import qualified Test.SharedDirSpec as SharedDirSpec+import qualified Test.SystemdJobSpec as SystemdJobSpec import qualified Test.SystemdSpec as SystemdSpec+import qualified Test.UiTailTemplateSpec as UiTailTemplateSpec+import qualified Test.UpDownTextSpec as UpDownTextSpec import qualified Test.UpTreeSpec as UpTreeSpec+import qualified Test.UpkeepRetentionSpec as UpkeepRetentionSpec import qualified Test.UpkeepSpec as UpkeepSpec import qualified Test.WindowSpec as WindowSpec+import qualified Test.WrapperOrderingSpec as WrapperOrderingSpec  import Test.Tasty (DependencyType (..), TestTree, defaultMain, sequentialTestGroup, testGroup) @@ -88,7 +129,11 @@                 , -- these two shim PATH, which is process-global                   PostgresInitSpec.tests                 , PostgresTemplateSpec.sandboxTests+                , PostgresUserSpec.sandboxTests                 , MigratorTemplateSpec.tests+                , PgBackRestSpec.sandboxTests+                , PgTextSearchSpec.sandboxTests+                , PgVectorDemoSpec.sandboxTests                 , -- the rest of the VM specs: they share one bridge and a                   -- handful of fixed addresses, so two at once is two guests                   -- claiming one address.@@ -98,19 +143,47 @@                 , PgBackupSpec.tests                 , PgPairDemoSpec.tests                 , PatroniHarnessSpec.tests+                , PatroniArchiveVmsSpec.vmTests+                , -- the user's own systemd manager and podman+                  QuadletUserSpec.tests+                , SystemdJobSpec.userTests                 ]             , CheckSpec.tests+            , AmendNodeSpec.tests+            , CardTemplateSpec.tests+            , BuiltinCardsSpec.tests+            , HttpServerFlagSpec.tests             , ClientModelSpec.tests+            , ClientModelStepSpec.tests             , ConcurrentSpec.tests+            , BinarySpec.tests             , DaemonSpec.tests+            , DetachedSpec.tests+            , BinaryOutputSpec.tests+            , RemoteOutputSpec.tests             , DagSpec.tests+            , DagPerNodeSpec.tests+            , DagStuckSpec.tests+            , EdgeListSpec.tests+            , GraphFixtureSpec.tests+            , GraphScaleSpec.tests+            , ListedPathsSpec.tests             , AptRepositorySpec.tests             , DebianPackageSpec.tests+            , ClipCppSpec.tests             , LlamaServerSpec.tests             , ServeApiSpec.tests+            , UiTailTemplateSpec.tests             , PgVectorSpec.tests+            , PgVectorDemoSpec.tests+            , PgTextSearchSpec.tests             , PlakarSpec.tests             , WireGuardSpec.tests+            , WireGuardMeshSpec.tests+            , NetbirdSpec.tests+            , PortMappingSpec.tests+            , RsyncSpec.tests+            , DeferredSpec.tests             , DownTreeSpec.tests             , FilesystemSpec.tests             , FollowCacheSpec.tests@@ -119,6 +192,7 @@             , FollowSignatureSpec.tests             , FollowSpec.tests             , GcpSpec.tests+            , SecretDeliverySpec.tests             , JWTSigningSpec.tests             , LedgerSpec.tests             , PodmanCommandSpec.tests@@ -126,14 +200,23 @@             , PostgresBackupSpec.tests             , PostgresClusterSpec.tests             , PgBouncerSpec.tests+            , HaproxySpec.tests             , EtcdSpec.tests+            , PatroniSpec.tests+            , PatroniPreferredLeaderSpec.tests+            , PatroniRollingMaintenanceSpec.tests+            , PatroniArchiveVmsSpec.tests+            , PgBackRestSpec.tests             , PostgresPairSpec.tests+            , PostgresPairPrereqsSpec.tests             , PostgresTemplateSpec.tests+            , PostgresUserSpec.tests             , PostgresTlsSpec.tests             , PostgrestCloudRunSpec.tests             , QemuResolveKernelSpec.tests             , QemuShutdownSpec.tests             , QuerySpec.tests+            , ReadinessSpec.tests             , ReportJsonSpec.tests             , RewriteSpec.tests             , ServeEventsSpec.tests@@ -143,8 +226,14 @@             , ServeSpec.tests             , ServeTlsSpec.tests             , StatusSinkSpec.tests+            , QuadletSpec.tests+            , SharedDirSpec.tests+            , SystemdJobSpec.tests             , SystemdSpec.tests+            , UpDownTextSpec.tests             , UpTreeSpec.tests             , UpkeepSpec.tests+            , UpkeepRetentionSpec.tests             , WindowSpec.tests+            , WrapperOrderingSpec.tests             ]
+ test/Test/AmendNodeSpec.hs view
@@ -0,0 +1,48 @@+{- | Layer 0 coverage for 'amendNode': amending one node of an 'Op' leaves+its predecessors as they were, which an 'fmap' over the 'Op' does not.+-}+module Test.AmendNodeSpec (tests) where++import Data.Foldable (toList)+import Data.Text (Text)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertEqual, testCase)++import Salmon.Builtin.Extension (Extension (..), Help, Op, amendNode, deps, evalDeps, nodeps, op, realNoop)+import Salmon.Op.OpGraph (OpGraph (..))+import Salmon.Op.Ref (mkRef)++tests :: TestTree+tests =+    testGroup+        "Salmon.Builtin.Extension.amendNode"+        [ testCase "amends the node and not its predecessor" amendsOnlyTheNode+        , testCase "an fmap over the Op reaches the predecessor (the hazard)" fmapReachesPredecessor+        , testCase "an Actionless node is returned unchanged" actionlessUnchanged+        ]++predecessor :: Op+predecessor = op "pre" nodeps $ \x -> x{help = "pre", ref = mkRef "amend-test" ("pre" :: Text)}++root :: Op+root = op "root" (deps [predecessor]) $ \x -> x{help = "root", ref = mkRef "amend-test" ("root" :: Text)}++-- | Every node's help, the root first, as the evaluator materializes them.+helps :: Op -> [Help]+helps o = [x.help | n <- toList (evalDeps o), x <- toList n.node]++amended :: Extension -> Extension+amended x = x{help = "amended"}++amendsOnlyTheNode :: IO ()+amendsOnlyTheNode = do+    assertEqual "before" ["root", "pre"] (helps root)+    assertEqual "after" ["amended", "pre"] (helps (amendNode amended root))++fmapReachesPredecessor :: IO ()+fmapReachesPredecessor =+    assertEqual "fmap" ["amended", "amended"] (helps (fmap (fmap amended) root))++actionlessUnchanged :: IO ()+actionlessUnchanged =+    assertEqual "actionless" [] (helps (amendNode amended realNoop))
+ test/Test/BinaryOutputSpec.hs view
@@ -0,0 +1,276 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE OverloadedStrings #-}++{- | Layer 1 coverage for where a command's output goes+("Salmon.Builtin.Nodes.Binary"'s 'Binary.Routing') and for the per-node line+it rides on ("Salmon.Builtin.NodeLog"): real subprocesses, all of them+@\/bin\/sh@, none longer than a second.++What only a real process shows is the point of the feature: that a streamed+line reaches the listener /while the command is still running/ (the case+holds the command open on a file it waits for, and creates that file only+after the first line has arrived), that what is kept is bounded however much+is printed, and that a command which never reads its standard input, or is+routed to a file or to nothing, still ends with its own exit code.++The sink registry is process-wide and this suite runs its groups in parallel+in one process, so every assertion about what a sink saw filters by the+node's own 'Ref'.+-}+module Test.BinaryOutputSpec (tests) where++import Control.Concurrent (forkIO)+import Control.Concurrent.MVar (newEmptyMVar, putMVar, takeMVar)+import Control.Exception (try)+import qualified Data.ByteString as ByteString+import qualified Data.ByteString.Char8 as C8+import Data.Functor.Identity (runIdentity)+import Data.IORef (IORef, atomicModifyIORef', newIORef, readIORef)+import System.Directory (doesFileExist)+import System.Exit (ExitCode (..))+import System.FilePath ((</>))+import System.Process (proc)+import System.Timeout (timeout)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, assertEqual, assertFailure, testCase)++import qualified Salmon.Actions.UpDown as UpDown+import Salmon.Builtin.Extension (Extension (..), Op, Track', ignoreTrack, nodeps, op)+import qualified Salmon.Builtin.NodeLog as NodeLog+import Salmon.Builtin.Nodes.Binary (Binary, Command (..), Routing (..), Sink (..))+import qualified Salmon.Builtin.Nodes.Binary as Binary+import Salmon.Op.Ref (Ref, mkRef)+import Salmon.Reporter (ReporterM (..), silent)+import Test.Harness (withTempDir)++tests :: TestTree+tests =+    testGroup+        "Salmon.Builtin.Nodes.Binary: where output goes"+        [ testGroup+            "the tail a stream keeps"+            [ testCase "everything, when it fits" tailFits+            , testCase "whole trailing lines, when it does not" tailDropsFromTheFront+            , testCase "the end of a line longer than the bound" tailCutsALongLine+            ]+        , testCase "captured is what it was: both streams whole, nothing streamed" capturedUnchanged+        , testCase "a streamed line arrives while the command is still running" streamsWhileRunning+        , testCase "a stream keeps a bounded tail of megabytes, and reports every line" boundedTail+        , testCase "discarded output is not kept, and the exit code is" discardedKeepsExitCode+        , testCase "output appended to a file lands there, both streams, across runs" appendsToFile+        , testCase "each stream has its own sink" mixedSinks+        , testCase "a command that never reads its standard input is not a failure" unreadStdin+        , testCase "a failing streamed command throws with the tail it kept" failureCarriesTail+        , testCase "a node's streamed lines are node log lines under its ref, not reports" nodeLinesGoToTheLog+        , testCase "a captured node says nothing to the log" capturedNodeIsSilent+        , testCase "a node's own message is split into lines; a sink stops hearing when it leaves" sayAndLeave+        ]++-------------------------------------------------------------------------------++within :: Int -> IO a -> IO a+within secs act = do+    r <- timeout (secs * 1000000) act+    maybe (assertFailure ("timed out after " <> show secs <> "s")) pure r++sh :: Command "sh" String+sh = Command $ \script -> proc "/bin/sh" ["-c", script]++shTrack :: Track' (Binary "sh")+shTrack = ignoreTrack++-- | Collects what a callback or reporter is handed, in order.+collector :: IO (a -> IO (), IO [a])+collector = do+    cell <- newIORef []+    pure (\x -> atomicModifyIORef' cell (\xs -> (x : xs, ())), reverse <$> readIORef cell)++-------------------------------------------------------------------------------++tailFits :: IO ()+tailFits = assertEqual "" "a\nbb\n" (Binary.tailOf 100 ["a", "bb"])++tailDropsFromTheFront :: IO ()+tailDropsFromTheFront = do+    -- "three\n" is 6 bytes, "four\n" is 5: 11 fit in 12, "two\n" would not+    assertEqual "" "three\nfour\n" (Binary.tailOf 12 ["one", "two", "three", "four"])+    assertEqual "nothing but the last" "four\n" (Binary.tailOf 5 ["one", "two", "three", "four"])++tailCutsALongLine :: IO ()+tailCutsALongLine = do+    let kept = Binary.tailOf 8 ["short", C8.replicate 100 'x' <> "END"]+    assertBool "within the bound" (ByteString.length kept <= 8)+    assertBool "the end of the line, not its start" ("END\n" `ByteString.isSuffixOf` kept)++capturedUnchanged :: IO ()+capturedUnchanged = within 10 $ do+    (onLine, seen) <- collector+    r <- Binary.runRouted Binary.captured (\c l -> onLine (c, l)) (proc "/bin/sh" ["-c", "echo out; echo err >&2; exit 3"]) ""+    assertEqual "" (ExitFailure 3, "out\n", "err\n") r+    assertEqual "nothing streamed" [] =<< seen++streamsWhileRunning :: IO ()+streamsWhileRunning = within 20 . withTempDir $ \dir -> do+    let gate = dir </> "gate"+        script = "echo first; while [ ! -e '" <> gate <> "' ]; do sleep 0.05; done; echo second"+    arrived <- newEmptyMVar+    ended <- newEmptyMVar+    _ <- forkIO (putMVar ended =<< (try (Binary.runRouted Binary.streamed (\_ l -> putMVar arrived l) (proc "/bin/sh" ["-c", script]) "") :: IO (Either IOError (ExitCode, ByteString.ByteString, ByteString.ByteString))))+    first <- takeMVar arrived+    assertEqual "" "first" first+    -- the command cannot have ended: what lets it end does not exist yet+    opened <- doesFileExist gate+    assertBool "the gate was still shut when the first line arrived" (not opened)+    writeFile gate ""+    second <- takeMVar arrived+    assertEqual "" "second" second+    r <- takeMVar ended+    assertEqual "and the report still has both" (Right (ExitSuccess, "first\nsecond\n", "")) r++boundedTail :: IO ()+boundedTail = within 60 $ do+    count <- newIORef (0 :: Int)+    -- 200000 lines of 7 bytes: 1.4MB printed, 64 bytes kept+    (code, out, err) <-+        Binary.runRouted+            (Binary.streamedKeeping 64)+            (\_ _ -> atomicModifyIORef' count (\n -> (n + 1, ())))+            (proc "/bin/sh" ["-c", "i=100000; while [ $i -lt 300000 ]; do echo $i; i=$((i+1)); done"])+            ""+    assertEqual "" ExitSuccess code+    assertEqual "every line was reported" 200000 =<< readIORef count+    assertBool ("kept " <> show (ByteString.length out) <> " bytes") (ByteString.length out <= 64)+    assertBool "and it is the end of the stream" ("299999\n" `ByteString.isSuffixOf` out)+    assertEqual "" "" err++discardedKeepsExitCode :: IO ()+discardedKeepsExitCode = within 10 $ do+    (onLine, seen) <- collector+    r <- Binary.runRouted Binary.discarded (\c l -> onLine (c, l)) (proc "/bin/sh" ["-c", "echo out; echo err >&2; exit 4"]) ""+    assertEqual "" (ExitFailure 4, "", "") r+    assertEqual "nothing streamed" [] =<< seen++appendsToFile :: IO ()+appendsToFile = within 10 . withTempDir $ \dir -> do+    let logfile = dir </> "command.log"+        run script = Binary.runRouted (Binary.appendedTo logfile) (\_ _ -> pure ()) (proc "/bin/sh" ["-c", script]) ""+    r1 <- run "echo one; echo two >&2"+    assertEqual "nothing kept for the report" (ExitSuccess, "", "") r1+    _ <- run "echo three"+    written <- C8.readFile logfile+    assertEqual "both streams, then the second run after the first" "one\ntwo\nthree\n" written++mixedSinks :: IO ()+mixedSinks = within 10 $ do+    (onLine, seen) <- collector+    r <-+        Binary.runRouted+            (Routing{stdoutTo = Capture, stderrTo = Stream 1024})+            (\c l -> onLine (c, l))+            (proc "/bin/sh" ["-c", "echo result; echo progress >&2"])+            ""+    assertEqual "" (ExitSuccess, "result\n", "progress\n") r+    assertEqual "only the streamed one was reported" [(NodeLog.Stderr, "progress")] =<< seen++unreadStdin :: IO ()+unreadStdin = within 20 $ do+    -- far more than a pipe holds, to a command that reads none of it+    (code, out, _) <- Binary.runRouted Binary.streamed (\_ _ -> pure ()) (proc "/bin/sh" ["-c", "echo done"]) (C8.replicate (4 * 1024 * 1024) 'x')+    assertEqual "" (ExitSuccess, "done\n") (code, out)+    -- and one that does read it gets it+    (_, counted, _) <- Binary.runRouted Binary.streamed (\_ _ -> pure ()) (proc "/bin/sh" ["-c", "wc -c | tr -d ' '"]) (C8.replicate 100000 'x')+    assertEqual "" "100000\n" counted++failureCarriesTail :: IO ()+failureCarriesTail = within 10 $ do+    (onReport, seen) <- collector+    r <- try (Binary.untrackedExecWith Binary.streamed sh "echo building; echo broken >&2; exit 2" "" (ReporterM onReport))+    case r of+        Right () -> assertFailure "a non-zero exit did not throw"+        Left (Binary.CommandFailed _ code out err) ->+            assertEqual "" (2, "building\n", "broken\n") (code, out, err)+    reports <- seen+    assertEqual+        "start, the two lines, stop"+        ["start", "line", "line", "stopped"]+        (map shape reports)+  where+    shape rep = case rep of+        Binary.CommandStart{} -> "start" :: String+        Binary.CommandOutput{} -> "line"+        Binary.CommandStopped{} -> "stopped"+        Binary.Requested{} -> "requested"++-------------------------------------------------------------------------------++-- | A node running one shell script, with its output routed as asked.+scriptNode :: Routing -> Ref -> String -> (Binary.Report -> IO ()) -> Op+scriptNode routing r script onReport =+    Binary.withBinaryWith routing shTrack sh script $ \run ->+        op "script" nodeps $ \actions ->+            actions+                { help = "runs a script"+                , ref = r+                , up = run (ReporterM onReport)+                }++runUp :: Op -> IO Bool+runUp = UpDown.upTree silent (pure . runIdentity)++-- | The lines a sink saw about one node.+about :: Ref -> [NodeLog.Line] -> [(NodeLog.Channel, String)]+about r ls = [(l.lineChannel, show l.lineText) | l <- ls, l.lineRef == r]++nodeLinesGoToTheLog :: IO ()+nodeLinesGoToTheLog = within 20 $ do+    let r = mkRef "binary-output-spec" ("streamed" :: String)+    (onLine, lines') <- collector+    (onReport, reports) <- collector+    ok <- NodeLog.withSink (ReporterM onLine) (runUp (scriptNode Binary.streamed r "echo compiling; printf 'carriage\\r\\n'; echo careful >&2" onReport))+    assertBool "the node came up" ok+    seen <- about r <$> lines'+    assertEqual+        "stdout in order, under the node's ref, without line terminators"+        [show ("compiling" :: String), show ("carriage" :: String)]+        [t | (NodeLog.Stdout, t) <- seen]+    assertEqual "stderr on its own channel" [show ("careful" :: String)] [t | (NodeLog.Stderr, t) <- seen]+    reps <- reports+    assertEqual+        "the recipe's reporter got the start and the stop, and no line twice"+        ["start", "stopped"]+        (map shape reps)+    assertBool "and the stop carries what was kept" (any Binary.isCommandSuccessful reps)+  where+    shape rep = case rep of+        Binary.Requested _ Binary.CommandStart{} -> "start" :: String+        Binary.Requested _ Binary.CommandStopped{} -> "stopped"+        Binary.Requested _ Binary.CommandOutput{} -> "line"+        _ -> "other"++capturedNodeIsSilent :: IO ()+capturedNodeIsSilent = within 20 $ do+    let r = mkRef "binary-output-spec" ("captured" :: String)+    (onLine, lines') <- collector+    (onReport, reports) <- collector+    ok <- NodeLog.withSink (ReporterM onLine) (runUp (scriptNode Binary.captured r "echo a-secret-token" onReport))+    assertBool "the node came up" ok+    assertEqual "nothing was said" [] . about r =<< lines'+    reps <- reports+    assertEqual+        "and the report is the one it always was"+        ["a-secret-token\n"]+        [out | Binary.Requested _ (Binary.CommandStopped _ ExitSuccess out _) <- reps]++sayAndLeave :: IO ()+sayAndLeave = do+    let r = mkRef "binary-output-spec" ("say" :: String)+    (onLine, lines') <- collector+    NodeLog.withSink (ReporterM onLine) $ do+        NodeLog.say r "waiting for ssh"+        NodeLog.say r "step 1 of 2\nstep 2 of 2"+    NodeLog.say r "nobody is listening"+    seen <- about r <$> lines'+    assertEqual+        ""+        [(NodeLog.Message, show t) | t <- ["waiting for ssh", "step 1 of 2", "step 2 of 2" :: String]]+        seen
+ test/Test/BinarySpec.hs view
@@ -0,0 +1,96 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE OverloadedRecordDot #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE TypeApplications #-}++{- | Layer 1 coverage for what "Salmon.Builtin.Nodes.Binary" promises about a+child's standard input: real subprocesses, asked what their descriptor 0 is.++The rule under test is that no process started for a node inherits the+standard input of the pass that runs it — under @run serve@ that is the+command channel, and a child that prompts would block the pass and read the+loop's commands. The children here answer through @\/proc\/self\/fd\/0@+rather than by reading, so the cases hold whatever this test process's own+standard input happens to be.+-}+module Test.BinarySpec (tests) where++import Control.Exception (try)+import qualified Data.ByteString.Char8 as C8+import System.Exit (ExitCode (..))+import System.FilePath ((</>))+import System.IO (Handle, IOMode (..), hClose, openFile, withFile)+import System.IO.Temp (withSystemTempDirectory)+import System.Process (CreateProcess (..), StdStream (..), createProcess, proc, waitForProcess)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertEqual, assertFailure, testCase)++import Salmon.Builtin.Nodes.Binary (CommandFailed (..), CommandIO (..), detachedStdin, execDetached, untrackedExecIO)++tests :: TestTree+tests =+    testGroup+        "Salmon.Builtin.Nodes.Binary"+        [ testCase "a CommandIO naming no standard input reads /dev/null, not ours" ioCommandIsDetached+        , testCase "a CommandIO naming its own standard input keeps it" ioCommandKeepsItsInput+        , testCase "detachedStdin leaves the caller's CreateProcess otherwise as given" detachedOnlyTouchesInherit+        , testCase "execDetached hands back stdout and gives the child an empty input" execDetachedReads+        , testCase "execDetached throws on a non-zero exit" execDetachedThrows+        ]++-- | What the child's descriptor 0 is, as the kernel names it.+whatIsStdin :: CreateProcess+whatIsStdin = proc "readlink" ["/proc/self/fd/0"]++-- | Run a 'CommandIO' to completion with its stdout sent to a file; the file's contents.+capturing :: (Handle -> IO CreateProcess) -> IO String+capturing mk =+    withSystemTempDirectory "salmon-binary-spec" $ \dir -> do+        let outPath = dir </> "out"+        out <- openFile outPath WriteMode+        (_, _, _, ph) <- untrackedExecIO (CommandIO @"readlink" (\() h -> mk h)) () out+        code <- waitForProcess ph+        hClose out+        assertEqual "the child exited cleanly" ExitSuccess code+        s <- readFile outPath+        length s `seq` pure s++ioCommandIsDetached :: IO ()+ioCommandIsDetached = do+    s <- capturing (\out -> pure whatIsStdin{std_out = UseHandle out})+    assertEqual "descriptor 0 of the child" "/dev/null\n" s++ioCommandKeepsItsInput :: IO ()+ioCommandKeepsItsInput =+    withSystemTempDirectory "salmon-binary-spec" $ \dir -> do+        let inPath = dir </> "in"+        writeFile inPath "fed by the node\n"+        s <- capturing $ \out -> do+            input <- openFile inPath ReadMode+            pure (proc "cat" []){std_in = UseHandle input, std_out = UseHandle out}+        assertEqual "the child read what the node gave it" "fed by the node\n" s++detachedOnlyTouchesInherit :: IO ()+detachedOnlyTouchesInherit = do+    piped <- detachedStdin (proc "true" []){std_in = CreatePipe} (pure . std_in)+    assertEqual "an asked-for pipe is still a pipe" "CreatePipe" (show piped)+    none <- detachedStdin (proc "true" []){std_in = NoStream} (pure . std_in)+    assertEqual "an explicit NoStream is left alone" "NoStream" (show none)+    withFile "/dev/null" WriteMode $ \sink -> do+        (_, _, _, ph) <- detachedStdin (proc "true" []){std_out = UseHandle sink} createProcess+        code <- waitForProcess ph+        assertEqual "the process still runs" ExitSuccess code++execDetachedReads :: IO ()+execDetachedReads = do+    out <- execDetached (proc "sh" ["-c", "if read -r line; then echo \"read: $line\"; else echo eof; fi"])+    assertEqual "a prompt reads end-of-file at once" "eof\n" (C8.unpack out)++execDetachedThrows :: IO ()+execDetachedThrows = do+    r <- try @CommandFailed (execDetached (proc "sh" ["-c", "echo no >&2; exit 3"]))+    case r of+        Left e -> do+            assertEqual "exit code" 3 e.commandFailed_exitCode+            assertEqual "stderr is kept" "no\n" (C8.unpack e.commandFailed_stderr)+        Right out -> assertFailure ("expected CommandFailed, got " <> show out)
+ test/Test/BuiltinCardsSpec.hs view
@@ -0,0 +1,425 @@+{- | Layer 0 coverage for the builtin card templates ("Salmon.Builtin.Cards",+feature 91138a85; "Salmon.Builtin.Cards.Gcp" and "Salmon.Builtin.Cards.Postgres",+feature d8a79380; "Salmon.Builtin.Cards.Keys" and "Salmon.Builtin.Cards.Patroni",+feature e593a79b).++* Attaching a card to a builtin changes nothing it renders: the same+  representative, @ref@, @help@ and @notes@, and its dependencies untouched.+* Each card, rendered here, parses and draws under the card contract with+  the vendored tramaj-js (@salmon-ops\/ui-test\/builtin-cards.test.mjs@, run+  under @node@, skipped loudly without it): the right sentence per verdict,+  its facts, no error on random contexts.+* 'Cards.tramajString' round-trips arbitrary text through tramaj-js.+* A card never carries what the builtin keeps out of its public fields: a+  password, a file's contents, an instance's metadata.+* A key or certificate card shows paths, never a key, a fingerprint or a+  digest; a pgBackRest card names an S3 archive by its kind only.+* A card's links are https console URLs built from the declaration's public+  names only, percent-encoded ('Cards.percentEncode').+* Every card links to its builtin's page on the docs site+  ('Cards.docsLink'), and that page has a source under+  @resources\/builtins\/@ (feature 42358f5a), and the link's base is the+  kitchen-sink site's published URL from @website\/src\/kitchen-sink.json@+  (feature 6b166715).+-}+module Test.BuiltinCardsSpec (tests) where++import Control.Monad (forM_, unless)+import qualified Data.ByteString as ByteString+import Data.Char (chr, isAsciiLower, isAsciiUpper, isDigit, isHexDigit)+import qualified Data.Map as Map+import Data.Aeson (object, (.=))+import qualified Data.Aeson as Aeson+import qualified Data.Aeson.KeyMap as KeyMap+import qualified Data.ByteString.Lazy as LByteString+import Data.Foldable (toList)+import Data.Text (Text)+import qualified Data.Text as Text+import qualified Data.Text.Encoding as Text+import Numeric (readHex)+import System.Directory (doesFileExist)+import System.Exit (ExitCode (..))+import System.FilePath ((</>))+import System.Process (proc, readCreateProcessWithExitCode)+import Test.QuickCheck (Gen, arbitrary, elements, forAll, frequency, generate, listOf, vectorOf, (===))+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, assertEqual, assertFailure, testCase)+import Test.Tasty.QuickCheck (testProperty)++import qualified Salmon.Builtin.Cards as Cards+import qualified Salmon.Builtin.Cards.Gcp as GcpCards+import qualified Salmon.Builtin.Cards.Keys as KeyCards+import qualified Salmon.Builtin.Cards.Patroni as HaCards+import qualified Salmon.Builtin.Cards.Postgres as PgCards+import Salmon.Builtin.Extension (Extension (..), Op, evalDeps, ignoreTrack)+import qualified Salmon.Builtin.Nodes.Certificates as Certs+import qualified Salmon.Builtin.Nodes.Filesystem as FS+import qualified Salmon.Builtin.Nodes.Gcp.CloudDns as Dns+import qualified Salmon.Builtin.Nodes.Gcp.Compute as Compute+import qualified Salmon.Builtin.Nodes.Gcp.Core as Core+import qualified Salmon.Builtin.Nodes.Gcp.LoadBalancing as Lb+import qualified Salmon.Builtin.Nodes.Gcp.SecretManager as Secrets+import qualified Salmon.Builtin.Nodes.Gcp.SshAccess as SshAccess+import qualified Salmon.Builtin.Nodes.Gcp.Storage as Storage+import qualified Salmon.Builtin.Nodes.Haproxy as Haproxy+import qualified Salmon.Builtin.Nodes.Keys as Keys+import qualified Salmon.Builtin.Nodes.Patroni as Patroni+import Salmon.Builtin.Nodes.Patroni.PreferredLeader (PreferredLeader (..))+import qualified Salmon.Builtin.Nodes.Patroni.PreferredLeader as PreferredLeader+import qualified Salmon.Builtin.Nodes.PgBackRest as PgBackRest+import qualified Salmon.Builtin.Nodes.Ssh as Ssh+import Salmon.Builtin.Nodes.SecretDelivery (Placement (..))+import qualified Salmon.Builtin.Nodes.Postgres as Postgres+import qualified Salmon.Builtin.Nodes.Systemd as Systemd+import qualified Salmon.Builtin.Nodes.WireGuard as WireGuard+import Salmon.Op.Actions (Act (..), Actions (..))+import Salmon.Op.CardTemplate (cardTemplateOf)+import qualified Salmon.Op.Dag as Dag+import Salmon.Op.OpGraph (OpGraph (..))+import Salmon.Op.Ref (Ref)+import Salmon.Reporter (silent)+import Test.Harness (requireExecutable, withTempDir)++-- | A builtin and its card.+data Case = Case+    { caseName :: Text+    , casePlain :: Op+    , caseCard :: Cards.Card+    }++cases :: [Case]+cases =+    [ Case "file-contents" (FS.filecontents (FS.FileContents filePath ("not on the card" :: Text))) (Cards.fileContentsCard filePath)+    , Case "directory" (FS.dir (FS.Directory "/var/lib/demo")) (Cards.directoryCard "/var/lib/demo")+    , Case "systemd-service" (Systemd.systemdService silent ignoreTrack ignoreTrack unitCfg) (Cards.systemdServiceCard unitCfg)+    , Case "pg-database" (Postgres.database silent ignoreTrack ignoreTrack 5433 (Postgres.Database "appdb")) (Cards.pgDatabaseCard 5433 (Postgres.Database "appdb"))+    ]+        <> gcpCases+        <> pgCases+        <> keyCases+        <> haCases+  where+    filePath = "/etc/demo/app.conf"++-- | What no card may show: the declarations below carry these, in places the builtins keep out of their public fields.+secretWords :: [Text]+secretWords = [pgPassword, "metadata-value-not-on-card", "/tmp/w/startup-script.sh", "s3-bucket-not-on-card", "s3-endpoint-not-on-card"]++pgPassword :: Text+pgPassword = "pg-password-not-on-card"++project :: Core.Project+project = Core.Project "demo-proj"++gcpCases :: [Case]+gcpCases =+    [ Case "gcp-instance" (Compute.gceInstance silent ignoreTrack demoInstance) (GcpCards.instanceCard demoInstance)+    , Case "gcp-address" (Compute.address silent ignoreTrack addr) (GcpCards.addressCard addr)+    , Case "gcp-address-internal" (Compute.address silent ignoreTrack addrIn) (GcpCards.addressCard addrIn)+    , Case "gcp-firewall-rule" (Compute.firewallRule silent ignoreTrack fw) (GcpCards.firewallRuleCard fw)+    , Case "gcp-subnet" (Compute.subnet silent ignoreTrack net) (GcpCards.subnetCard net)+    , Case "gcp-dns-zone" (Dns.managedZone silent ignoreTrack zone) (GcpCards.managedZoneCard zone)+    , Case "gcp-dns-record" (Dns.recordSet silent ignoreTrack rs) (GcpCards.recordSetCard False rs)+    , Case "gcp-dns-record-resolved" (Dns.resolvedRecordSet silent ignoreTrack rs (pure Nothing)) (GcpCards.recordSetCard True rs)+    , Case "gcp-bucket" (Storage.bucket silent ignoreTrack bkt) (GcpCards.bucketCard bkt)+    , Case "gcp-secret" (Secrets.secret silent ignoreTrack sec) (GcpCards.secretCard sec)+    , Case "gcp-secret-file" (Secrets.secretFile silent ignoreTrack secFile) (GcpCards.secretFileCard secFile)+    , Case "gcp-application-lb" (Lb.applicationLoadBalancer silent ignoreTrack alb) (GcpCards.applicationLoadBalancerCard alb)+    ]+  where+    addr = Compute.Address "demo-ip" project (Core.Region "europe-west1") Compute.ExternalAddress+    addrIn = Compute.Address "demo-ip-in" project (Core.Region "europe-west1") (Compute.InternalAddress "demo-subnet" (Just "10.0.0.5"))+    fw = Compute.FirewallRule "demo-ssh" project "default" "tcp:22" ["203.0.113.0/24"] ["demo-ssh"]+    net = Compute.Subnet "demo-subnet" project (Core.Region "europe-west1") "default" "10.0.0.0/24" Compute.PrivateSubnet+    zone = Dns.ManagedZone "demo-zone" project "example.org" "a demo zone"+    rs = Dns.RecordSet zone "www.example.org" Dns.A 300 ["203.0.113.7"]+    bkt = Storage.Bucket "demo-bucket" project (Core.Region "europe-west1") True+    sec = Secrets.Secret "demo-secret" project "automatic"+    secFile = Secrets.SecretFile project "demo-secret" "latest" (Placement "/etc/demo/token" "root" "demo" "0640")+    alb = Lb.httpLoadBalancer "demo-lb" project (Core.Region "europe-west1") [Lb.InstanceGroupBackend "demo-group" (Lb.InstanceGroupZone "europe-west1-b") [8080]] Nothing++demoInstance :: Compute.Instance+demoInstance =+    Compute.Instance+        { Compute.instanceName = "demo-vm"+        , Compute.instanceProject = project+        , Compute.instanceZone = Core.Zone "europe-west1-b"+        , Compute.instanceMachineType = Compute.E2Medium+        , Compute.instanceBootDisk = Compute.BootDisk 10 Nothing (Just "ubuntu-2404-lts-amd64") (Just "ubuntu-os-cloud")+        , Compute.instanceNetwork = "default"+        , Compute.instanceSubnet = "default"+        , Compute.instanceServiceAccount = Nothing+        , Compute.instanceMetadata = Map.fromList [("some-key", "metadata-value-not-on-card")]+        , Compute.instanceMetadataFiles = Map.fromList [("startup-script", "/tmp/w/startup-script.sh")]+        , Compute.instanceExternalAddress = Compute.ReservedExternal "demo-ip"+        , Compute.instanceInternalAddress = Compute.EphemeralInternal+        , Compute.instanceTags = ["demo-ssh"]+        , Compute.instancePower = Compute.PoweredOn+        , Compute.instanceScopes = Compute.DefaultScopes+        , Compute.instanceMachineTypeChange = Compute.RefuseMachineTypeChange+        , Compute.instanceRunLimit = Compute.NoRunLimit+        }++pgCases :: [Case]+pgCases =+    [ Case "pg-user" (Postgres.user silent ignoreTrack ignoreTrack 5433 app (Postgres.Password pgPassword)) (PgCards.userCard 5433 app)+    , Case "pg-user-passfile" (Postgres.userPassFile silent ignoreTrack ignoreTrack 5433 (FS.PreExisting "/run/secrets/app.pass") app) (PgCards.userPassFileCard 5433 "/run/secrets/app.pass" app)+    , Case "pg-replication-user" (Postgres.replicationUser silent ignoreTrack ignoreTrack 5433 repl (Postgres.Password pgPassword)) (PgCards.replicationUserCard 5433 repl)+    , Case "pg-replication-slot" (Postgres.replicationSlot silent ignoreTrack 5433 "standby_1") (PgCards.replicationSlotCard 5433 "standby_1")+    , Case "pg-hba-line" (Postgres.hbaLine silent ignoreTrack "main" hba) (PgCards.hbaLineCard "main" hba)+    , Case "pg-alter-system" (Postgres.alterSystemSet silent ignoreTrack 5433 "wal_level" "replica") (PgCards.alterSystemCard 5433 "wal_level" "replica")+    , Case "pg-server-tls" (Postgres.serverTls silent ignoreTrack 5433 tls) (PgCards.serverTlsCard 5433 tls)+    ]+  where+    app = Postgres.User "app"+    repl = Postgres.User "replicator"+    hba = "hostssl all app 10.0.0.0/24 scram-sha-256"+    tls = Postgres.ServerTls "/etc/demo/tls/server.crt" "/etc/demo/tls/server.key" "/etc/demo/tls/ca.crt"++keyCases :: [Case]+keyCases =+    [ Case "ssh-key" (Keys.sshKey silent ignoreTrack sshPair) (KeyCards.sshKeyCard sshPair)+    , Case "ssh-ca-sign" (Keys.signKey silent ignoreTrack sshCa kid principals sshPair) (KeyCards.sshCaSignCard sshCa kid principals sshPair)+    , Case "ssh-available" (SshAccess.sshAvailable silent endpoint) (KeyCards.sshAvailableCard endpoint)+    , Case "gcp-metadata-ssh-ca" (SshAccess.installMetadataCaKey silent ignoreTrack metaCa) (KeyCards.metadataSshCaCard metaCa)+    , Case "tls-key" (Certs.tlsKey silent ignoreTrack tlsK) (KeyCards.tlsKeyCard tlsK)+    , Case "tls-csr" (Certs.signingRequest silent ignoreTrack csr) (KeyCards.signingRequestCard csr)+    , Case "tls-self-signed" (Certs.selfSign silent ignoreTrack selfS) (KeyCards.selfSignedCard selfS)+    , Case "tls-ca" (Certs.certificateAuthority silent ignoreTrack ca) (KeyCards.certificateAuthorityCard ca)+    , Case "tls-ca-signed" (Certs.caSign silent ignoreTrack ignoreTrack caS) (KeyCards.caSignedCard caS)+    , Case "jwk-key" (Keys.jwkKey jwk) (KeyCards.jwkKeyCard jwk)+    , Case "wg-private-key" (WireGuard.privateKey ignoreTrack "/etc/wireguard/wg0.key") (KeyCards.wgPrivateKeyCard "/etc/wireguard/wg0.key")+    , Case "wg-public-key" (WireGuard.publicKey ignoreTrack ignoreTrack "/etc/wireguard/wg0.key" "/etc/wireguard/wg0.pub") (KeyCards.wgPublicKeyCard "/etc/wireguard/wg0.key" "/etc/wireguard/wg0.pub")+    ]+  where+    sshPair = Keys.SSHKeyPair Keys.ED25519 "/home/demo/.ssh" "id_demo"+    sshCa = Keys.SSHCertificateAuthority (Keys.SSHKeyPair Keys.ED25519 "/etc/demo/ssh-ca" "ca")+    kid = Keys.KeyIdentifier "demo-login"+    principals = [Keys.Principal "root", Keys.Principal "demo"]+    endpoint = SshAccess.SshEndpoint (Just "root") "203.0.113.7" 22 Ssh.noClientOpts+    metaCa = SshAccess.MetadataSshCa project "/etc/demo/ssh-ca/ca.pub"+    tlsK = Certs.Key Certs.RSA2048 "/etc/demo/tls" "server.key"+    csr = Certs.SigningRequest (Certs.Domain "db.example.org") tlsK "/etc/demo/tls" "server.csr"+    selfS = Certs.SelfSigned "/etc/demo/tls/server.pem" csr+    ca = Certs.CertificateAuthority (Certs.Key Certs.RSA4096 "/etc/demo/ca" "ca.key") "/etc/demo/ca/ca.crt" (Certs.Domain "demo CA") 3650+    caS = Certs.CaSigned "/etc/demo/tls/app.crt" (Certs.SigningRequest (Certs.Domain "app") tlsK "/etc/demo/tls" "app.csr") ca 365+    jwk = Keys.JWKKeyPair Keys.ED25519 "/etc/demo/jwk" "signing.jwk"++haCases :: [Case]+haCases =+    [ Case "patroni-member" (Patroni.patroniMember silent ignoreTrack ignoreTrack patroniCfg) (HaCards.patroniMemberCard patroniCfg)+    , Case "patroni-preferred-leader" (PreferredLeader.preferredLeader preferred) (HaCards.preferredLeaderCard preferred)+    , Case "pgbackrest-config" (PgBackRest.configuration ignoreTrack pbrS3) (HaCards.pgBackRestConfigurationCard pbrS3)+    , Case "pgbackrest-config-posix" (PgBackRest.configuration ignoreTrack pbr) (HaCards.pgBackRestConfigurationCard pbr)+    , Case "pgbackrest-stanza" (PgBackRest.stanza silent ignoreTrack pbrS3) (HaCards.pgBackRestStanzaCard pbrS3)+    , Case "pgbackrest-first-backup" (PgBackRest.firstBackup silent ignoreTrack pbrS3) (HaCards.pgBackRestFirstBackupCard pbrS3)+    , Case "pgbackrest-restore" (PgBackRest.restoredCluster silent ignoreTrack pbr PgBackRest.Latest) (HaCards.pgBackRestRestoreCard pbr PgBackRest.Latest)+    , Case "haproxy" (Haproxy.setup silent ignoreTrack ignoreTrack router) (HaCards.haproxyCard router)+    ]+  where+    preferred = PreferredLeader "demo" "n1" "10.0.0.1:8008" (Just "/etc/patroni/rest.creds") 60+    pbr =+        PgBackRest.PgBackRestConfig+            { PgBackRest.pbr_stanza = "demo"+            , PgBackRest.pbr_pg_path = "/var/lib/postgresql/16/main"+            , PgBackRest.pbr_pg_port = 5432+            , PgBackRest.pbr_repo = PgBackRest.PosixRepo "/srv/archive"+            , PgBackRest.pbr_retention_full = 2+            , PgBackRest.pbr_global = []+            , PgBackRest.pbr_stanza_options = []+            , PgBackRest.pbr_config_file = "/etc/pgbackrest/pgbackrest.conf"+            , PgBackRest.pbr_secrets_file = Nothing+            , PgBackRest.pbr_log_path = "/var/log/pgbackrest"+            , PgBackRest.pbr_user = "postgres"+            , PgBackRest.pbr_run_as = Nothing+            , PgBackRest.pbr_binary = "/usr/bin/pgbackrest"+            }+    pbrS3 =+        pbr+            { PgBackRest.pbr_repo = PgBackRest.S3Repo "s3-bucket-not-on-card" "s3-endpoint-not-on-card" "eu" "/pgbackrest" True+            , PgBackRest.pbr_secrets_file = Just "/etc/pgbackrest/conf.d/secrets.conf"+            }+    router = Haproxy.patroniRouter [Haproxy.Member "n1" "10.0.0.1" 5432 8008, Haproxy.Member "n2" "10.0.0.2" 5432 8008]++patroniCfg :: Patroni.PatroniConfig+patroniCfg =+    Patroni.PatroniConfig+        { Patroni.pat_scope = "demo"+        , Patroni.pat_namespace = "/service/"+        , Patroni.pat_name = "n2"+        , Patroni.pat_etcd_hosts = ["10.0.0.1:2379", "10.0.0.2:2379"]+        , Patroni.pat_etcd_tls = Nothing+        , Patroni.pat_rest_listen = "0.0.0.0:8008"+        , Patroni.pat_rest_connect_address = "10.0.0.2:8008"+        , Patroni.pat_pg_version = "16"+        , Patroni.pat_pg_cluster = "main"+        , Patroni.pat_pg_listen = "0.0.0.0:5432"+        , Patroni.pat_pg_connect_address = "10.0.0.2:5432"+        , Patroni.pat_pg_hba = []+        , Patroni.pat_bootstrap_parameters = []+        , Patroni.pat_config_dir = "/etc/patroni"+        , Patroni.pat_secrets_file = "/etc/patroni/secrets.yml"+        , Patroni.pat_user = "postgres"+        , Patroni.pat_binary = "/usr/bin/patroni"+        , Patroni.pat_ready_timeout_seconds = 60+        , Patroni.pat_archive = Nothing+        }++unitCfg :: Systemd.Config+unitCfg = Systemd.Config Systemd.System "/etc/systemd/system" "demo.service" unit svc install+  where+    unit = Systemd.Unit "a service" "network-online.target"+    svc = Systemd.Service Systemd.Simple "nobody" "nogroup" "0022" (Systemd.Start "/bin/true" []) Systemd.OnFailure Systemd.Process "/"+    install = Systemd.Install "multi-user.target"++tests :: TestTree+tests =+    testGroup+        "Salmon.Builtin.Cards"+        [ testGroup "a card changes nothing the builtin renders" (fmap presentationOnly cases)+        , testCase "tramajString escapes what would end the literal" $ do+            assertEqual "plain" "\"abc\"" (Cards.tramajString "abc")+            assertEqual "quote, backslash, backtick" "\"\\\"\\\\\\`\"" (Cards.tramajString "\"\\`")+            assertEqual "newline, control" "\"a\\nb\\u{7}\"" (Cards.tramajString "a\nb\a")+        , testCase "no card carries a password, a file's contents or an instance's metadata" $+            forM_ cases $ \c -> forM_ ("not on the card" : secretWords) $ \w ->+                assertBool (Text.unpack c.caseName <> " shows " <> show w) (not (w `Text.isInfixOf` Cards.renderCard c.caseCard))+        , testCase "every GCP card links to the console, for its own project" $+            forM_ gcpCases $ \c -> do+                assertBool (Text.unpack c.caseName <> ": no link") (not (null c.caseCard.cardLinks))+                forM_ c.caseCard.cardLinks $ \l -> do+                    assertBool (show l) ("https://console.cloud.google.com/" `Text.isPrefixOf` l.linkUrl)+                    assertBool (show l) ("?project=demo-proj" `Text.isSuffixOf` l.linkUrl)+        , testCase "a console link, by hand" $ do+            assertEqual "instance" ["https://console.cloud.google.com/compute/instancesDetail/zones/europe-west1-b/instances/demo-vm?project=demo-proj"] (fmap (.linkUrl) (GcpCards.instanceCard demoInstance).cardLinks)+            assertEqual "encoded" "https://console.cloud.google.com/a%2Fb/%C3%A9%20%3F?project=p%26q%3Dr" (GcpCards.consoleUrl ["a/b", "é ?"] (Core.Project "p&q=r"))+        , testProperty "percentEncode: only unreserved characters and %XX, and decodes back" $+            forAll genText $ \t ->+                let e = Cards.percentEncode t+                 in (Text.all (\ch -> isAsciiLower ch || isAsciiUpper ch || isDigit ch || ch `elem` ("-._~%" :: String)) e, percentDecode e) === (True, Just t)+        , testCase "every card links to its builtin's docs page, last, and the page has a source" docsPages+        , testCase "a docs link, by hand" $+            assertEqual "url" (Cards.Link "docs" "https://lucasdicioccio.github.io/salmon/builtin-filesystem-file-contents.html") (Cards.docsLink "filesystem-file-contents")+        , testCase "the docs link base is the kitchen-sink site's published URL" docsSite+        , testCase "every card draws under tramaj-js; tramajString round-trips (node)" runNode+        ]++docsPages :: IO ()+docsPages = do+    let dir = "../resources/builtins"+    there <- doesFileExist (dir </> "README.md")+    forM_ cases $ \c -> case c.caseCard.cardDocs of+        Nothing -> assertFailure (Text.unpack c.caseName <> ": no docs page")+        Just slug -> do+            assertBool (show slug <> ": a slug is lowercase letters, digits and dashes") (not (Text.null slug) && Text.all (\ch -> isAsciiLower ch || isDigit ch || ch == '-') slug)+            assertEqual (Text.unpack c.caseName <> ": the docs link comes last") (Just (Cards.docsLink slug)) (lastMaybe (Cards.allLinks c.caseCard))+            assertBool (Text.unpack c.caseName <> ": the docs link is drawn") (Cards.tramajString (Cards.docsLink slug).linkUrl `Text.isInfixOf` Cards.renderCard c.caseCard)+            if not there+                then putStrLn "SKIPPED: resources/builtins is not next to the test suite; the page-source check needs the source tree"+                else do+                    page <- doesFileExist (dir </> Text.unpack slug <> ".md")+                    assertBool (Text.unpack c.caseName <> ": no page source " <> dir </> Text.unpack slug <> ".md") page+  where+    lastMaybe xs = if null xs then Nothing else Just (last xs)++{- | The pages under @resources\/builtins\/@ are a kitchen-sink input+(@website\/scripts\/sync-repo-docs.sh@ mirrors each to+@website\/src\/builtin-<slug>.cmark@, so they are on the site's search index+and navigation), and 'Cards.docsLink' must point at where that site is+published: @publishURL@ and @basePath@ of @website\/src\/kitchen-sink.json@.+A move of the site breaks this test instead of every card's link.+-}+docsSite :: IO ()+docsSite = do+    let cfg = "../website/src/kitchen-sink.json"+    there <- doesFileExist cfg+    if not there+        then putStrLn "SKIPPED: website/src/kitchen-sink.json is not next to the test suite; the site check needs the source tree"+        else do+            bytes <- LByteString.readFile cfg+            case Aeson.decode bytes of+                Just (Aeson.Object o)+                    | Just (Aeson.String publishUrl) <- KeyMap.lookup "publishURL" o+                    , Just (Aeson.String basePath) <- KeyMap.lookup "basePath" o ->+                        assertEqual "docsBaseUrl" (Text.dropWhileEnd (== '/') publishUrl <> basePath) Cards.docsBaseUrl+                _ -> assertFailure (cfg <> ": no publishURL/basePath strings")++percentDecode :: Text -> Maybe Text+percentDecode t = either (const Nothing) Just . Text.decodeUtf8' . ByteString.pack =<< go (Text.unpack t)+  where+    go [] = Just []+    go ('%' : a : b : rest)+        | isHexDigit a && isHexDigit b, [(n, "")] <- readHex [a, b] = (fromInteger n :) <$> go rest+    go ('%' : _) = Nothing+    go (ch : rest)+        | ch < chr 128 = (fromIntegral (fromEnum ch) :) <$> go rest+        | otherwise = Nothing++presentationOnly :: Case -> TestTree+presentationOnly c = testCase (Text.unpack c.caseName) $ do+    let carded = Cards.withCard (Cards.renderCard c.caseCard) c.casePlain+    assertEqual "the root: representative, shorthand, ref, help, notes" (fmap describe (rootOf c.casePlain)) (fmap describe (rootOf carded))+    assertEqual "the card is carried" (Just (Cards.renderCard c.caseCard)) (cardTemplateOf . (.extension.dynamics) =<< rootOf carded)+    assertEqual+        "the dependencies carry no card and are unchanged"+        [describe a | a <- drop 1 (allActs c.casePlain)]+        [describe a | a <- drop 1 (allActs carded)]+    assertEqual "nor do they carry a template" [] [() | a <- drop 1 (allActs carded), Just _ <- [cardTemplateOf a.extension.dynamics]]+  where+    describe :: Act Extension -> (Dag.Representative, Text, Ref, Text, [Text])+    describe a = (Dag.representative a, a.shorthand, a.extension.ref, a.extension.help, a.extension.notes)++rootOf :: Op -> Maybe (Act Extension)+rootOf o = case o.node of+    Actions a -> Just a+    Actionless -> Nothing++allActs :: Op -> [Act Extension]+allActs o = [a | n <- toList (evalDeps o), Actions a <- [n.node]]++-- arbitrary text, weighted towards what a Tramaj literal must escape+genText :: Gen Text+genText =+    Text.pack+        <$> listOf+            ( frequency+                [ (4, elements "\"\\`$@.(){}\n\t\r\0\a\DEL")+                , (2, elements "ab é日")+                , (1, arbitrary)+                ]+            )++runNode :: IO ()+runNode = do+    let script = "../salmon-ops/ui-test/builtin-cards.test.mjs"+    there <- doesFileExist script+    if not there+        then putStrLn "SKIPPED: salmon-ops/ui-test is not next to the test suite; the builtin card checks need the source tree"+        else requireExecutable "node" $ withTempDir $ \dir -> do+            texts <- generate (vectorOf 300 genText)+            let input = dir </> "cards.json"+                quote :: Text -> Aeson.Value+                quote t = object ["text" .= t, "source" .= (".p(" <> Cards.tramajString t <> ")")]+                card :: Case -> Aeson.Value+                card c =+                    object+                        [ "name" .= c.caseName+                        , "source" .= Cards.renderCard c.caseCard+                        , "facts" .= c.caseCard.cardFacts+                        , "verdicts" .= sentences c.caseCard.cardVerdicts+                        , "links" .= [(l.linkLabel, l.linkUrl) | l <- Cards.allLinks c.caseCard]+                        ]+            LByteString.writeFile input (Aeson.encode (object ["cards" .= fmap card cases, "quotes" .= fmap quote ("" : texts :: [Text])]))+            (code, out, err) <- readCreateProcessWithExitCode (proc "node" [script, input]) ""+            unless (code == ExitSuccess) $+                assertFailure ("node " <> script <> " failed (" <> show code <> "):\n" <> out <> err)++sentences :: Cards.Verdicts -> Aeson.Value+sentences v =+    object+        [ "unchecked" .= v.verdictUnchecked+        , "inPlace" .= v.verdictInPlace+        , "toApply" .= v.verdictToApply+        , "unsure" .= v.verdictUnsure+        , "noCheck" .= v.verdictNoCheck+        ]
+ test/Test/CardTemplateSpec.hs view
@@ -0,0 +1,97 @@+{- | Layer 0 coverage for a node's card template (feature 458c16c8,+"Salmon.Op.CardTemplate"): it is opt-in, presentation only, and does not+change what a declaration renders. A node without one is exactly what it+was; a node with one has the same representative, @ref@, @help@ and+@notes@ as without; two declarations differing only by their template+do not conflict.+-}+module Test.CardTemplateSpec (tests) where++import Data.Dynamic (Dynamic, toDyn)+import Data.Foldable (toList)+import qualified Data.Map.Strict as Map+import Data.Text (Text)+import qualified Data.Text as Text+import Test.QuickCheck (Gen, Property, arbitrary, choose, counterexample, elements, forAll, listOf, vectorOf, (===))+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, assertEqual, testCase)+import Test.Tasty.QuickCheck (testProperty)++import Salmon.Builtin.Extension (Extension (..), Op, amendNode, deps, evalDeps, nodeps, op, withCardTemplate)+import Salmon.Op.Actions (Act (..), Actions (..))+import Salmon.Op.CardTemplate (cardTemplateOf)+import qualified Salmon.Op.Dag as Dag+import Salmon.Op.OpGraph (OpGraph (..))+import Salmon.Op.Ref (Ref, mkRef)+import Salmon.Op.Supervision (Supervision (..), defaultSupervision)++tests :: TestTree+tests =+    testGroup+        "Salmon.Op.CardTemplate"+        [ testCase "a node without a template has none" $+            assertEqual "" Nothing (cardTemplateOf (dynamicsOf (leaf "x" [])))+        , testCase "withCardTemplate replaces, the last one wins" $ do+            let o = amendNode (withCardTemplate "b") (amendNode (withCardTemplate "a") (leaf "x" []))+            assertEqual "the template" (Just "b") (cardTemplateOf (dynamicsOf o))+            assertEqual "only one is carried" 1 (length (dynamicsOf o))+        , testCase "amending the root with a template leaves its predecessor alone" $ do+            let pre = leaf "pre" []+                root = op "root" (deps [pre]) $ \x -> x{ref = mkRef "card-test" ("root" :: Text)}+                templates = [cardTemplateOf x.dynamics | n <- toList (evalDeps (amendNode (withCardTemplate "t") root)), x <- toList n.node]+            assertEqual "root first" [Just "t", Nothing] templates+        , testCase "two look-alikes differing only by template in one graph: no conflict, one template kept" $ do+            let a = amendNode (withCardTemplate "first") (leaf "same" ["n"])+                b = amendNode (withCardTemplate "second") (leaf "same" ["n"])+                top = op "top" (deps [a, b]) $ \x -> x{ref = mkRef "card-test" ("top" :: Text)}+                dag = Dag.foldDag Dag.sameRepresentative (evalDeps top)+            assertEqual "no conflict" 0 (length (Dag.dagConflicts dag))+            -- within one fold the look-alike skip keeps the one walked first+            -- (module notes, "Dag"); across declarations under serve the+            -- newest wins, which "Test.ServeHttpSpec" checks+            let kept = cardTemplateOf . (.extension.dynamics) =<< Map.lookup (refOf "same") (Dag.dagNodes dag)+            assertBool ("one of the two: " <> show kept) (kept `elem` [Just "first", Just "second"])+        , testProperty "a template changes neither the representative nor ref, help, notes" prop_presentationOnly+        , testProperty "other dynamics are kept, in order, and still compared" prop_otherDynamicsKept+        ]++refOf :: Text -> Ref+refOf k = mkRef "card-test" k++leaf :: Text -> [Text] -> Op+leaf k ns = op "leaf" nodeps $ \x -> x{ref = refOf k, help = "help " <> k, notes = ns}++dynamicsOf :: Op -> [Dynamic]+dynamicsOf o = concat [x.dynamics | x <- toList o.node]++actOf :: Op -> [Act Extension]+actOf o = case o.node of+    Actions a -> [a]+    Actionless -> []++genText :: Gen Text+genText = Text.pack <$> listOf (elements "ab {}()$.\"\\\n<>&é")++prop_presentationOnly :: Property+prop_presentationOnly =+    forAll genText $ \k ->+        forAll (listOf genText) $ \ns ->+            forAll genText $ \src ->+                let plain = leaf k ns+                    carded = amendNode (withCardTemplate src) plain+                 in counterexample (show (src, k, ns)) $+                        (fmap Dag.representative (actOf carded), fmap describe (actOf carded), cardTemplateOf (dynamicsOf carded))+                            === (fmap Dag.representative (actOf plain), fmap describe (actOf plain), Just src)+  where+    describe :: Act Extension -> (Text, Ref, Text, [Text])+    describe a =(a.shorthand, a.extension.ref, a.extension.help, a.extension.notes)++prop_otherDynamicsKept :: Property+prop_otherDynamicsKept =+    forAll (choose (0, 3 :: Int)) $ \n ->+        forAll (vectorOf n arbitrary) $ \reapplies ->+            forAll genText $ \src ->+                let sups = [defaultSupervision{supReapply = r} | r <- reapplies]+                    plain = amendNode (\x -> x{dynamics = fmap toDyn sups}) (leaf "d" [])+                    carded = amendNode (withCardTemplate src) plain+                 in fmap Dag.representative (actOf carded) === fmap Dag.representative (actOf plain)
+ test/Test/ClientModelReference.hs view
@@ -0,0 +1,433 @@+{-# LANGUAGE OverloadedStrings #-}++{- | "Salmon.Client.Model" as it was before its display order stopped being+a list: the reference 'step' that "Test.ClientModelStepSpec" holds the+current one to.++__This is a copy, on purpose, and it is not to be improved.__ It is the+text of @salmon-ops\/src\/Salmon\/Client\/Model.hs@ at the commit before+'modelOrder' became an @Order@ (types, 'fromDag', 'step', 'rebase',+'nodesInOrder', 'counts' and the JSON readers, unchanged; the module+header and the rendering functions, which the comparison does not need,+left out). 'step' here removes a node with @filter (\/= r) modelOrder@:+quadratic over a teardown, and plainly right, which is what a reference is+for.++Because it is a copy it has types of its own ('Model', 'Node', 'Event')+with the same constructors and field names as the real ones, so the spec+compares the two through what they 'show'.++A rule added to the real fold after this copy was taken (a new event kind,+a new field) will fail the comparison until it is added here too. That is+the price of a reference that does not share code with what it checks; if+it stops being worth paying, delete this module and the comparison with+it, and keep the properties that need no reference.+-}+module Test.ClientModelReference (+    Event (..),+    eventOf,+    RefId (..),+    Model (..),+    Node (..),+    Check (..),+    Pass (..),+    Counts (..),+    fromDag,+    step,+    rebase,+    nodesInOrder,+    counts,+) where++import Data.Aeson (Result (..), Value (..), fromJSON)+import qualified Data.Aeson.Key as Key+import qualified Data.Aeson.KeyMap as KeyMap+import Data.Foldable (toList)+import Data.List (foldl')+import Data.Map.Strict (Map)+import qualified Data.Map.Strict as Map+import Data.Maybe (fromMaybe, mapMaybe)+import Data.Text (Text)+import qualified Data.Text as Text+import Data.Word (Word64)++-------------------------------------------------------------------------------+-- events++-- | A node's identity on the wire: the short tag and the full text.+data RefId = RefId+    { refShort :: !Text+    , refFull :: !Text+    }+    deriving (Show, Eq, Ord)++{- | One event as the stream carried it. The fields are what every client+needs to route the object; 'eventValue' keeps the whole thing for whatever+else a report says.+-}+data Event = Event+    { eventSeq :: !(Maybe Word64)+    -- ^ 'Nothing' only for the @gap@ event, which the server sends without one+    , eventStream :: !Text+    , eventKind :: !Text+    , eventRef :: !(Maybe RefId)+    , eventOrigin :: !(Maybe Text)+    -- ^ the origin's name, for an event produced for a command+    , eventValue :: !Value+    }+    deriving (Show, Eq)++-- | Read an event out of its wire object. Total: a shapeless object is an+-- event of kind @?@ with no ref, which the model records and moves past.+eventOf :: Value -> Event+eventOf v =+    Event+        { eventSeq = numberAt ["seq"] v+        , eventStream = fromMaybe "?" (textAt ["stream"] v)+        , eventKind = fromMaybe "?" (textAt ["kind"] v)+        , eventRef = refAt ["ref"] v+        , eventOrigin = textAt ["origin", "name"] v+        , eventValue = v+        }++-------------------------------------------------------------------------------+-- the model++-- | A node's own last word on its effect, as @next-look@ and @\/dag@ carry it.+data Check = Check+    { checkVerdict :: !Text+    -- ^ @success@\/@skipped@\/@completed@\/@failure@\/@unknown@\/@immaterial@+    , checkReason :: !(Maybe Text)+    }+    deriving (Show, Eq)++data Node = Node+    { nodeRef :: !RefId+    , nodeShorthand :: !Text+    , nodeHelp :: !Text+    , nodeNotes :: ![Text]+    , nodeDirection :: !Text+    -- ^ @up@ or @down@+    , nodeConvergence :: !Text+    -- ^ @pending@\/@stale@\/@converged@\/@errored@\/@blocked@+    , nodeCheck :: !(Maybe Check)+    , nodeOutput :: ![Text]+    -- ^ the snapshot's output ring, then each @output@ line since, oldest first+    , nodeSaid :: ![Text]+    -- ^ the @log@ lines the stream carried (what the node said during a+    -- one-shot @up@), oldest first; a line from standard error is prefixed+    -- @err| @ and the node's own message @msg| @. In no snapshot.+    , nodeSaidSeq :: !Word64+    -- ^ the number 'nodeSaid' is current to; 0 until a line arrives+    , nodeError :: !(Maybe Text)+    -- ^ the last @failed@'s error, cleared by a later @done@\/@skip@+    , nodeLastKind :: !(Maybe Text)+    -- ^ the kind of the last event about this node, and its stream+    , nodeLastSeq :: !(Maybe Word64)+    , nodeSeq :: !Word64+    -- ^ the number this view is current to: the snapshot's, then each event's+    , nodeDependencies :: ![RefId]+    , nodeDependants :: ![RefId]+    , nodePaths :: ![Text]+    }+    deriving (Show, Eq)++-- | The loop's last convergence pass as the stream told it.+data Pass+    = -- | @converge-start@: nodes to turn down, nodes to turn up+      Converging !Int !Int+    | -- | @converge-stop@: everything applied cleanly, nodes left+      Stopped !Bool !Int+    deriving (Show, Eq)++data Model = Model+    { modelMode :: !Text+    -- ^ @interactive@\/@replay@\/@following@, from the snapshot's envelope+    , modelSeq :: !Word64+    -- ^ the highest sequence number seen: the snapshot's, then each+    -- event's; the cursor to resume @\/events@ from+    , modelLoopSeq :: !Word64+    -- ^ the number the loop-level fields are current to+    , modelOrder :: ![RefId]+    -- ^ the snapshot's dependency order ('Salmon.Op.Dag.dagOrder')+    , modelNodes :: !(Map RefId Node)+    , modelPass :: !(Maybe Pass)+    , modelSupervised :: !(Maybe Bool)+    -- ^ 'Nothing' until a @supervised@ event says+    , modelLast :: !(Maybe Event)+    -- ^ the last event folded, whatever it was about+    , modelResyncReason :: !(Maybe Text)+    -- ^ why the snapshot should be re-read; cleared by 'resolve' or 'fromDag'+    }+    deriving (Show, Eq)++-- | The snapshot should be re-read: a @gap@, or a declaration changed the+-- node set. The reason is human text for a status line.+modelResync :: Model -> Maybe Text+modelResync = modelResyncReason++-- | Forget the resync request (the snapshot is being re-read).+resolve :: Model -> Model+resolve m = m{modelResyncReason = Nothing}++{- | A re-read snapshot joining a model that has been folding: the nodes,+order and mode are the fresh snapshot's; the loop-level fields, their+stamp and the last event are the old model's; the cursor is the higher of+the two; and the resync request is answered. See the module header for+why the loop's part is not simply the snapshot's. What a node said+('nodeSaid') is the old model's too, for the nodes the fresh snapshot+still has: no snapshot carries it.+-}+rebase :: Model -> Model -> Model+rebase old fresh =+    fresh+        { modelNodes = Map.mapWithKey keepSaid fresh.modelNodes+        , modelSeq = max old.modelSeq fresh.modelSeq+        , modelLoopSeq = old.modelLoopSeq+        , modelPass = old.modelPass+        , modelSupervised = old.modelSupervised+        , modelLast = old.modelLast+        , modelResyncReason = Nothing+        }+  where+    keepSaid :: RefId -> Node -> Node+    keepSaid r n = case Map.lookup r old.modelNodes of+        Just o -> n{nodeSaid = o.nodeSaid, nodeSaidSeq = o.nodeSaidSeq}+        Nothing -> n++{- | A model from a @\/dag@ answer. 'Left' names what is missing; a @\/dag@+answer always has @nodes@ and @seq@, so a 'Left' is a wrong URL, not a+version skew.+-}+fromDag :: Value -> Either String Model+fromDag v = do+    nodes <- maybe (Left "/dag answer has no nodes array") Right (arrayAt ["nodes"] v)+    seqNo <- maybe (Left "/dag answer carries no seq") Right (numberAt ["seq"] v)+    parsed <- traverse (nodeOf seqNo) nodes+    pure+        Model+            { modelMode = fromMaybe "?" (textAt ["mode"] v)+            , modelSeq = seqNo+            , modelLoopSeq = 0+            , modelOrder = fmap nodeRef parsed+            , modelNodes = Map.fromList [(nodeRef n, n) | n <- parsed]+            , modelPass = Nothing+            , modelSupervised = Nothing+            , modelLast = Nothing+            , modelResyncReason = Nothing+            }+  where+    nodeOf :: Word64 -> Value -> Either String Node+    nodeOf seqNo n = do+        r <- maybe (Left ("a node without a ref: " <> show n)) Right (refAt ["ref"] n)+        pure+            Node+                { nodeRef = r+                , nodeShorthand = fromMaybe "?" (textAt ["shorthand"] n)+                , nodeHelp = fromMaybe "" (textAt ["help"] n)+                , nodeNotes = maybe [] (mapMaybe asText) (arrayAt ["notes"] n)+                , nodeDirection = fromMaybe "?" (textAt ["direction"] n)+                , nodeConvergence = fromMaybe "?" (textAt ["convergence"] n)+                , nodeCheck = checkAt ["status", "check"] n+                , nodeOutput = maybe [] (mapMaybe asText) (arrayAt ["status", "output"] n)+                , nodeSaid = []+                , nodeSaidSeq = 0+                , nodeError = Nothing+                , nodeLastKind = Nothing+                , nodeLastSeq = Nothing+                , nodeSeq = seqNo+                , nodeDependencies = maybe [] (mapMaybe (refAt [])) (arrayAt ["dependencies"] n)+                , nodeDependants = maybe [] (mapMaybe (refAt [])) (arrayAt ["dependants"] n)+                , nodePaths = maybe [] (mapMaybe asText) (arrayAt ["paths"] n)+                }++{- | Fold one event in. Total; an event already accounted for (numbered at+or below the stamp of the part it would change, see the module header)+leaves that part as it was. Otherwise the cursor moves to the event's+number, the node the event is about is updated, and the loop-level fields+follow the @serve@ stream. An event that changes nothing still becomes+'modelLast' if it is new to the cursor.+-}+step :: Model -> Event -> Model+step m0 e+    | Just s <- e.eventSeq, s <= m0.modelSeq = byStream m0+    | otherwise = byStream (m0{modelSeq = fromMaybe m0.modelSeq e.eventSeq, modelLast = Just e})+  where+    byStream m = case (e.eventStream, e.eventKind) of+        ("server", "gap") -> m{modelResyncReason = Just ("events " <> fromText (numberAt ["from"] e.eventValue) <> " fell off the ring")}+        ("server", _) -> m+        ("serve", "declared") ->+            onLoop m $ \l -> l{modelResyncReason = Just ("epoch " <> fromText (numberAt ["epoch"] e.eventValue) <> " declared " <> fromMaybe "?" (textAt ["direction"] e.eventValue))}+        ("serve", "cleared") -> onLoop m $ \l -> l{modelResyncReason = Just "every seed retired"}+        ("serve", "converge-start") ->+            onLoop m $ \l -> l{modelPass = Just (Converging (intAt ["down"] e.eventValue) (intAt ["up"] e.eventValue))}+        ("serve", "converge-stop") ->+            onLoop m $ \l -> l{modelPass = Just (Stopped (fromMaybe False (boolAt ["ok"] e.eventValue)) (intAt ["remaining"] e.eventValue))}+        ("serve", "supervised") -> onLoop m $ \l -> l{modelSupervised = boolAt ["on"] e.eventValue}+        ("serve", _) -> m+        ("updown", k) -> maybe m (\r -> onNode r (settle k e.eventValue) m) e.eventRef+        -- an @acted@ wraps what the tending machine did in the pass's+        -- vocabulary; the inner object carries the ref+        ("upkeep", "acted") ->+            case KeyMap.lookup "report" =<< asObject e.eventValue of+                Just inner | Just r <- refAt ["ref"] inner ->+                    let k = fromMaybe "?" (textAt ["kind"] inner)+                     in onNode r (updown k inner . touch ("acted " <> k)) m+                _ -> m+        ("upkeep", "next-look") ->+            maybe m (\r -> onNode r (\n -> (touch e.eventKind n){nodeCheck = checkAt ["check"] e.eventValue}) m) e.eventRef+        ("upkeep", k) -> maybe m (\r -> onNode r (touch k) m) e.eventRef+        -- a held action's line: in the ring, so under the node's stamp+        ("output", "output") ->+            case (e.eventRef, textAt ["line"] e.eventValue) of+                (Just r, Just line) -> onNode r (\n -> n{nodeOutput = keepLast (n.nodeOutput ++ [line])}) m+                _ -> m+        -- a line said during a one-shot up: in no snapshot, so its own stamp+        ("output", "log") ->+            case (e.eventRef, textAt ["line"] e.eventValue) of+                (Just r, Just line) -> said r (channelMark (textAt ["channel"] e.eventValue) <> line) m+                _ -> m+        _ -> m++    said :: RefId -> Text -> Model -> Model+    said r line m = case Map.lookup r m.modelNodes of+        Nothing -> m+        Just n+            | Just s <- e.eventSeq, s <= n.nodeSaidSeq -> m+            | otherwise ->+                let n' = n{nodeSaid = keepLast (n.nodeSaid ++ [line]), nodeSaidSeq = fromMaybe n.nodeSaidSeq e.eventSeq}+                 in m{modelNodes = Map.insert r n' m.modelNodes}++    keepLast :: [Text] -> [Text]+    keepLast xs = drop (length xs - outputKept) xs++    -- the loop-level fields, unless the event is at or below their stamp+    onLoop :: Model -> (Model -> Model) -> Model+    onLoop m f = case e.eventSeq of+        Just s | s <= m.modelLoopSeq -> m+        _ -> (f m){modelLoopSeq = fromMaybe m.modelLoopSeq e.eventSeq}++    -- update the node (unless the event is at or below its stamp), or+    -- drop it: a node wanted down that a pass has brought down is pruned+    -- by the loop after the pass, and /dag would no longer show it+    onNode :: RefId -> (Node -> Node) -> Model -> Model+    onNode r f m = case Map.lookup r m.modelNodes of+        Nothing -> m+        Just n+            | Just s <- e.eventSeq, s <= n.nodeSeq -> m+            | otherwise ->+                let n' = (f n){nodeSeq = fromMaybe n.nodeSeq e.eventSeq}+                 in if n'.nodeDirection == "down" && n'.nodeConvergence == "converged" && n'.nodeLastKind `elem` [Just "done", Just "acted done"]+                        then m{modelNodes = Map.delete r m.modelNodes, modelOrder = filter (/= r) m.modelOrder}+                        else m{modelNodes = Map.insert r n' m.modelNodes}++    touch :: Text -> Node -> Node+    touch k n = n{nodeLastKind = Just k, nodeLastSeq = e.eventSeq}++    -- the pass's verdict on a node, in /dag's convergence words+    settle :: Text -> Value -> Node -> Node+    settle k v n = updown k v (touch k n)++    updown :: Text -> Value -> Node -> Node+    updown k v n = case k of+        "done" -> n{nodeConvergence = "converged", nodeError = Nothing}+        "skip" -> n{nodeConvergence = "converged", nodeError = Nothing}+        "failed" -> n{nodeConvergence = "errored", nodeError = textAt ["error"] v}+        "blocked" -> n{nodeConvergence = "blocked"}+        _ -> n++-------------------------------------------------------------------------------+-- reading it++-- | The nodes in the snapshot's dependency order.+nodesInOrder :: Model -> [Node]+nodesInOrder m = mapMaybe (`Map.lookup` m.modelNodes) m.modelOrder++lookupNode :: RefId -> Model -> Maybe Node+lookupNode r m = Map.lookup r m.modelNodes++-- | How many lines of 'nodeOutput' and of 'nodeSaid' a node's view keeps.+outputKept :: Int+outputKept = 200++-- | How a @log@ line's channel shows in front of it: nothing for standard+-- output, the marks the text reporter and the web UI's tail use otherwise.+channelMark :: Maybe Text -> Text+channelMark c = case c of+    Just "stderr" -> "err| "+    Just "message" -> "msg| "+    _ -> ""++{- | The last @k@ lines a live tail of this node shows: what it said during+its one-shot @up@ when it said anything, its ring otherwise (a held+action's output, which is the only other thing that moves while one+watches).+-}+tailLines :: Int -> Node -> [Text]+tailLines k n = lastN (if null n.nodeSaid then n.nodeOutput else n.nodeSaid)+  where+    lastN xs = drop (length xs - k) xs++data Counts = Counts+    { countConverged :: !Int+    , countErrored :: !Int+    , countTotal :: !Int+    }+    deriving (Show, Eq)++counts :: Model -> Counts+counts m =+    foldl' tally (Counts 0 0 0) (Map.elems m.modelNodes)+  where+    tally :: Counts -> Node -> Counts+    tally (Counts c e t) n =+        Counts+            (c + fromEnum (n.nodeConvergence == "converged"))+            (e + fromEnum (n.nodeConvergence == "errored"))+            (t + 1)++-------------------------------------------------------------------------------+-- reading JSON, totally++asObject :: Value -> Maybe (KeyMap.KeyMap Value)+asObject (Object o) = Just o+asObject _ = Nothing++asText :: Value -> Maybe Text+asText (String t) = Just t+asText _ = Nothing++at :: [Text] -> Value -> Maybe Value+at [] v = Just v+at (k : ks) (Object o) = KeyMap.lookup (Key.fromText k) o >>= at ks+at _ _ = Nothing++textAt :: [Text] -> Value -> Maybe Text+textAt ks v = at ks v >>= asText++numberAt :: [Text] -> Value -> Maybe Word64+numberAt ks v = case fromJSON <$> at ks v of+    Just (Success n) -> Just n+    _ -> Nothing++intAt :: [Text] -> Value -> Int+intAt ks v = maybe 0 fromIntegral (numberAt ks v)++boolAt :: [Text] -> Value -> Maybe Bool+boolAt ks v = case at ks v of+    Just (Bool b) -> Just b+    _ -> Nothing++arrayAt :: [Text] -> Value -> Maybe [Value]+arrayAt ks v = case at ks v of+    Just (Array xs) -> Just (toList xs)+    _ -> Nothing++refAt :: [Text] -> Value -> Maybe RefId+refAt ks v = RefId <$> textAt (ks ++ ["short"]) v <*> textAt (ks ++ ["full"]) v++checkAt :: [Text] -> Value -> Maybe Check+checkAt ks v = Check <$> textAt (ks ++ ["verdict"]) v <*> pure (textAt (ks ++ ["reason"]) v)++fromText :: Maybe Word64 -> Text+fromText = maybe "?" (Text.pack . show)
test/Test/ClientModelSpec.hs view
@@ -10,7 +10,11 @@ folding it once; an @acted@ is unwrapped to the pass's vocabulary; a node wanted down is dropped by its @done@; the SSE block parser reads what "Salmon.Actions.Serve.Events" renders, comments included; and the rendered-rows and header are the text expected.+rows and header are the text expected. The same snapshot, sequence and text+are written out in @rs\/fixtures\/client-model.json@, which the Rust client's+fold is tested on (@rs\/salmon-serve-client@); one case here holds that file+to this module, so the two folds are tested on the same wire data and+neither side can change it alone.  Layer 1 over a real @withHttpServer@ on a temp socket, driven through the client itself: @dag@, then @commandAsync "up ..."@, then @events@ from the@@ -23,7 +27,7 @@ import Control.Concurrent.MVar (MVar, newEmptyMVar, putMVar, takeMVar) import Control.Exception (try) import Control.Monad (forM_, when)-import Data.Aeson (FromJSON, ToJSON, Value (..), object, (.=))+import Data.Aeson (FromJSON, ToJSON, Value (..), eitherDecodeFileStrict, object, toJSON, (.=)) import qualified Data.Aeson.Key as Key import qualified Data.Aeson.KeyMap as KeyMap import qualified Data.ByteString.Builder as Builder@@ -36,6 +40,7 @@ import Data.Text (Text) import qualified Data.Text as Text import GHC.Generics (Generic)+import System.Directory (doesFileExist) import System.FilePath ((</>)) import System.IO (Handle, hClose) import System.Posix.IO (FdOption (CloseOnExec), createPipe, fdToHandle, setFdOption)@@ -52,7 +57,8 @@ import qualified Salmon.Client.Model as Model import Salmon.Client.Model (Check (..), Model (..), Node (..), Pass (..), RefId (..)) import Salmon.Op.Configure (Configure (..))-import Salmon.Op.Ref (mkRef)+import qualified Salmon.Builtin.NodeLog as NodeLog+import Salmon.Op.Ref (mkRef, shortRef, unRef) import Salmon.Op.Track (Track (..)) import Salmon.Reporter (contramap) import qualified Salmon.Reporter.Tagged as Tagged@@ -71,6 +77,9 @@             , testCase "a node wanted down is dropped by its done" downNodeDropped             , testCase "the SSE parser reads what the server renders" sseParser             , testCase "the rendered rows and header" rendering+            , testCase "the output stream: a held action's lines join the ring, a one-shot up's lines are kept across a re-read" outputFolded+            , testCase "a /dag tail seeds what a node said; a rebase keeps whichever is later" saidFromTail+            , testCase "the fixture the Rust client is tested on is this recorded pass" sharedFixture             ]         , testGroup             "the client against a real server"@@ -156,7 +165,7 @@ recordedPass = do     m0 <- start     assertEqual "the snapshot's seq" 5 m0.modelSeq-    assertEqual "the snapshot's order" [refOf "n1", refOf "n2", refOf "root"] m0.modelOrder+    assertEqual "the snapshot's order" [refOf "n1", refOf "n2", refOf "root"] (Model.orderToList m0.modelOrder)     assertEqual "mode" "interactive" m0.modelMode     -- the declaration asks for a re-read, and the fold goes on past it     let afterDeclared = fold m0 (take 1 recorded)@@ -234,7 +243,7 @@     assertEqual "two nodes" 2 (Model.countTotal (Model.counts m0))     let m = fold m0 [about "updown" "eval" "n1" 2 [], about "updown" "done" "n1" 3 []]     assertEqual "n1 is gone once down" Nothing (Model.lookupNode (refOf "n1") m)-    assertEqual "the order follows" [refOf "n2"] m.modelOrder+    assertEqual "the order follows" [refOf "n2"] (Model.orderToList m.modelOrder)     -- while a node wanted up that is done stays     let m' = fold m0 [about "updown" "done" "n2" 4 []]     assertEqual "n2 stays, converged" (Just "converged") (nodeConvergence <$> Model.lookupNode (refOf "n2") m')@@ -283,6 +292,138 @@     assertEqual "an event line about a node" "#11 updown failed n2 n2" (Model.renderEventLine (Model.eventOf (recorded !! 5)))     assertEqual "an event line about the loop" "#13 serve converge-stop ok=false remaining=2" (Model.renderEventLine (Model.eventOf (recorded !! 7))) +{- | The @output@ stream folded: a held action's @output@ lines go on after+the snapshot's ring under the node's stamp, a one-shot @up@'s @log@ lines+(encoded here by the server's own 'Tagged.FromNode', so the wire shape is+not this test's guess) go to 'nodeSaid' under a stamp of their own, and a+re-read snapshot replaces the first and keeps the second. Neither changes+what the node's last event is said to be.+-}+outputFolded :: IO ()+outputFolded = do+    let builder = mkRef "client-model-spec" ("builder" :: Text)+        rid = RefId (shortRef builder) (unRef builder)+        refJson = object ["short" .= shortRef builder, "full" .= unRef builder]+        node ring = case nodeValue "x" "the-builder" [] of+            Object o -> Object (KeyMap.insert "ref" refJson (KeyMap.insert "status" (object ["output" .= (ring :: [Text])]) o))+            v -> v+        dagAt :: Int -> [Text] -> Value+        dagAt seqNo ring = object ["mode" .= ("interactive" :: Text), "seq" .= seqNo, "nodes" .= [node ring]]+        said seqNo channel line = Events.eventValue (Events.Event seqNo Nothing (Events.Reported (Tagged.FromNode (NodeLog.Line builder channel line))))+        held :: Int -> Text -> Value+        held seqNo line = object ["stream" .= ("output" :: Text), "kind" .= ("output" :: Text), "seq" .= seqNo, "ref" .= refJson, "line" .= line]+        eval = object ["stream" .= ("updown" :: Text), "kind" .= ("eval" :: Text), "seq" .= (6 :: Int), "ref" .= refJson]+        events =+            [ eval+            , said 7 NodeLog.Stdout "STEP 1/2: FROM scratch"+            , said 8 NodeLog.Stderr "warning: no cache"+            , said 9 NodeLog.Message "waiting for ssh"+            , held 10 "listening on :8080"+            ]+        view m = maybe (assertFailure "no builder node") pure (Model.lookupNode rid m)+    m0 <- either (assertFailure . ("snapshot: " <>)) pure (Model.fromDag (dagAt 5 ["spawn"]))+    let m = fold m0 events+    n <- view m+    assertEqual "what it said, marked by channel" ["STEP 1/2: FROM scratch", "err| warning: no cache", "msg| waiting for ssh"] n.nodeSaid+    assertEqual "current to the last line" 9 n.nodeSaidSeq+    assertEqual "the ring, then the held action's line" ["spawn", "listening on :8080"] n.nodeOutput+    assertEqual "a line is not the node's last event" (Just "eval", Just 6) (n.nodeLastKind, n.nodeLastSeq)+    assertEqual "the cursor follows the lines" 10 m.modelSeq+    assertEqual "a tail prefers what was said" ["err| warning: no cache", "msg| waiting for ssh"] (Model.tailLines 2 n)+    assertEqual "the line shows in the event line" "#8 output log " (Text.take 14 (Model.renderEventLine (Model.eventOf (events !! 2))))+    assertBool "with its mark" ("err| warning: no cache" `Text.isSuffixOf` Model.renderEventLine (Model.eventOf (events !! 2)))+    -- replayed (a reconnect from an older cursor): nothing is said twice+    assertEqual "folding the lines again changes nothing" m (fold m events)+    -- a re-read snapshot has the ring's lines and none of the said ones+    fresh <- either (assertFailure . ("snapshot: " <>)) pure (Model.fromDag (dagAt 12 ["spawn", "listening on :8080"]))+    assertEqual "no snapshot carries what was said" (Just []) (nodeSaid <$> Model.lookupNode rid fresh)+    r <- view (Model.rebase m fresh)+    assertEqual "rebase keeps what was said" n.nodeSaid r.nodeSaid+    assertEqual "and takes the snapshot's ring" ["spawn", "listening on :8080"] r.nodeOutput+    -- the held line at 10 is below the fresh snapshot's stamp: not appended twice+    r' <- view (fold (Model.rebase m fresh) events)+    assertEqual "a replay onto the rebased model appends nothing" (r.nodeOutput, r.nodeSaid) (r'.nodeOutput, r'.nodeSaid)+    -- bounded: a chatty node keeps the last 'Model.outputKept' lines+    let chatty = fold m0 [said (100 + fromIntegral i) NodeLog.Stdout (Text.pack (show i)) | i <- [1 .. Model.outputKept + 50 :: Int]]+    c <- view chatty+    assertEqual "bounded" Model.outputKept (length c.nodeSaid)+    assertEqual "the newest kept" (Just (Text.pack (show (Model.outputKept + 50)))) (lastOf c.nodeSaid)+    -- a line about a node the model does not have is moved past+    let stranger = Events.eventValue (Events.Event 400 Nothing (Events.Reported (Tagged.FromNode (NodeLog.Line (mkRef "client-model-spec" ("other" :: Text)) NodeLog.Stdout "x"))))+    assertEqual "an unknown node's line changes no node" m0.modelNodes (fold m0 [stranger]).modelNodes+  where+    lastOf [] = Nothing+    lastOf xs = Just (last xs)++{- | A @\/dag@ answer whose node carries a @tail@ (what @\/events@ sent about+it, none above the answer's @seq@): its @log@ lines seed 'nodeSaid', current+to the snapshot's number, so the same lines replayed are not said twice and+a page opened after a build still shows it. A rebase keeps whichever side is+current to the later number.+-}+saidFromTail :: IO ()+saidFromTail = do+    let builder = mkRef "client-model-spec" ("tailed" :: Text)+        rid = RefId (shortRef builder) (unRef builder)+        refJson = object ["short" .= shortRef builder, "full" .= unRef builder]+        said seqNo channel line = Events.eventValue (Events.Event seqNo Nothing (Events.Reported (Tagged.FromNode (NodeLog.Line builder channel line))))+        eval = object ["stream" .= ("updown" :: Text), "kind" .= ("eval" :: Text), "seq" .= (6 :: Int), "ref" .= refJson]+        lines3 = [said 7 NodeLog.Stdout "STEP 1/2: FROM scratch", said 8 NodeLog.Stderr "warning: no cache", said 9 NodeLog.Message "waiting for ssh"]+        node tl = case nodeValue "x" "the-builder" [] of+            Object o -> Object (KeyMap.insert "ref" refJson (KeyMap.insert "tail" (toJSON (tl :: [Value])) o))+            v -> v+        dagAt :: Int -> [Value] -> Value+        dagAt seqNo tl = object ["mode" .= ("interactive" :: Text), "seq" .= seqNo, "nodes" .= [node tl]]+        view m = maybe (assertFailure "no tailed node") pure (Model.lookupNode rid m)+        load v = either (assertFailure . ("snapshot: " <>)) pure (Model.fromDag v)+    m <- load (dagAt 12 (eval : lines3))+    n <- view m+    assertEqual "the tail's log lines, marked by channel" ["STEP 1/2: FROM scratch", "err| warning: no cache", "msg| waiting for ssh"] n.nodeSaid+    assertEqual "current to the snapshot" 12 n.nodeSaidSeq+    assertEqual "replayed, they are not said twice" (Just n.nodeSaid) (nodeSaid <$> Model.lookupNode rid (fold m lines3))+    -- an older model that folded the same lines live, rebased onto it+    m0 <- load (dagAt 5 [])+    let live = fold m0 lines3+    r <- view (Model.rebase live m)+    assertEqual "the snapshot is the later: its lines" (n.nodeSaid, 12) (r.nodeSaid, r.nodeSaidSeq)+    -- a model that has heard more than the snapshot keeps what it heard+    let ahead = fold m [said 13 NodeLog.Stdout "STEP 2/2: COPY"]+    a <- view ahead+    r' <- view (Model.rebase ahead m)+    assertEqual "the model is the later: its lines" (a.nodeSaid, 13) (r'.nodeSaid, r'.nodeSaidSeq)++{- | @rs\/fixtures\/client-model.json@ against this module: its @snapshot@+and @recorded@ are 'snapshot' and 'recorded' object for object, and its+@expect@ is what "Salmon.Client.Model" renders after the fold. The Rust+tests read the same file, so a change to the fold's text or to the wire+shapes here fails this case until the file (and with it the Rust side) is+brought along.+-}+sharedFixture :: IO ()+sharedFixture = do+    let path = "../rs/fixtures/client-model.json"+    there <- doesFileExist path+    if not there+        then putStrLn "SKIPPED: rs/fixtures/client-model.json is not next to the test suite; the comparison needs the source tree"+        else do+            fixture <- either (assertFailure . ("the fixture is not JSON: " <>)) pure =<< eitherDecodeFileStrict path+            let field :: [Key.Key] -> Value -> Maybe Value+                field [] v = Just v+                field (k : ks) (Object o) = KeyMap.lookup k o >>= field ks+                field _ _ = Nothing+                expect ks = field ("expect" : ks) fixture+            assertEqual "snapshot" (Just snapshot) (field ["snapshot"] fixture)+            assertEqual "recorded" (Just (toJSON recorded)) (field ["recorded"] fixture)+            m0 <- start+            let m = fold m0 recorded+            target <- case expect ["target"] of+                Just (String t) -> pure t+                other -> assertFailure ("expect.target: " <> show other)+            assertEqual "expect.rows" (Just (toJSON (fmap Model.renderNodeRow (Model.nodesInOrder m)))) (expect ["rows"])+            assertEqual "expect.header" (Just (String (Model.renderHeader target m))) (expect ["header"])+            assertEqual "expect.eventLines" (Just (toJSON (fmap (Model.renderEventLine . Model.eventOf) recorded))) (expect ["eventLines"])+            assertEqual "expect.resync" (Just (toJSON (Model.modelResync m))) (expect ["resync"])+ ------------------------------------------------------------------------------- -- Layer 1: the client against a real server @@ -345,6 +486,10 @@                         Nothing                         [Serve.stdinProducer stdinR, Http.serverProducer server]                 putMVar worldVar w+            -- the loop publishes its inbox from a thread it forks: wait for+            -- it, or the first request can beat it and be answered 503+            timeout (10 * 1000000) (Http.awaitLoop server)+                >>= maybe (assertFailure "the loop did not start within 10s") pure             client <- Client.newUnixClient path             r <- act (Running stdinW worldVar client upsRef)             _ <- try (hClose stdinW) :: IO (Either IOError ())
+ test/Test/ClientModelStepSpec.hs view
@@ -0,0 +1,636 @@+{- | 'Salmon.Client.Model.step' removes a node by lookup, and shows what it+showed when it filtered a list.++The model's display order used to be a list, and dropping a node (one+wanted @down@ whose @done@ arrives) filtered all of it: a teardown, one+such event per node, was quadratic (finding 5 of+@resources\/graph-profiles.md@). The order is now 'Model.Order'. What must+not have moved is anything a client can see, so the bar here is+observable equivalence with the fold as it was, which is kept, untouched,+as "Test.ClientModelReference":++  * __QuickCheck__, over generated scripts ('Script': a snapshot, then+    events and re-read snapshots): after every step the two folds show the+    same nodes in the same order with the same fields, and agree on the+    loop's part; the order is the last snapshot's with exactly the dropped+    nodes missing, holds no node the model does not have, and repeats none;+    a teardown in any order agrees step by step; and 'Model.Order' on its+    own is the list operations it replaces. A further property checks that+    the scripts do reach what they are for (removals, a removed node read+    again, events about a node that is not there).+  * __Unit cases__ for the positions a removal can be in (first, middle,+    last, absent, all of them), for a removed node coming back with a+    re-read snapshot, and for a snapshot that lists a ref twice.+  * __One scale case__: a teardown of a hundred thousand nodes within a+    minute, in the manner of "Test.GraphScaleSpec" (it tells per node from+    per node squared; it times nothing).++All of it is Layer 0: pure, in-process. __Not covered:__ the events are+generated from the kinds the fold names plus one it does not, not recorded+from a server; the scripts use a pool of eight refs and a ghost, so they+say nothing about size (the scale case does, for a teardown only); and+'Model.fromDag' is only given well-formed snapshots, apart from repeated+refs. The shared fixture @rs\/fixtures\/client-model.json@ is held to this+fold by "Test.ClientModelSpec", unchanged.+-}+module Test.ClientModelStepSpec (tests) where++import Control.Exception (evaluate)+import Data.Aeson (Value (..), object, (.=))+import qualified Data.Aeson.Key as Key+import Data.List (foldl', nub)+import qualified Data.Map.Strict as Map+import Data.Maybe (isJust)+import qualified Data.Set as Set+import Data.Text (Text)+import qualified Data.Text as Text+import Data.Word (Word64)+import System.Timeout (timeout)+import Test.Tasty (TestTree, localOption, testGroup)+import Test.Tasty.HUnit (assertBool, assertEqual, assertFailure, testCase)+import Test.Tasty.QuickCheck (+    Arbitrary (..),+    Gen,+    Property,+    QuickCheckTests (..),+    checkCoverage,+    choose,+    conjoin,+    counterexample,+    cover,+    elements,+    frequency,+    listOf,+    oneof,+    property,+    shrinkList,+    shuffle,+    sized,+    sublistOf,+    testProperty,+    (===),+ )++import qualified Salmon.Client.Model as Model+import Salmon.Client.Model (RefId (..))++import qualified Test.ClientModelReference as Reference++tests :: TestTree+tests =+    testGroup+        "Salmon.Client.Model.step: removal without filtering the order"+        [ testGroup+            "unit"+            [ testCase "removing the first, a middle and the last node keeps the others in place" removesEachPosition+            , testCase "a done about a node that is absent changes neither the nodes nor the order" absentNodeChangesNothing+            , testCase "a full teardown empties the model, first to last and last to first" fullTeardown+            , testCase "a removed node is shown again, in the snapshot's place, once a re-read snapshot has it" removedThenReread+            , testCase "a ref a snapshot lists twice is shown twice and removed from both places" repeatedRef+            , testCase "an Order reads as its list, and equality is what is read" orderUnit+            ]+        , localOption (QuickCheckTests cases) $+            testGroup+                "properties"+                [ testProperty "step shows what the reference step shows, after every event of a script" prop_stepMatchesReference+                , testProperty "the order is the last snapshot's less the dropped nodes: no removed node, no duplicate" prop_orderInvariant+                , testProperty "a teardown in any order agrees with the reference at every step and ends empty" prop_teardownInAnyOrder+                , testProperty "Order: toList . fromList is the list" prop_orderRoundTrips+                , testProperty "Order: removals are the filters they replace" prop_orderRemoveIsFilter+                , testProperty "Order: two orders are equal exactly when they read the same" prop_orderEquality+                ]+        , testProperty "the generated scripts reach removals, re-read removed nodes and absent nodes" prop_scriptsReachTheCases+        , testCase "a teardown of a hundred thousand nodes folds within a minute" teardownAtScale+        ]++-- | Cases per property (QuickCheck's default is a hundred): the folds are+-- pure and a script is a hundred-odd steps at most, so this stays at a few+-- seconds each (the comparison shows every node at every step).+cases :: Int+cases = 1000++-------------------------------------------------------------------------------+-- wire objects++refOf :: Text -> RefId+refOf short = RefId short ("full-" <> short)++refValue :: Text -> Value+refValue short = object ["short" .= short, "full" .= ("full-" <> short)]++nodeValue :: Text -> Text -> Text -> Value+nodeValue direction convergence short =+    object+        [ "ref" .= refValue short+        , "shorthand" .= ("node " <> short)+        , "help" .= ("" :: Text)+        , "notes" .= ([] :: [Text])+        , "direction" .= direction+        , "convergence" .= convergence+        , "status" .= Null+        , "paths" .= (["/" <> short] :: [Text])+        , "dynamics" .= ([] :: [Text])+        , "dependencies" .= ([] :: [Value])+        , "dependants" .= ([] :: [Value])+        ]++snapshotValue :: Word64 -> [Value] -> Value+snapshotValue seqNo nodes = object ["mode" .= ("interactive" :: Text), "seq" .= seqNo, "nodes" .= nodes]++-- | A snapshot of nodes all wanted down, in this order.+retiring :: Word64 -> [Text] -> Value+retiring seqNo shorts = snapshotValue seqNo (fmap (nodeValue "down" "pending") shorts)++updown :: Text -> Text -> Word64 -> Value+updown kind short seqNo =+    object ["stream" .= ("updown" :: Text), "kind" .= kind, "seq" .= seqNo, "ref" .= refValue short]++-------------------------------------------------------------------------------+-- the two folds, side by side++-- | One step of a script: an event off the stream, or a snapshot re-read+-- and joined to the model that has been folding ('Model.rebase').+data Action+    = Fold Value+    | Reread Value+    deriving (Show)++-- | The first snapshot, then what follows it.+data Script = Script Value [Action]+    deriving (Show)++startNew :: Value -> Model.Model+startNew = either (error . ("fromDag: " <>)) id . Model.fromDag++startOld :: Value -> Reference.Model+startOld = either (error . ("reference fromDag: " <>)) id . Reference.fromDag++applyNew :: Model.Model -> Action -> Model.Model+applyNew m (Fold v) = Model.step m (Model.eventOf v)+applyNew m (Reread s) = Model.rebase m (startNew s)++applyOld :: Reference.Model -> Action -> Reference.Model+applyOld m (Fold v) = Reference.step m (Reference.eventOf v)+applyOld m (Reread s) = Reference.rebase m (startOld s)++-- | Every model a script goes through, the first snapshot's included.+runNew :: Script -> [Model.Model]+runNew (Script s actions) = scanl applyNew (startNew s) actions++runOld :: Script -> [Reference.Model]+runOld (Script s actions) = scanl applyOld (startOld s) actions++{- | Everything a client reads off a model. The reference has types of its+own, with the constructors and field names of the real ones, so a node, a+pass and an event are compared by what they 'show': every field of every+node is in there.+-}+data Seen = Seen+    { seenInOrder :: [String]+    -- ^ the nodes as shown, first to last ('nodesInOrder')+    , seenOrder :: [(Text, Text)]+    -- ^ the order itself, which may name what the node map does not+    , seenNodes :: [(Text, Text)]+    -- ^ the refs of the node map (the nodes themselves are in 'seenInOrder')+    , seenCounts :: (Int, Int, Int)+    , seenMode :: Text+    , seenSeq :: Word64+    , seenLoopSeq :: Word64+    , seenPass :: String+    , seenSupervised :: Maybe Bool+    , seenLast :: String+    , seenResync :: Maybe Text+    }+    deriving (Eq, Show)++seenNew :: Model.Model -> Seen+seenNew m =+    Seen+        { seenInOrder = fmap show (Model.nodesInOrder m)+        , seenOrder = [(r.refShort, r.refFull) | r <- Model.orderToList m.modelOrder]+        , seenNodes = [(r.refShort, r.refFull) | r <- Map.keys m.modelNodes]+        , seenCounts = let c = Model.counts m in (c.countConverged, c.countErrored, c.countTotal)+        , seenMode = m.modelMode+        , seenSeq = m.modelSeq+        , seenLoopSeq = m.modelLoopSeq+        , seenPass = show m.modelPass+        , seenSupervised = m.modelSupervised+        , seenLast = show m.modelLast+        , seenResync = Model.modelResync m+        }++seenOld :: Reference.Model -> Seen+seenOld m =+    Seen+        { seenInOrder = fmap show (Reference.nodesInOrder m)+        , seenOrder = [(r.refShort, r.refFull) | r <- m.modelOrder]+        , seenNodes = [(r.refShort, r.refFull) | r <- Map.keys m.modelNodes]+        , seenCounts = let c = Reference.counts m in (c.countConverged, c.countErrored, c.countTotal)+        , seenMode = m.modelMode+        , seenSeq = m.modelSeq+        , seenLoopSeq = m.modelLoopSeq+        , seenPass = show m.modelPass+        , seenSupervised = m.modelSupervised+        , seenLast = show m.modelLast+        , seenResync = m.modelResyncReason+        }++-------------------------------------------------------------------------------+-- generators++-- | Few enough refs that a script comes back to the same node many times:+-- updated, removed, removed again, read again from a later snapshot.+pool :: [Text]+pool = ["n" <> Text.pack (show i) | i <- [0 .. 7 :: Int]]++-- | A ref no snapshot has.+ghost :: Text+ghost = "ghost"++genShort :: Gen Text+genShort = frequency [(12, elements pool), (1, pure ghost)]++{- | A snapshot at this number: mostly a shuffled part of the pool, each+ref once, as @\/dag@ answers; one time in ten any list over the pool, so+that refs repeat (not something a server sends, but the fold is total over+it and removal has to stay what it was there too). Three nodes in four are+wanted down, since those are the ones a @done@ removes.+-}+genSnapshot :: Word64 -> Gen Value+genSnapshot seqNo = do+    shorts <- frequency [(9, sublistOf pool >>= shuffle), (1, listOf (elements pool))]+    snapshotValue seqNo <$> traverse genNode shorts+  where+    genNode short = do+        direction <- frequency [(3, pure "down"), (1, pure "up")]+        convergence <- elements ["pending", "stale", "converged", "errored", "blocked"]+        pure (nodeValue direction convergence short)++{- | One event, numbered or not. Every stream and kind the fold names, a+kind it does not (@novel@), an event with no ref, and a shapeless object;+weighted towards @done@, the one that removes.+-}+genEvent :: Maybe Word64 -> Gen Value+genEvent seqNo =+    numbered+        <$> frequency+            [ (10, genUpdown >>= \(kind, rest) -> about "updown" kind rest <$> genShort)+            , (3, acted <$> genUpdown <*> genShort)+            , (2, about "upkeep" "next-look" <$> ((: []) . ("check" .=) <$> genCheck) <*> genShort)+            , (1, about "upkeep" <$> elements ["parked", "hung-up", "novel"] <*> pure [] <*> genShort)+            , (2, about "output" "output" <$> genLine <*> genShort)+            , (2, about "output" "log" <$> ((<>) <$> genLine <*> genChannel) <*> genShort)+            , (3, genServe)+            , (1, (\(n :: Int) -> ["stream" .= ("server" :: Text), "kind" .= ("gap" :: Text), "from" .= n]) <$> choose (0, 50))+            , (1, pure ["stream" .= ("updown" :: Text), "kind" .= ("done" :: Text)])+            , (1, pure [])+            ]+  where+    numbered :: [(Key.Key, Value)] -> Value+    numbered fields = object (maybe fields (\s -> ("seq" .= s) : fields) seqNo)++    about :: Text -> Text -> [(Key.Key, Value)] -> Text -> [(Key.Key, Value)]+    about stream kind rest short = ["stream" .= stream, "kind" .= kind, "ref" .= refValue short] ++ rest++    -- what a tending machine did, in the pass's vocabulary: the inner+    -- object carries the kind and the ref+    acted :: (Text, [(Key.Key, Value)]) -> Text -> [(Key.Key, Value)]+    acted (kind, rest) short =+        ["stream" .= ("upkeep" :: Text), "kind" .= ("acted" :: Text), "report" .= object (about "updown" kind rest short)]++    genUpdown :: Gen (Text, [(Key.Key, Value)])+    genUpdown =+        frequency+            [ (7, pure ("done", []))+            , (1, pure ("skip", []))+            , (1, pure ("failed", ["error" .= ("boom" :: Text)]))+            , (1, pure ("blocked", []))+            , (1, pure ("eval", []))+            , (1, pure ("novel", []))+            ]++    genCheck :: Gen Value+    genCheck = do+        verdict <- elements ["success", "failure", "unknown" :: Text]+        reason <- elements [[], ["reason" .= ("because" :: Text)]]+        pure (object (("verdict" .= verdict) : reason))++    genLine :: Gen [(Key.Key, Value)]+    genLine = oneof [pure [], (\(n :: Int) -> ["line" .= ("line " <> Text.pack (show n))]) <$> choose (0, 9)]++    genChannel :: Gen [(Key.Key, Value)]+    genChannel = elements [[], ["channel" .= ("stderr" :: Text)], ["channel" .= ("message" :: Text)]]++    genServe :: Gen [(Key.Key, Value)]+    genServe = do+        (a :: Int) <- choose (0, 9)+        (b :: Int) <- choose (0, 9)+        on <- elements [True, False]+        (("stream" .= ("serve" :: Text)) :)+            <$> elements+                [ ["kind" .= ("declared" :: Text), "epoch" .= a, "direction" .= ("down" :: Text)]+                , ["kind" .= ("cleared" :: Text)]+                , ["kind" .= ("converge-start" :: Text), "down" .= a, "up" .= b]+                , ["kind" .= ("converge-stop" :: Text), "ok" .= on, "remaining" .= a]+                , ["kind" .= ("supervised" :: Text), "on" .= on]+                , ["kind" .= ("novel" :: Text)]+                ]++{- | What follows a snapshot at @cursor@: mostly events numbered just past+the last (as the stream numbers them), some numbered at or below it (a+replay, which each part of the model drops by its own stamp), a few with+no number; and now and then a re-read snapshot, at the cursor or behind+it, which is how a node the fold removed comes back.+-}+genActions :: Word64 -> Int -> Gen [Action]+genActions _ 0 = pure []+genActions cursor k = do+    (action, cursor') <- frequency [(12, event), (1, reread)]+    (action :) <$> genActions cursor' (k - 1)+  where+    event = do+        seqNo <-+            frequency+                [ (8, Just . (cursor +) <$> choose (1, 2))+                , (2, Just <$> choose (0, cursor))+                , (1, pure Nothing)+                ]+        v <- genEvent seqNo+        pure (Fold v, maybe cursor (max cursor) seqNo)+    reread = do+        seqNo <- frequency [(3, pure cursor), (1, choose (0, cursor))]+        v <- genSnapshot seqNo+        pure (Reread v, cursor)++instance Arbitrary Script where+    arbitrary = sized $ \size -> do+        seqNo <- choose (0, 5)+        first <- genSnapshot seqNo+        k <- choose (0, 10 + size)+        Script first <$> genActions seqNo k++    -- drop steps; the events themselves are not shrunk+    shrink (Script first actions) = Script first <$> shrinkList (const []) actions++-- | A ref from the pool or the ghost, for the properties of 'Model.Order' alone.+newtype SmallRef = SmallRef RefId+    deriving (Show)++instance Arbitrary SmallRef where+    arbitrary = SmallRef . refOf <$> genShort++unsmall :: [SmallRef] -> [RefId]+unsmall xs = [r | SmallRef r <- xs]++-------------------------------------------------------------------------------+-- properties++-- | The bar: whatever the script, the model shows after each step what the+-- fold that filtered a list shows.+prop_stepMatchesReference :: Script -> Property+prop_stepMatchesReference script =+    conjoin+        [ counterexample ("after " <> show k <> " steps") (seenNew new === seenOld old)+        | (k, new, old) <- zip3 [0 :: Int ..] (runNew script) (runOld script)+        ]++{- | Without the reference: at every step the order is the refs of the+last snapshot read, in its order, less exactly the nodes the model no+longer has. So it never holds a removed node, it names every node the+model has, and it repeats a ref only if the snapshot did.+-}+prop_orderInvariant :: Script -> Property+prop_orderInvariant (Script first actions) =+    conjoin+        [ counterexample ("after " <> show k <> " steps") $+            conjoin+                [ counterexample "the order is the snapshot's less the dropped" (order === filter (`Map.member` m.modelNodes) base)+                , counterexample "every node is in the order" (Set.fromList order === Map.keysSet m.modelNodes)+                , counterexample "no duplicate the snapshot did not have" (property (nub base /= base || nub order == order))+                , counterexample "the size is the list's" (Model.orderSize m.modelOrder === length order)+                ]+        | (k, (base, m)) <- zip [0 :: Int ..] states+        , let order = Model.orderToList m.modelOrder+        ]+  where+    states = scanl advance (listed first, startNew first) actions+    advance (base, m) action = case action of+        Fold _ -> (base, applyNew m action)+        Reread s -> (listed s, applyNew m action)+    -- the refs a snapshot lists, in its order+    listed = Model.orderToList . Model.modelOrder . startNew++{- | A teardown: every node wanted down, one @done@ each, in an order of+its own (a pass brings nodes down in dependency order, tending machines in+any). Up to two hundred nodes; the order and the nodes shown are+compared after every removal, everything else at the end.+-}+data Teardown = Teardown [Text] [Text]+    deriving (Show)++instance Arbitrary Teardown where+    arbitrary = sized $ \size -> do+        n <- choose (0, 2 * size)+        let shorts = ["t" <> Text.pack (show i) | i <- [1 .. n]]+        Teardown shorts <$> shuffle shorts+    shrink (Teardown shorts gone) =+        [Teardown (filter (/= s) shorts) (filter (/= s) gone) | s <- shorts]++prop_teardownInAnyOrder :: Teardown -> Property+prop_teardownInAnyOrder (Teardown shorts gone) =+    conjoin+        [ conjoin+            [ counterexample ("after " <> show k <> " removals") $+                conjoin+                    [ Model.orderToList new.modelOrder === [refOf short | Reference.RefId short _ <- old.modelOrder]+                    , shown new === [n.nodeRef.refShort | n <- Reference.nodesInOrder old]+                    ]+            | (k, new, old) <- zip3 [0 :: Int ..] news olds+            ]+        , counterexample "the same model at the end" (seenNew (last news) === seenOld (last olds))+        , counterexample "nothing is left" (Model.orderToList (last news).modelOrder === [])+        , counterexample "no node is left" (Map.size (last news).modelNodes === 0)+        ]+  where+    script = Script (retiring 0 shorts) [Fold (updown "done" s i) | (i, s) <- zip [1 ..] gone]+    news = runNew script+    olds = runOld script++prop_orderRoundTrips :: [SmallRef] -> Property+prop_orderRoundTrips xs =+    conjoin+        [ Model.orderToList (Model.orderFromList refs) === refs+        , Model.orderSize (Model.orderFromList refs) === length refs+        ]+  where+    refs = unsmall xs++-- | Any run of removals (of refs shown once, shown twice, already removed+-- or never there) reads as the same run of filters over the list.+prop_orderRemoveIsFilter :: [SmallRef] -> [SmallRef] -> Property+prop_orderRemoveIsFilter xs gone =+    fmap Model.orderToList (scanl (flip Model.orderRemove) (Model.orderFromList refs) (unsmall gone))+        === scanl (\rs r -> filter (/= r) rs) refs (unsmall gone)+  where+    refs = unsmall xs++-- | Equality sees the reading and not the seats: an order with a ref+-- removed is the order built without it.+prop_orderEquality :: [SmallRef] -> [SmallRef] -> SmallRef -> Property+prop_orderEquality xs ys (SmallRef r) =+    conjoin+        [ (Model.orderFromList a == Model.orderFromList b) === (a == b)+        , Model.orderRemove r (Model.orderFromList a) === Model.orderFromList (filter (/= r) a)+        ]+  where+    a = unsmall xs+    b = unsmall ys++{- | The scripts are only worth the equivalence they are run for if they+get to the cases: a node removed by the fold, a removed node shown again+after a re-read, and a @done@ about a node the model does not have (the+ghost, or one already removed). 'checkCoverage' runs as many cases as it+takes to be sure of the shares below.+-}+prop_scriptsReachTheCases :: Script -> Property+prop_scriptsReachTheCases script@(Script _ actions) =+    checkCoverage $+        cover 50 (removals > 0) "a node was removed" $+            cover 10 (removals > 3) "more than three nodes were removed" $+                cover 25 cameBack "a removed node was read again" $+                    cover 50 strangers "a done about a node that is not there" $+                        property True+  where+    states = runNew script+    steps = zip3 states actions (drop 1 states)+    keys :: Model.Model -> Set.Set RefId+    keys = Map.keysSet . Model.modelNodes+    dropped = [keys before `Set.difference` keys after | (before, Fold _, after) <- steps]+    removals = sum (fmap Set.size dropped)+    strangers =+        or+            [ maybe False (`Set.notMember` keys before) e.eventRef+            | (before, Fold v, _) <- steps+            , let e = Model.eventOf v+            , e.eventStream == "updown" && e.eventKind == "done"+            ]+    -- (refs the fold has removed and no snapshot has brought back, seen one come back)+    cameBack = snd (foldl' track (Set.empty, False) steps)+    track (gone, seen) (before, action, after) = case action of+        Fold _ -> (gone `Set.union` (keys before `Set.difference` keys after), seen)+        Reread _ -> (gone `Set.difference` keys after, seen || not (Set.null (gone `Set.intersection` keys after)))++-------------------------------------------------------------------------------+-- unit cases++shown :: Model.Model -> [Text]+shown = fmap (refShort . Model.nodeRef) . Model.nodesInOrder++foldNew :: Model.Model -> [Value] -> Model.Model+foldNew = foldl' (\m v -> Model.step m (Model.eventOf v))++four :: Model.Model+four = startNew (retiring 0 ["a", "b", "c", "d"])++removesEachPosition :: IO ()+removesEachPosition = do+    assertEqual "the snapshot's order" ["a", "b", "c", "d"] (shown four)+    let without short = foldNew four [updown "done" short 1]+    assertEqual "the first" ["b", "c", "d"] (shown (without "a"))+    assertEqual "a middle one" ["a", "c", "d"] (shown (without "b"))+    assertEqual "the other middle one" ["a", "b", "d"] (shown (without "c"))+    assertEqual "the last" ["a", "b", "c"] (shown (without "d"))+    let m = without "b"+    assertEqual "the order is what is shown" [refOf "a", refOf "c", refOf "d"] (Model.orderToList m.modelOrder)+    assertEqual "and its size" 3 (Model.orderSize m.modelOrder)+    assertEqual "the node is gone from the map too" Nothing (Model.lookupNode (refOf "b") m)+    assertEqual "three counted" 3 (Model.countTotal (Model.counts m))+    -- one after another, the rest keep their places+    assertEqual "two removed" ["a", "d"] (shown (foldNew four [updown "done" "c" 1, updown "done" "b" 2]))+    -- an eval does not remove, nor a failed+    assertEqual "only a done removes" ["a", "b", "c", "d"] (shown (foldNew four [updown "eval" "b" 1, updown "failed" "c" 2]))++absentNodeChangesNothing :: IO ()+absentNodeChangesNothing = do+    let m = foldNew four [updown "done" "ghost" 1]+    assertEqual "the order" (Model.orderToList four.modelOrder) (Model.orderToList m.modelOrder)+    assertEqual "the nodes" four.modelNodes m.modelNodes+    assertEqual "the cursor still moves" 1 m.modelSeq+    -- and a node already removed is such a node: a second done, numbered+    -- later, finds nothing to remove+    let once = foldNew four [updown "done" "b" 1]+        twice = foldNew once [updown "done" "b" 2]+    assertEqual "removed once" ["a", "c", "d"] (shown twice)+    assertEqual "the nodes are as after the first" once.modelNodes twice.modelNodes+    assertEqual "the order too" once.modelOrder twice.modelOrder++fullTeardown :: IO ()+fullTeardown = do+    let shorts = ["a", "b", "c", "d"]+        forwards = scanl (\m (i, s) -> foldNew m [updown "done" s i]) four (zip [1 ..] shorts)+        backwards = scanl (\m (i, s) -> foldNew m [updown "done" s i]) four (zip [1 ..] (reverse shorts))+    assertEqual "first to last" [["a", "b", "c", "d"], ["b", "c", "d"], ["c", "d"], ["d"], []] (fmap shown forwards)+    assertEqual "last to first" [["a", "b", "c", "d"], ["a", "b", "c"], ["a", "b"], ["a"], []] (fmap shown backwards)+    let emptied = last forwards+    assertEqual "no order left" [] (Model.orderToList emptied.modelOrder)+    assertEqual "no node left" 0 (Map.size emptied.modelNodes)+    assertEqual "nothing counted" (Model.Counts 0 0 0) (Model.counts emptied)+    assertEqual "the cursor is the last done's" 4 emptied.modelSeq+    assertBool "the last event is kept" (isJust emptied.modelLast)++removedThenReread :: IO ()+removedThenReread = do+    let m = foldNew four [updown "done" "b" 1, updown "done" "d" 2]+    assertEqual "two gone" ["a", "c"] (shown m)+    -- the server still has b (declared again, say): the re-read snapshot+    -- lists it where the server orders it, and that is where it shows+    let again = Model.rebase m (startNew (retiring 5 ["c", "b", "a"]))+    assertEqual "b is back, in the fresh snapshot's order" ["c", "b", "a"] (shown again)+    -- and it can be removed again, by an event past the snapshot+    assertEqual "a replay of its old done is dropped" ["c", "b", "a"] (shown (foldNew again [updown "done" "b" 1]))+    assertEqual "a new done removes it" ["c", "a"] (shown (foldNew again [updown "done" "b" 6]))++repeatedRef :: IO ()+repeatedRef = do+    let m = startNew (retiring 0 ["a", "b", "a", "c"])+    assertEqual "shown twice" ["a", "b", "a", "c"] (shown m)+    assertEqual "one node for the two" 3 (Map.size m.modelNodes)+    assertEqual "removed from both places" ["b", "c"] (shown (foldNew m [updown "done" "a" 1]))+    assertEqual "the others keep theirs" ["a", "a", "c"] (shown (foldNew m [updown "done" "b" 1]))++orderUnit :: IO ()+orderUnit = do+    let refs = fmap refOf ["a", "b", "c"]+        order = Model.orderFromList refs+    assertEqual "reads as its list" refs (Model.orderToList order)+    assertEqual "size" 3 (Model.orderSize order)+    assertEqual "the empty order" [] (Model.orderToList (Model.orderFromList []))+    assertEqual "removing from the empty order" (Model.orderFromList []) (Model.orderRemove (refOf "a") (Model.orderFromList []))+    assertEqual "a removal equals the order built without the ref" (Model.orderFromList (fmap refOf ["a", "c"])) (Model.orderRemove (refOf "b") order)+    assertBool "the order of the refs matters" (Model.orderFromList (reverse refs) /= order)+    assertEqual "shown as the list it reads as" "orderFromList [RefId {refShort = \"a\", refFull = \"full-a\"}]" (show (Model.orderFromList [refOf "a"]))++-------------------------------------------------------------------------------+-- scale++{- | A hundred thousand nodes wanted down and a @done@ for each, in the+snapshot's order: the benchmark's teardown series, without the graph. The+fold that filtered the order took 1.5 to 2 seconds for ten thousand and+grew with the square (so minutes here); this one is expected well under a+second. The bound is as loose as "Test.GraphScaleSpec"'s, for the same+reason: the suite shares its process and its machine.+-}+teardownAtScale :: IO ()+teardownAtScale = do+    let size = 100000 :: Int+        shorts = ["s" <> Text.pack (show i) | i <- [1 .. size]]+        events = [Model.Event (Just i) "updown" "done" (Just (refOf s)) Nothing Null | (i, s) <- zip [1 ..] shorts]+    m0 <- evaluate (startNew (retiring 0 shorts))+    assertEqual "every node is in the snapshot" size (Model.orderSize m0.modelOrder)+    outcome <- timeout (60 * 1000000) (evaluate (foldl' Model.step m0 events))+    case outcome of+        Nothing -> assertFailure "still folding after 60s, which is what a pass over the order per removal looks like"+        Just m -> do+            assertEqual "nothing left in the order" 0 (Model.orderSize m.modelOrder)+            assertEqual "nor in the node map" 0 (Map.size m.modelNodes)+            assertEqual "the cursor is the last event's" (fromIntegral size) m.modelSeq
+ test/Test/ClipCppSpec.hs view
@@ -0,0 +1,274 @@+{-# LANGUAGE OverloadedStrings #-}++{- | Coverage for "Salmon.Builtin.Nodes.ClipCpp". __No clip.cpp was built and+no model was loaded__: everything here is Layer 0, plus 'embed' run against a+stub.++* The arguments are compared with what the upstream sources read (the build+  steps of the README, the option names of @CMakeLists.txt@, the flags of+  @examples\/common-clip.cpp@), which is not the same as having run them.+* 'fixture' is __hand-written__, following @writeNpyFile@ in+  @examples\/common-clip.cpp@ byte for byte. It is not a file a real+  @extract@ wrote.+* The stub is a shell script that behaves as @extract@ was read to behave:+  it writes its file into the current directory under upstream's names, and+  it exits 0 when it could not read an image.+-}+module Test.ClipCppSpec (tests) where++import qualified Data.ByteString as ByteString+import Data.ByteString (ByteString)+import Data.Functor.Identity (runIdentity)+import Data.List (nub, sort)+import qualified Data.Text as Text+import System.Directory (createDirectoryIfMissing, doesDirectoryExist, getCurrentDirectory)+import System.FilePath ((</>))+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, assertEqual, testCase)++import Salmon.Actions.Query (pathedNodes)+import Salmon.Actions.UpDown (CheckResult (..))+import Salmon.Builtin.Extension+import Salmon.Builtin.Nodes.ClipCpp+import Salmon.Builtin.Nodes.Debian.Package (Package (..))+import Salmon.Op.Eval (expand)+import Salmon.Reporter (silent)+import Test.Harness (requireExecutable, runUp, withTempDir, writeExecutable)++source :: ClipSource+source = clipSourceAt "/opt/clip"++model :: ModelFile+model = ModelFile "/var/lib/models/clip.gguf" "def456" Nothing++clip :: ClipCpp+clip = defaultClipCpp source model 512++-- | No tool is installed by anybody.+noTools :: ClipTools+noTools = ClipTools ignoreTrack ignoreTrack realNoop++{- | Hand-written: a NumPy 1.0 file as upstream's @writeNpyFile@ lays it out+(magic, version 1.0, a header length of 118, the dictionary padded with+spaces to byte 127, a newline, then the floats), holding @[0.25, -1.5, 3.0]@.+-}+fixture :: ByteString+fixture = npy "(1, 3)" [[0x00, 0x00, 0x80, 0x3e], [0x00, 0x00, 0xc0, 0xbf], [0x00, 0x00, 0x40, 0x40]]++npy :: ByteString -> [[Int]] -> ByteString+npy shape floats =+    mconcat+        [ ByteString.pack [0x93]+        , "NUMPY"+        , ByteString.pack [1, 0, 118, 0]+        , dict+        , ByteString.replicate (128 - (10 + ByteString.length dict + 1)) 0x20+        , ByteString.pack [0x0a]+        , ByteString.pack (fromIntegral <$> concat floats)+        ]+  where+    dict = "{'descr': '<f4', 'fortran_order': False, 'shape': " <> shape <> ", }"++tests :: TestTree+tests =+    testGroup+        "Salmon.Builtin.Nodes.ClipCpp"+        [ testGroup+            "build arguments"+            [ testCase "the commit is fetched by name, checked out detached, then its submodules" $+                assertEqual+                    ""+                    [ ["init", "--quiet", "/opt/clip/src"]+                    , ["-C", "/opt/clip/src", "fetch", "--depth", "1", "https://github.com/monatis/clip.cpp.git", "913458d5d1c9238380a0b0826cd1e71c8828a82d"]+                    , ["-C", "/opt/clip/src", "checkout", "--quiet", "--detach", "--force", "913458d5d1c9238380a0b0826cd1e71c8828a82d"]+                    , ["-C", "/opt/clip/src", "submodule", "update", "--init", "--recursive", "--force"]+                    ]+                    (fetchArgs source)+            , testCase "configure: native as declared, upstream's benchmark left out" $ do+                assertEqual "" ["-S", "/opt/clip/src", "-B", "/opt/clip/build", "-DCLIP_NATIVE=ON", "-DCLIP_BUILD_TESTS=OFF"] (configureArgs source)+                assertEqual "" "-DCLIP_NATIVE=OFF" (configureArgs source{clipNative = False} !! 4)+            , testCase "only the extract target is compiled, one job unless told" $ do+                assertEqual "" ["--build", "/opt/clip/build", "--target", "extract"] (compileArgs source)+                assertEqual "" ["--parallel", "3"] (drop 4 (compileArgs source{clipJobs = Just 3}))+            , testCase "the binary is in the build directory's bin" $+                assertEqual "" "/opt/clip/build/bin/extract" (clipExtractBinary source)+            , testCase "only a full commit id is a pin" $ do+                assertBool "the known one" (validCommit knownCommit)+                assertBool "a branch" (not (validCommit "main"))+                assertBool "an abbreviation" (not (validCommit "913458d5"))+                assertBool "forty characters that are not hex" (not (validCommit (Text.replicate 40 "z")))+            , testCase "the stamp names the commit, the remote and the options" $ do+                assertEqual+                    ""+                    "clip.cpp 913458d5d1c9238380a0b0826cd1e71c8828a82d\nfrom https://github.com/monatis/clip.cpp.git\n-DCLIP_NATIVE=ON -DCLIP_BUILD_TESTS=OFF\n"+                    (buildStamp source)+                assertBool "another commit" (buildStamp source /= buildStamp source{clipCommit = Text.replicate 40 "a"})+                assertBool "other options" (buildStamp source /= buildStamp source{clipNative = False})+                assertEqual "more jobs is the same build" (buildStamp source) (buildStamp source{clipJobs = Just 8})+            ]+        , testGroup+            "the build's check and its refusals"+            [ testCase "nothing built, then a stamp without a binary, then both, then another pin" $ withTempDir $ \tmp -> do+                let src = clipSourceAt tmp+                assertEqual "" (Failure ("no build of clip.cpp in " <> Text.pack tmp)) =<< clipBuildCheck src+                createDirectoryIfMissing True (tmp </> "build" </> "bin")+                writeFile (stampPath src) (Text.unpack (buildStamp src))+                assertEqual "" (Failure ("no binary at " <> Text.pack (clipExtractBinary src))) =<< clipBuildCheck src+                writeFile (clipExtractBinary src) ""+                assertEqual "" Success =<< clipBuildCheck src+                assertEqual+                    ""+                    (Failure "the build is of another commit or of other options")+                    =<< clipBuildCheck src{clipCommit = Text.replicate 40 "a"}+            , testCase "a branch name fails the node before any command runs" $ withTempDir $ \tmp -> do+                ok <- runUp (clipBuild silent noTools (clipSourceAt tmp){clipCommit = "main"})+                assertBool "refused" (not ok)+                assertBool "nothing was fetched" . not =<< doesDirectoryExist (tmp </> "src")+            ]+        , testGroup+            "extract arguments"+            [ testCase "a text" $+                assertEqual "" ["-m", "/var/lib/models/clip.gguf", "-v", "0", "--text", "a red apple"] (extractArgs clip (EmbedText "a red apple"))+            , testCase "an image, and threads when given" $+                assertEqual+                    ""+                    ["-m", "/var/lib/models/clip.gguf", "-v", "0", "-t", "2", "--image", "/data/a.jpg"]+                    (extractArgs clip{ccThreads = Just 2} (EmbedImage "/data/a.jpg"))+            , testCase "a text that looks like an option is still the value of --text" $+                assertEqual "" ["--text", "--image"] (drop 4 (extractArgs clip (EmbedText "--image")))+            , testCase "the files upstream names" $ do+                assertEqual "" "text_vec_0.npy" (outputName (EmbedText "anything"))+                assertEqual "" "img_vec_a.jpg.npy" (outputName (EmbedImage "/data/a.jpg"))+            , testCase "the check's image is upstream's own, from the checkout" $+                assertEqual "" (Just "/opt/clip/src/tests/white.jpg") (ccProbeImage clip)+            ]+        , testGroup+            "the .npy file (hand-written fixture)"+            [ testCase "upstream's layout is 128 bytes of header" $+                assertEqual "" (128 + 12) (ByteString.length fixture)+            , testCase "the row is read" $+                assertEqual "" (Right [0.25, -1.5, 3.0]) (parseNpy fixture)+            , testCase "something else is refused" $+                assertEqual "" (Left "not a NumPy file") (parseNpy "Processing: 100.00%\n")+            , testCase "a file cut short is refused, with both counts" $+                assertEqual+                    ""+                    (Left "the header announces 3 floats and 8 bytes follow")+                    (parseNpy (ByteString.take (128 + 8) fixture))+            , testCase "more than one row is refused" $+                assertEqual "" (Left "not a (1, N) array") (parseNpy (npy "(2, 3)" (replicate 6 [0, 0, 0, 0])))+            , testCase "a NaN is refused, since pgvector would refuse it" $+                assertEqual+                    ""+                    (Left "the vector holds a value that is not finite")+                    (parseNpy (npy "(1, 1)" [[0x00, 0x00, 0xc0, 0x7f]]))+            , testCase "as pgvector reads it" $+                assertEqual "" "[0.25,-1.5,3.0]" (pgvectorLiteral [0.25, -1.5, 3.0])+            ]+        , testGroup+            "verdicts"+            [ testCase "both towers at the declared width" $+                assertEqual "" Success (interpretVectors 3 (Right [1, 2, 3]) (Just (Right [4, 5, 6])))+            , testCase "no probe image, text alone decides" $+                assertEqual "" Success (interpretVectors 3 (Right [1, 2, 3]) Nothing)+            , testCase "another width names both" $+                assertEqual+                    ""+                    (Failure "the model produces 3 dimensions for text, 512 declared")+                    (interpretVectors 512 (Right [1, 2, 3]) Nothing)+            , testCase "a vision tower of another width" $+                assertEqual+                    ""+                    (Failure "the model produces 2 dimensions for image, 3 declared")+                    (interpretVectors 3 (Right [1, 2, 3]) (Just (Right [1, 2])))+            , testCase "a tower that gave nothing" $+                assertEqual "" (Failure "image: extract wrote no vector: x") (interpretVectors 3 (Right [1, 2, 3]) (Just (Left "extract wrote no vector: x")))+            ]+        , testGroup+            "graph"+            [ testCase "the build, the model and the build's directory are under the node" $ do+                assertEqual+                    ""+                    [ "clip.cpp produces 512-dimensional vectors for text and images"+                    , "builds clip.cpp 913458d5d1c9 in /opt/clip"+                    , "ensures /opt/clip exists, including subdirs"+                    , "GGUF model at /var/lib/models/clip.gguf"+                    ]+                    (helps (clipCpp silent noTools clip))+            , testCase "debianTools brings git, cmake and a compiler, and nothing else" $+                assertEqual+                    ""+                    (sort [Package "build-essential", Package "cmake", Package "git"])+                    (sort (nub (packagesOf (clipCpp silent debianTools clip))))+            , testCase "with no tools there is no package in the graph" $+                assertEqual "" [] (packagesOf (clipCpp silent noTools clip))+            ]+        , testCase "embed, against a stub that behaves as extract was read to" stubbed+        ]+  where+    packagesOf :: Op -> [Package]+    packagesOf = concatMap snd . collectDynamics+    helps :: Op -> [Text.Text]+    helps o = [h | (_, _, h) <- pathedNodes (runIdentity (expand o))]++{- | A shell script in place of the binary. It logs its directory and+arguments, writes the fixture under the name upstream would use, and for an+image whose name holds @unreadable@ says so and exits 0, as upstream does.+-}+stubScript :: FilePath -> FilePath -> String+stubScript logFile fixtureFile =+    unlines+        [ "#!/bin/sh"+        , "printf '%s\\n' \"$PWD\" \"$@\" > '" <> logFile <> "'"+        , "out=''"+        , "while [ $# -gt 0 ]; do"+        , "  case \"$1\" in"+        , "    --text) out='text_vec_0.npy' ;;"+        , "    --image) case \"$2\" in"+        , "        *unreadable*) echo \"main: failed to load image from '$2'\" >&2 ;;"+        , "        *) out=\"img_vec_$(basename \"$2\").npy\" ;;"+        , "      esac ;;"+        , "  esac"+        , "  shift 2"+        , "done"+        , "if [ -n \"$out\" ]; then cp '" <> fixtureFile <> "' \"./$out\"; fi"+        , "exit 0"+        ]++stubbed :: IO ()+stubbed = requireExecutable "sh" $ withTempDir $ \tmp -> do+    let src = clipSourceAt (tmp </> "clip")+        logFile = tmp </> "argv.log"+        fixtureFile = tmp </> "fixture.npy"+        image = tmp </> "white.jpg"+        c = (defaultClipCpp src (ModelFile (tmp </> "model.gguf") "unused" Nothing) 3){ccProbeImage = Just image}+    createDirectoryIfMissing True (tmp </> "clip" </> "build" </> "bin")+    ByteString.writeFile fixtureFile fixture+    writeFile image ""+    writeExecutable (clipExtractBinary src) (stubScript logFile fixtureFile)++    assertEqual "no model yet" (Left ("no model at " <> Text.pack (tmp </> "model.gguf"))) =<< embed c (EmbedText "x")+    writeFile (tmp </> "model.gguf") ""++    assertEqual "a text" (Right [0.25, -1.5, 3.0]) =<< embed c (EmbedText "a red apple")+    logged <- lines <$> readFile logFile+    here <- getCurrentDirectory+    assertEqual "what it was given" ["-m", tmp </> "model.gguf", "-v", "0", "--text", "a red apple"] (drop 1 logged)+    assertBool "it ran in a directory of its own" (take 1 logged /= [here])+    assertBool "which is gone" . not =<< doesDirectoryExist (concat (take 1 logged))++    assertEqual "an image" (Right [0.25, -1.5, 3.0]) =<< embed c (EmbedImage image)+    assertEqual+        "exit 0 and no file is a failure"+        (Left ("extract wrote no vector: main: failed to load image from '" <> Text.pack (tmp </> "unreadable.jpg") <> "'"))+        =<< embed c (EmbedImage (tmp </> "unreadable.jpg"))++    assertEqual "both towers, the declared width" Success =<< clipCheck c+    assertEqual+        "another width declared"+        (Failure "the model produces 3 dimensions for text, 512 declared")+        =<< clipCheck c{ccDimension = 512}+    assertEqual+        "a vision tower that gives nothing"+        (Failure ("image: extract wrote no vector: main: failed to load image from '" <> Text.pack (tmp </> "unreadable.jpg") <> "'"))+        =<< clipCheck c{ccProbeImage = Just (tmp </> "unreadable.jpg")}
test/Test/DaemonSpec.hs view
@@ -51,6 +51,7 @@         , testCase "a process ignoring SIGTERM is escalated to SIGKILL" escalatesToKill         , testCase "the whole process group goes, not just the leader" killsTheGroup         , testCase "output reaches the node while the process is still running" capturesOutput+        , testCase "the process does not inherit the supervisor's standard input" detachedFromStdin         , testCase "a one-shot driver is told it cannot run this" upRefuses         ] @@ -130,6 +131,17 @@     _ <- timeout 400000 (awaitLines ls (\xs -> length xs > before))     after <- length <$> atomically (readTVar ls)     assertEqual "it stopped talking once the machine was cancelled" before after++{- | Under @serve@ the supervisor's standard input is the command channel, so+a service that reads its own would eat commands. The process is asked what+its descriptor 0 is, which holds whatever this test's own input happens to be.+-}+detachedFromStdin :: IO ()+detachedFromStdin = within 20 $ do+    seen <- supervising (sh "readlink /proc/self/fd/0; while true; do sleep 0.05; done") $ \ls -> do+        awaitLines ls (not . null)+        reverse <$> atomically (readTVar ls)+    assertEqual "descriptor 0 of the daemon" ["/dev/null"] (take 1 seen)  {- | The case @cancel@ alone cannot handle, and the reason the escalation is recovered from @f9d7116@ rather than left to 'System.Process.withCreateProcess':
+ test/Test/DagPerNodeSpec.hs view
@@ -0,0 +1,583 @@+{-# LANGUAGE OverloadedStrings #-}++{- | Layer 0: 'Salmon.Op.Dag.foldDag' visits each node once, and under its+stated precondition gives what the fold that visited every occurrence gave.++The old fold is kept as "Test.DagReference".'foldDagPerOccurrence' and is the+reference throughout. What "gives the same" means is 'disagreements': the+same nodes in the same order, the same dependencies and dependants in the+same order, representatives the conflict test cannot tell apart, and the+same contested 'Ref's with the same declarations taking part. The number of+'Salmon.Op.Dag.Conflict's is deliberately /not/ the same: the reference+records one each time the representative changes, the per-node fold one per+declaration that lost.++The precondition is "Test.DagReference".'coherent'. The inputs are:++* the "Test.GraphFixture" shapes, with colliding 'Ref's, which are coherent+  by construction (a node is a function of its number);+* small random recipes ('Recipe'), where 'Ref's and descriptions are drawn+  from small pools so that nodes collide, re-declare each other with+  different predecessors, and sit on cycles of 'Ref's, filtered to the+  coherent ones;+* the family of incoherent inputs ('Hidden') on which the two folds are+  known to differ, pinned as a difference.++Everything is pure and small: the reference is exponential in the node count+on a shape that shares. The one sizeable case folds a chain of a thousand+diamonds, which only a per-node fold can finish.+-}+module Test.DagPerNodeSpec (tests) where++import Control.Exception (evaluate)+import Control.Monad (forM_)+import Control.Monad.Identity (Identity (..))+import Data.List (nub, sortOn)+import qualified Data.Map.Strict as Map+import qualified Data.Set as Set+import Data.Text (Text)+import qualified Data.Text as Text+import System.Timeout (timeout)+import Test.QuickCheck (Gen, Property, choose, chooseInt, conjoin, counterexample, elements, forAll, frequency, label, oneof, property, shuffle, sublistOf, suchThat, vectorOf, (===))+import Test.Tasty (TestTree, localOption, testGroup)+import Test.Tasty.HUnit (assertBool, assertEqual, assertFailure, testCase)+import Test.Tasty.QuickCheck (QuickCheckTests (..), testProperty)++import qualified Salmon.Actions.UpDown as UpDown+import Salmon.Builtin.Extension (Extension, Op, check, deps, down, dynamics, evalDeps, help, nodeps, notes, op, ref, up)+import Salmon.Op.Actions (Act (..), Actions (..))+import qualified Salmon.Op.Dag as Dag+import Salmon.Op.Graph (Graph (..))+import Salmon.Op.OpGraph (OpGraph (..))+import Salmon.Op.Ref (Ref, mkRef)++import Test.DagReference (coherent, foldDagPerOccurrence, occurrences)+import Test.GraphFixture+import Test.Harness (capture)++tests :: TestTree+tests =+    testGroup+        "Salmon.Op.Dag per node"+        [ testGroup+            "unit"+            [ testCase "every fixture shape folds as the reference does" shapesMatchReference+            , testCase "a colliding ref: last writer wins, the loser is reported once" collidingRef+            , testCase "a contested node reached by many paths is one conflict, not one per flip" oneConflictPerLoser+            , testCase "the representative is the last declaration in the tree, not the last one walked" lastInTreeWins+            , testCase "three declarations of one ref: the last wins, each loser is reported against it" threeWriters+            , testCase "a re-declaration with different predecessors still merges its edges" edgesStillAccumulate+            , testCase "a cycle of refs folds, and is reported stuck" cycleOfRefs+            , testCase "opting out of the conflict test records none" optOut+            , testCase "known difference: a differing node below two declarations that look alike is not seen" hiddenBelowLookAlikes+            , testCase "a thousand diamonds fold, with one conflict per colliding ref" thousandDiamonds+            ]+        , testGroup+            "properties"+            [ localOption (QuickCheckTests 500) $+                testProperty "fixture graphs: equal to the per-occurrence fold" prop_fixturesMatchReference+            , localOption (QuickCheckTests 2000) $+                testProperty "coherent recipes: equal to the per-occurrence fold" prop_coherentRecipesMatchReference+            , localOption (QuickCheckTests 2000) $+                testProperty "any recipe: a well-formed part of what the per-occurrence fold finds" prop_anyRecipeIsWellFormedPart+            , localOption (QuickCheckTests 500) $+                testProperty "incoherent recipes of the known family: the hidden conflict is lost, first writer kept" prop_hiddenConflictIsLost+            ]+        ]++-------------------------------------------------------------------------------+-- The two folds, and what agreeing means.++perNode :: Op -> Dag.Dag Extension+perNode = Dag.foldDag Dag.sameRepresentative . evalDeps++reference :: Op -> Dag.Dag Extension+reference = foldDagPerOccurrence Dag.sameRepresentative . evalDeps++isCoherent :: Op -> Bool+isCoherent = coherent Dag.sameRepresentative . evalDeps++-- | The descriptions taking part in the conflicts over each 'Ref': the kept+-- and the replaced ones, as a set.+contestants :: Dag.Dag Extension -> Map.Map Ref [Dag.Representative]+contestants dag =+    fmap (sortOn show . nub) $+        Map.fromListWith+            (<>)+            [ (c.conflictRef, [Dag.representative c.conflictKept, Dag.representative c.conflictReplaced])+            | c <- Dag.dagConflicts dag+            ]++{- | Everything on which the per-node fold (second) differs from the+reference (first), over what a coherent input promises. Empty when they+agree.+-}+disagreements :: Dag.Dag Extension -> Dag.Dag Extension -> [String]+disagreements old new =+    concat+        [ ["order: " <> show (Dag.dagOrder old) <> " /= " <> show (Dag.dagOrder new) | Dag.dagOrder old /= Dag.dagOrder new]+        , ["dependencies: " <> show (Dag.dagDependencies old) <> " /= " <> show (Dag.dagDependencies new) | Dag.dagDependencies old /= Dag.dagDependencies new]+        , ["dependants: " <> show (Dag.dagDependants old) <> " /= " <> show (Dag.dagDependants new) | Dag.dagDependants old /= Dag.dagDependants new]+        , ["nodes: " <> show (Map.keys (Dag.dagNodes old)) <> " /= " <> show (Map.keys (Dag.dagNodes new)) | Map.keys (Dag.dagNodes old) /= Map.keys (Dag.dagNodes new)]+        , [ "representative of " <> show r <> ": " <> show (Dag.representative a) <> " /= " <> show (fmap Dag.representative b)+          | (r, a) <- Map.toList (Dag.dagNodes old)+          , let b = Map.lookup r (Dag.dagNodes new)+          , fmap Dag.representative b /= Just (Dag.representative a)+          ]+        , -- conflicts, as a set: the same refs are contested, by the same+          -- declarations.+          ["contestants: " <> show (contestants old) <> " /= " <> show (contestants new) | contestants old /= contestants new]+        , wellFormedConflicts new+        ]++{- | What the per-node fold promises about its conflicts on any input: for+each contested 'Ref', one conflict per declaration that lost, each against+the final representative.+-}+wellFormedConflicts :: Dag.Dag Extension -> [String]+wellFormedConflicts new =+    concat+        [ [ "conflict on " <> show c.conflictRef <> " does not keep the final representative"+          | c <- Dag.dagConflicts new+          , fmap Dag.representative (Dag.representativeOf new c.conflictRef) /= Just (Dag.representative c.conflictKept)+          ]+        , [ "conflict on " <> show c.conflictRef <> " replaces what it keeps"+          | c <- Dag.dagConflicts new+          , Dag.representative c.conflictKept == Dag.representative c.conflictReplaced+          ]+        , [ "conflicts on " <> show r <> ": " <> show n <> " for " <> show (length reps) <> " declarations"+          | (r, reps) <- Map.toList (contestants new)+          , let n = length [() | c <- Dag.dagConflicts new, c.conflictRef == r]+          , n /= length reps - 1+          ]+        ]++-- | The invariant of a 'Dag.Dag', and that one direction is the other's transpose.+malformed :: Dag.Dag Extension -> [String]+malformed dag =+    concat+        [ ["order repeats a ref" | nub order /= order]+        , ["nodes and order disagree" | Map.keysSet (Dag.dagNodes dag) /= Set.fromList order]+        , ["dependencies and order disagree" | Map.keysSet (Dag.dagDependencies dag) /= Set.fromList order]+        , ["dependants and order disagree" | Map.keysSet (Dag.dagDependants dag) /= Set.fromList order]+        , ["dependants is not the transpose of dependencies" | forward /= backward]+        ]+  where+    order = Dag.dagOrder dag+    forward = Set.fromList [(d, r) | (r, ds) <- Map.toList (Dag.dagDependencies dag), d <- ds]+    backward = Set.fromList [(d, r) | (d, rs) <- Map.toList (Dag.dagDependants dag), r <- rs]++-------------------------------------------------------------------------------+-- Unit tests.++refOf :: Text -> Ref+refOf = mkRef "dag-per-node-spec"++-- | A node described by its name and a @help@, over the given nodes.+named :: Text -> Text -> [Op] -> Op+named name described below = op name (deps below) $ \x -> x{ref = refOf name, help = described}++helpOf :: Dag.Dag Extension -> Text -> Maybe Text+helpOf dag name = fmap (\act -> act.extension.help) (Dag.representativeOf dag (refOf name))++conflictsOn :: Dag.Dag Extension -> Text -> [(Text, Text)]+conflictsOn dag name =+    [ (c.conflictKept.extension.help, c.conflictReplaced.extension.help)+    | c <- reverse (Dag.dagConflicts dag)+    , c.conflictRef == refOf name+    ]++shapesMatchReference :: IO ()+shapesMatchReference =+    forM_ shapes $ \shape ->+        forM_ [0, 1, 2, 3, 5, 7, 12, 20] $ \size ->+            forM_ [(AsVertices, 0), (AsVertices, 1), (AsConnect, 1 / 3), (AsOverlay, 1 / 2)] $ \(form, fraction) -> do+                let root = generateWith defaultOptions{optEdges = form, optCollisions = fraction} shape size 42+                    at = show shape <> " at " <> show size <> ", " <> show form <> ", colliding " <> show fraction+                assertBool (at <> ": the fixture is coherent") (isCoherent root)+                assertEqual at [] (disagreements (reference root) (perNode root))+  where+    shapes =+        [ Chain+        , Fan+        , Tree 1+        , Tree 2+        , Tree 3+        , Diamonds 1+        , Diamonds 2+        , Diamonds 3+        , Layered 1 1+        , Layered 3 1+        , Layered 4 2+        , Layered 3 3+        , RandomLayered 4 2+        , RandomLayered 5 3+        , RandomLayered 3 3+        ]++collidingRef :: IO ()+collidingRef = do+    let root = named "root" "" [named "contested" "first" [], named "contested" "second" []]+        dag = perNode root+    assertEqual "the later declaration is the representative" (Just "second") (helpOf dag "contested")+    assertEqual "one conflict: second kept, first replaced" [("second", "first")] (conflictsOn dag "contested")+    assertEqual "nothing else is contested" 1 (length (Dag.dagConflicts dag))+    assertEqual "as the reference" [] (disagreements (reference root) dag)++{- | The count that changed. @pair@ is a node and a differing declaration of+it, side by side; it sits under a parent reached by four paths. The+reference flips the representative at every occurrence after the first:+seven conflicts for one contested node. Per node it is one.+-}+oneConflictPerLoser :: IO ()+oneConflictPerLoser = do+    let parent = named "parent" "" [named "contested" "first" [], named "contested" "second" []]+        paths = [named ("via" <> Text.pack (show i)) "" [parent] | i <- [1 .. 4 :: Int]]+        root = named "root" "" paths+    assertEqual "the reference: one per flip" 7 (length (Dag.dagConflicts (reference root)))+    assertEqual "per node: one" [("second", "first")] (conflictsOn (perNode root) "contested")+    assertEqual "and otherwise as the reference" [] (disagreements (reference root) (perNode root))++{- | The walk meets @first@ (under @parent@), then @second@, and then skips+the second occurrence of @parent@. The last declaration in the tree is the+@first@ under that skipped occurrence, and that is the one kept: the walk+order does not leak into which declaration wins.+-}+lastInTreeWins :: IO ()+lastInTreeWins = do+    let parent = named "parent" "" [named "contested" "first" []]+        root = named "root" "" [parent, named "contested" "second" [], parent]+        dag = perNode root+    assertBool "coherent" (isCoherent root)+    assertEqual "the reference keeps the last in the tree" (Just "first") (helpOf (reference root) "contested")+    assertEqual "and so does the per-node fold" (Just "first") (helpOf dag "contested")+    assertEqual "reported against the declaration that lost" [("first", "second")] (conflictsOn dag "contested")+    assertEqual "the reference reported both flips" [("second", "first"), ("first", "second")] (conflictsOn (reference root) "contested")+    assertEqual "as the reference" [] (disagreements (reference root) dag)++{- | With more than two declarations the reference reports the chain of+flips (@b@ over @a@, @c@ over @b@); per node every loser is reported against+the declaration that ended up kept, in the order the losers were first met.+-}+threeWriters :: IO ()+threeWriters = do+    let root = named "root" "" [named "contested" d [] | d <- ["a", "b", "c"]]+        dag = perNode root+    assertEqual "the last wins" (Just "c") (helpOf dag "contested")+    assertEqual "the reference: the chain of flips" [("b", "a"), ("c", "b")] (conflictsOn (reference root) "contested")+    assertEqual "per node: each loser against the winner" [("c", "a"), ("c", "b")] (conflictsOn dag "contested")+    assertEqual "the same declarations take part" [] (disagreements (reference root) dag)++-- | Same 'Ref', same description, different predecessors: both occurrences+-- are walked, because their predecessors are part of what they look like.+edgesStillAccumulate :: IO ()+edgesStillAccumulate = do+    let root = named "root" "" [named "x" "" [named "p1" "" []], named "x" "" [named "p2" "" []]]+        dag = perNode root+    assertEqual "x depends on both declarations' predecessors" (fmap refOf ["p1", "p2"]) (Dag.dependenciesOf dag (refOf "x"))+    assertEqual "no conflict" 0 (length (Dag.dagConflicts dag))+    assertEqual "as the reference" [] (disagreements (reference root) dag)++{- | An expansion is a finite tree, so a cycle can only be one of 'Ref's: @a@+over @b@ over a second declaration of @a@. Both edges are kept and both+nodes are stuck, in either direction.+-}+cycleOfRefs :: IO ()+cycleOfRefs = do+    let root = named "a" "" [named "b" "" [named "a" "" []]]+        dag = perNode root+    assertEqual "two nodes" (fmap refOf ["a", "b"]) (Dag.dagOrder dag)+    assertEqual "a stands on b" [refOf "b"] (Dag.dependenciesOf dag (refOf "a"))+    assertEqual "and b on a" [refOf "a"] (Dag.dependenciesOf dag (refOf "b"))+    assertEqual "both stuck going up" (Set.fromList (fmap refOf ["a", "b"])) (Dag.stuck Dag.dependenciesOf dag)+    assertEqual "and coming down" (Set.fromList (fmap refOf ["a", "b"])) (Dag.stuck Dag.dependantsOf dag)+    assertEqual "as the reference" [] (disagreements (reference root) dag)+    (r, readBack) <- capture+    ok <- UpDown.upDag (const (pure UpDown.Required)) r dag+    reports <- readBack+    assertBool "the walk fails" (not ok)+    assertEqual "both nodes are Blocked" 2 (length [() | UpDown.Blocked _ <- reports])++optOut :: IO ()+optOut = do+    let root = named "root" "" [named "contested" "first" [], named "contested" "second" []]+        dag = Dag.foldDag (\_ _ -> True) (evalDeps root) :: Dag.Dag Extension+        old = foldDagPerOccurrence (\_ _ -> True) (evalDeps root) :: Dag.Dag Extension+    assertEqual "no conflicts" 0 (length (Dag.dagConflicts dag))+    assertEqual "nor in the reference" 0 (length (Dag.dagConflicts old))+    assertEqual "same order" (Dag.dagOrder old) (Dag.dagOrder dag)+    assertEqual "same dependencies" (Dag.dagDependencies old) (Dag.dagDependencies dag)+    -- nothing tells the two declarations apart, so either is "the" node: the+    -- reference keeps the last occurrence, the per-node fold the first.+    assertEqual "the reference keeps the last" (Just "second") (helpOf old "contested")+    assertEqual "per node: the first, which the test called the same" (Just "first") (helpOf dag "contested")++{- | __The accepted difference.__ Two declarations of @svc@ with the same+description, each over a @file@ with the same 'Ref'; the two @file@s differ.+The two @svc@ occurrences look alike (same 'Ref', same description, same+predecessor 'Ref's), so the second is not walked and the differing @file@+below it is never seen.++The reference reports the conflict and keeps the last @file@. Per node there+is no conflict and the first @file@ is kept. This input is not 'coherent',+and that is the precondition the fold states.+-}+hiddenBelowLookAlikes :: IO ()+hiddenBelowLookAlikes = do+    let svc contents = named "svc" "a unit" [named "file" contents []]+        root = named "root" "" [named "one" "" [svc "old contents"], named "two" "" [svc "new contents"]]+        old = reference root+        new = perNode root+    assertBool "the input is not coherent" (not (isCoherent root))+    assertEqual "the reference reports the fight over the file" [("new contents", "old contents")] (conflictsOn old "file")+    assertEqual "the reference keeps the last writer" (Just "new contents") (helpOf old "file")+    assertEqual "per node: no conflict is reported" [] (conflictsOn new "file")+    assertEqual "per node: the first writer is kept" (Just "old contents") (helpOf new "file")+    -- the shape of the graph is not affected here: only the description is.+    assertEqual "same order" (Dag.dagOrder old) (Dag.dagOrder new)+    assertEqual "same dependencies" (Dag.dagDependencies old) (Dag.dagDependencies new)+    -- one level is enough to be seen again: a difference in the description+    -- of the unit itself makes the two occurrences distinct, and the file is found.+    let told = named "root" "" [named "one" "" [named "svc" "a unit (old)" [named "file" "old contents" []]], named "two" "" [named "svc" "a unit (new)" [named "file" "new contents" []]]]+    assertBool "telling the units apart makes it coherent" (isCoherent told)+    assertEqual "and both conflicts are found" [] (disagreements (reference told) (perNode told))+    assertEqual "" [("new contents", "old contents")] (conflictsOn (perNode told) "file")++{- | A chain of a thousand diamonds has @2^1000@ paths: only a fold that pays+per node returns. Compared with the fixture's own per-node build, which+shares no code with the fold.+-}+thousandDiamonds :: IO ()+thousandDiamonds = do+    let shape = Diamonds 2+        size = 3001+        opts = defaultOptions{optCollisions = 1 / 10}+        root = generateWith opts shape size 7+        built = dagOf root+    outcome <- timeout (60 * 1000000) $ do+        let dag = perNode root+        _ <- evaluate (Map.size (Dag.dagNodes dag) + length (Dag.dagConflicts dag))+        pure dag+    case outcome of+        Nothing -> assertFailure "still folding after 60s, which is what paying per path looks like"+        Just dag -> do+            assertEqual "every node" (counts shape size).countNodes (fromIntegral (Map.size (Dag.dagNodes dag)))+            assertEqual "order" (Dag.dagOrder built) (Dag.dagOrder dag)+            assertEqual "dependencies" (Dag.dagDependencies built) (Dag.dagDependencies dag)+            assertEqual "dependants" (fmap Set.fromList (Dag.dagDependants built)) (fmap Set.fromList (Dag.dagDependants dag))+            let colliders = filter (isCollider opts shape size 7) [0 .. size]+            assertEqual "the colliding refs are the contested ones" (Set.fromList (fmap nodeRef colliders)) (Set.fromList (fmap (\c -> c.conflictRef) (Dag.dagConflicts dag)))+            assertEqual "one conflict per colliding ref" (length colliders) (length (Dag.dagConflicts dag))+            forM_ colliders $ \i ->+                assertEqual+                    ("the second declaration of n" <> show i <> " is kept")+                    (Just "graph fixture node (colliding declaration)")+                    (fmap (\act -> act.extension.help) (Dag.representativeOf dag (nodeRef i)))+            assertEqual "conflicts are well formed" [] (wellFormedConflicts dag)++-------------------------------------------------------------------------------+-- Fixture graphs.++data Fixture = Fixture Shape Int Seed EdgeForm Rational+    deriving (Show)++fixtureOp :: Fixture -> Op+fixtureOp (Fixture shape size seed form fraction) =+    generateWith defaultOptions{optEdges = form, optCollisions = fraction} shape size seed++-- | Any shape, at a size whose expansion the reference can walk.+genFixture :: Gen Fixture+genFixture = do+    shape <-+        oneof+            [ pure Chain+            , pure Fan+            , Tree <$> chooseInt (1, 4)+            , Diamonds <$> chooseInt (1, 4)+            , layered Layered+            , layered RandomLayered+            ]+    size <- chooseInt (0, 60) `suchThat` (\n -> (counts shape n).countOccurrences <= 3000)+    seed <- fromIntegral <$> chooseInt (0, maxBound)+    form <- elements [AsVertices, AsConnect, AsOverlay]+    fraction <- elements [0, 0, 1 / 10, 1 / 3, 1 / 2, 1]+    pure (Fixture shape size seed form fraction)+  where+    layered mk = do+        w <- chooseInt (1, 5)+        k <- chooseInt (1, w)+        pure (mk w k)++shapeName :: Shape -> String+shapeName = takeWhile (/= ' ') . show++prop_fixturesMatchReference :: Property+prop_fixturesMatchReference =+    forAll genFixture $ \fixture@(Fixture shape _ _ _ fraction) ->+        let root = fixtureOp fixture+            old = reference root+            new = perNode root+            -- no conflict test at all: the same graph, and nothing reported.+            oldBlind = foldDagPerOccurrence (\_ _ -> True) (evalDeps root) :: Dag.Dag Extension+            newBlind = Dag.foldDag (\_ _ -> True) (evalDeps root) :: Dag.Dag Extension+         in label (shapeName shape <> (if fraction > 0 then ", colliding" else "")) $+                conjoin+                    [ counterexample "the fixture is not coherent" (isCoherent root)+                    , disagreements old new === []+                    , malformed new === []+                    , counterexample "opting out: order" (Dag.dagOrder oldBlind === Dag.dagOrder newBlind)+                    , counterexample "opting out: dependencies" (Dag.dagDependencies oldBlind === Dag.dagDependencies newBlind)+                    , counterexample "opting out: dependants" (Dag.dagDependants oldBlind === Dag.dagDependants newBlind)+                    , counterexample "opting out: conflicts" (length (Dag.dagConflicts newBlind) === 0)+                    ]++-------------------------------------------------------------------------------+-- Random recipes.++{- | One declaration of a recipe: which effect site it names, which of a few+descriptions it gives it, whether it is glue (no node at all), how its+dependencies are written, and which later declarations it depends on.+-}+data Decl = Decl+    { declSite :: Int+    , declDescription :: Int+    , declGlue :: Bool+    , declForm :: EdgeForm+    , declBelow :: [Int]+    }+    deriving (Show)++{- | Declarations numbered from 0, the root; a declaration only depends on+later ones, so the expansion is finite. Sites and descriptions come from+pools smaller than the recipe, so declarations collide on a 'Ref' with the+same or a different description, with the same or different predecessors,+below one another (a cycle of 'Ref's) or side by side.+-}+newtype Recipe = Recipe [Decl]+    deriving (Show)++recipeOp :: Recipe -> Op+recipeOp (Recipe decls) = build 0+  where+    table = Map.fromList (zip [0 :: Int ..] decls)+    build i =+        case Map.lookup i table of+            Nothing -> OpGraph nodeps Actionless+            Just d+                | d.declGlue -> OpGraph (Identity (written d.declForm (fmap build d.declBelow))) Actionless+                | otherwise ->+                    op (siteName d.declSite) (Identity (written d.declForm (fmap build d.declBelow))) $ \x ->+                        x{ref = refOf (siteName d.declSite), help = "description " <> Text.pack (show d.declDescription)}+    siteName s = "site" <> Text.pack (show s)+    written form xs =+        case form of+            AsVertices -> Vertices xs+            AsConnect -> foldr (Connect . Vertices . pure) (Vertices []) xs+            AsOverlay -> foldr (Overlay . Vertices . pure) (Vertices []) xs++genRecipe :: Gen Recipe+genRecipe = do+    n <- chooseInt (1, 9)+    sites <- chooseInt (1, max 1 (n - 1))+    fmap Recipe . sequence $+        [ do+            site <- chooseInt (0, sites - 1)+            description <- frequency [(5, pure 0), (2, pure 1), (1, pure 2)]+            glue <- frequency [(7, pure False), (1, pure True)]+            form <- elements [AsVertices, AsConnect, AsOverlay]+            wanted <- chooseInt (0, 3)+            below <- fmap (take wanted) (shuffle =<< sublistOf [i + 1 .. n - 1])+            -- now and then the same dependency written twice.+            twice <- frequency [(6, pure []), (1, pure (take 1 below))]+            pure (Decl site description glue form (below <> twice))+        | i <- [0 .. n - 1]+        ]++-- | What a recipe exercises, for the test's own report of its coverage.+recipeLabel :: Op -> String+recipeLabel root+    | not (null (Dag.dagConflicts old)) && Set.size (Dag.stuck Dag.dependenciesOf old) > 0 = "conflicts and a cycle of refs"+    | not (null (Dag.dagConflicts old)) = "conflicts"+    | Set.size (Dag.stuck Dag.dependenciesOf old) > 0 = "a cycle of refs"+    | occurrences (evalDeps root) > Map.size (Dag.dagNodes old) = "shared nodes, no conflict"+    | otherwise = "no sharing"+  where+    old = reference root++prop_coherentRecipesMatchReference :: Property+prop_coherentRecipesMatchReference =+    forAll (genRecipe `suchThat` (isCoherent . recipeOp)) $ \recipe ->+        let root = recipeOp recipe+         in label (recipeLabel root) $+                conjoin+                    [ disagreements (reference root) (perNode root) === []+                    , malformed (perNode root) === []+                    ]++{- | Without the precondition the per-node fold still returns a well-formed+'Dag.Dag' that claims nothing the reference does not: its nodes, its edges+and its contested 'Ref's are among the reference's. What it can do is miss+some, which is the known difference.+-}+prop_anyRecipeIsWellFormedPart :: Property+prop_anyRecipeIsWellFormedPart =+    forAll genRecipe $ \recipe ->+        let root = recipeOp recipe+            old = reference root+            new = perNode root+         in label (if isCoherent root then "coherent" else "incoherent") $+                conjoin+                    [ malformed new === []+                    , wellFormedConflicts new === []+                    , counterexample "a node the reference does not have" (property (Map.keysSet (Dag.dagNodes new) `Set.isSubsetOf` Map.keysSet (Dag.dagNodes old)))+                    , counterexample "an edge the reference does not have" (property (Dag.dagEdges new `Set.isSubsetOf` Dag.dagEdges old))+                    , counterexample "a contested ref the reference does not have" (property (Map.keysSet (contestants new) `Set.isSubsetOf` Map.keysSet (contestants old)))+                    ]++-------------------------------------------------------------------------------+-- The incoherent family.++{- | The counter-example, generalised: several parents, each over its own+copy of a chain of nodes that look alike from copy to copy, ending in a+@file@ whose description differs between copies (the first and the last+always differ).+-}+data Hidden = Hidden+    { hiddenDepth :: Int+    -- ^ look-alike nodes between a parent and the file: at least one.+    , hiddenContents :: [Int]+    -- ^ the file's description under each parent: at least two.+    }+    deriving (Show)++genHidden :: Gen Hidden+genHidden = do+    depth <- chooseInt (1, 5)+    parents <- chooseInt (2, 5)+    contents <- vectorOf parents (choose (0, 2)) `suchThat` (\cs -> head cs /= last cs)+    pure (Hidden depth contents)++hiddenOp :: Hidden -> Op+hiddenOp (Hidden depth contents) =+    named "root" "" [named ("parent" <> Text.pack (show i)) "" [chain depth c] | (i, c) <- zip [0 :: Int ..] contents]+  where+    chain 0 c = named "file" ("contents " <> Text.pack (show c)) []+    chain k c = named ("link" <> Text.pack (show k)) "a link" [chain (k - 1) c]++prop_hiddenConflictIsLost :: Property+prop_hiddenConflictIsLost =+    forAll genHidden $ \hidden ->+        let root = hiddenOp hidden+            old = reference root+            new = perNode root+            described c = Just ("contents " <> Text.pack (show c))+         in conjoin+                [ counterexample "the input is coherent" (not (isCoherent root))+                , counterexample "the reference reports no conflict" (not (null (conflictsOn old "file")))+                , counterexample "the reference keeps the last writer" (helpOf old "file" === described (last hidden.hiddenContents))+                , counterexample "per node: a conflict is reported" (conflictsOn new "file" === [])+                , counterexample "per node: the first writer is kept" (helpOf new "file" === described (head hidden.hiddenContents))+                , counterexample "the graph itself differs" (Dag.dagOrder old === Dag.dagOrder new)+                , counterexample "the graph itself differs" (Dag.dagDependencies old === Dag.dagDependencies new)+                , malformed new === []+                ]
+ test/Test/DagReference.hs view
@@ -0,0 +1,203 @@+{-# LANGUAGE FlexibleContexts #-}+{-# LANGUAGE ScopedTypeVariables #-}++{- | The fold "Salmon.Op.Dag" had before it went per node, kept as the+reference the per-node 'Salmon.Op.Dag.foldDag' is tested against, and the+precondition under which the two agree, as a predicate.++Nothing outside the tests and the @graph-profiles@ benchmark uses this+module. 'foldDagPerOccurrence' visits every occurrence of every node, so on a+shape that shares nodes it is exponential in the node count: keep its inputs+small.+-}+module Test.DagReference (+    foldDagPerOccurrence,+    foldDagPerOccurrenceWith,+    coherent,+    occurrences,+    stuckRounds,+) where++import Control.Comonad.Cofree (Cofree (..))+import Data.Foldable (toList)+import Data.List (foldl')+import qualified Data.Map.Strict as Map+import qualified Data.Set as Set+import GHC.Records (HasField (..))++import Salmon.Op.Actions+import Salmon.Op.Dag (Dag, dagOrder, emptyDag, record)+import Salmon.Op.Graph+import Salmon.Op.OpGraph+import Salmon.Op.Ref++{- | The per-occurrence fold, as it was: every occurrence is 'record'ed, so+the edge sets are the union over all of them, the representative is the last+occurrence in the tree, and a 'Salmon.Op.Dag.Conflict' is recorded every time+the representative changes to a differing one (so one contested node reached+by @k@ paths gives up to @2k - 1@ of them).+-}+foldDagPerOccurrence ::+    forall m ext.+    (HasField "ref" ext Ref) =>+    (Act ext -> Act ext -> Bool) ->+    Cofree Graph (OpGraph m (Actions ext)) ->+    Dag ext+foldDagPerOccurrence = foldDagPerOccurrenceWith record++{- | 'foldDagPerOccurrence' with the 'record' to use given: the tests of+"Test.EdgeListSpec" pass the appending one of "Test.EdgeListReference", to+compare the fold with what it was before edge lists stopped appending.+-}+foldDagPerOccurrenceWith ::+    forall m ext.+    (HasField "ref" ext Ref) =>+    ((Act ext -> Act ext -> Bool) -> Ref -> Act ext -> [Ref] -> Dag ext -> Dag ext) ->+    (Act ext -> Act ext -> Bool) ->+    Cofree Graph (OpGraph m (Actions ext)) ->+    Dag ext+foldDagPerOccurrenceWith record' same = go emptyDag+  where+    go ::+        Dag ext ->+        Cofree Graph (OpGraph m (Actions ext)) ->+        Dag ext+    go dag (x :< g) =+        case x.node of+            Actionless -> foldl' go dag (effPreds g)+            Actions act ->+                let aref = getField @"ref" act.extension+                    preds = effPreds g+                    predRefs = nubOrd [pr | p <- preds, Just pr <- [refOf p]]+                 in foldl' go (record' same aref act predRefs dag) preds++-- | The nearest 'Ref'-carrying subtrees below a node, through any glue.+effPreds ::+    Graph (Cofree Graph (OpGraph m (Actions ext))) ->+    [Cofree Graph (OpGraph m (Actions ext))]+effPreds g = concatMap pick (toList g)+  where+    pick c@(x :< g') =+        case x.node of+            Actions _ -> [c]+            Actionless -> effPreds g'++refOf :: (HasField "ref" ext Ref) => Cofree Graph (OpGraph m (Actions ext)) -> Maybe Ref+refOf (x :< _) =+    case x.node of+        Actions act -> Just (getField @"ref" act.extension)+        Actionless -> Nothing++-- | One occurrence of a node: what it is, the refs it depends on, and the+-- occurrences below it.+data Occurrence ext = Occurrence+    { occRef :: Ref+    , occAct :: Act ext+    , occPredRefs :: [Ref]+    , occBelow :: [Occurrence ext]+    }++-- | The occurrences at the top of an expansion: the root, or (a root that is+-- glue) the nearest nodes below it.+tops ::+    (HasField "ref" ext Ref) =>+    Cofree Graph (OpGraph m (Actions ext)) ->+    [Occurrence ext]+tops c@(x :< g) =+    case x.node of+        Actionless -> concatMap tops (effPreds g)+        Actions act ->+            let preds = effPreds g+             in [ Occurrence+                    { occRef = getField @"ref" act.extension+                    , occAct = act+                    , occPredRefs = nubOrd [pr | p <- preds, Just pr <- [refOf p]]+                    , occBelow = concatMap tops preds+                    }+                | Just _ <- [refOf c]+                ]++everyOccurrence :: [Occurrence ext] -> [Occurrence ext]+everyOccurrence os = concat [o : everyOccurrence o.occBelow | o <- os]++-- | How many occurrences of nodes an expansion holds: what+-- 'foldDagPerOccurrence' pays for.+occurrences :: (HasField "ref" ext Ref) => Cofree Graph (OpGraph m (Actions ext)) -> Int+occurrences = length . everyOccurrence . tops++{- | The precondition of the per-node 'Salmon.Op.Dag.foldDag', decided by+looking at every occurrence (which is exactly what the fold no longer does).++Two occurrences /look alike/ when they have the same 'Ref', acts the test+calls the same, and the same list of predecessor 'Ref's. An expansion is+__coherent__ when any two occurrences that look alike have as many+predecessors as each other and those look alike too, one for one in order:+that is, when looking alike at the top means being the same tree all the way+down.++The test must be an equivalence for this to mean anything;+'Salmon.Op.Dag.sameRepresentative' is.+-}+coherent ::+    forall m ext.+    (HasField "ref" ext Ref) =>+    (Act ext -> Act ext -> Bool) ->+    Cofree Graph (OpGraph m (Actions ext)) ->+    Bool+coherent same root = all agree (Map.elems groups)+  where+    -- alike in ref and predecessor refs; the acts are told apart below,+    -- since they can only be compared pairwise.+    groups :: Map.Map (Ref, [Ref]) [Occurrence ext]+    groups =+        Map.fromListWith+            (flip (<>))+            [((o.occRef, o.occPredRefs), [o]) | o <- everyOccurrence (tops root)]++    agree :: [Occurrence ext] -> Bool+    agree [] = True+    agree (o : rest) =+        let (alike, others) = span' (\p -> same o.occAct p.occAct) rest+         in all (sameBelow o) alike && agree others++    span' p xs = (filter p xs, filter (not . p) xs)++    sameBelow :: Occurrence ext -> Occurrence ext -> Bool+    sameBelow a b =+        length a.occBelow == length b.occBelow+            && and (zipWith lookAlike a.occBelow b.occBelow)++    lookAlike :: Occurrence ext -> Occurrence ext -> Bool+    lookAlike a b =+        a.occRef == b.occRef+            && a.occPredRefs == b.occPredRefs+            && same a.occAct b.occAct++{- | 'Salmon.Op.Dag.stuck' as it was before it went one pass, kept as the+reference the one-pass version is tested against ("Test.DagStuckSpec") and+compared with in the @graph-profiles@ benchmark.++Kahn's algorithm by rounds: each round recomputes the ready set over every+node still remaining, so a graph @d@ levels deep costs @d@ rounds of up to+every node each, quadratic on a chain.+-}+stuckRounds :: (Dag ext -> Ref -> [Ref]) -> Dag ext -> Set.Set Ref+stuckRounds waitsOn dag = go (Set.fromList (dagOrder dag))+  where+    go remaining =+        let ready =+                Set.filter+                    (\r -> all (`Set.notMember` remaining) (waitsOn dag r))+                    remaining+         in if Set.null ready+                then remaining+                else go (remaining `Set.difference` ready)++-- | order-preserving dedup.+nubOrd :: (Ord b) => [b] -> [b]+nubOrd = go Set.empty+  where+    go _ [] = []+    go s (y : ys)+        | Set.member y s = go s ys+        | otherwise = y : go (Set.insert y s) ys
+ test/Test/DagStuckSpec.hs view
@@ -0,0 +1,235 @@+{-# LANGUAGE OverloadedStrings #-}++{- | 'Dag.stuck', the nodes a walk can never reach: unit cases, and QuickCheck+properties comparing the one-pass version with the rounds it replaced+('Test.DagReference.stuckRounds').++The graphs are the "Test.GraphFixture" shapes (acyclic by construction), the+same with extra random edges added (which make cycles, self-loops included),+random digraphs over a fixture's nodes, folds of fixtures whose 'Ref's+collide (so the 'Dag' carries 'Dag.Conflict's), and arbitrary @waitsOn@+functions that name 'Ref's the 'Dag' does not hold, or the same one twice.++Sizes stay small, up to a few hundred nodes, so the default run is cheap.+The depth-scaling numbers are in @resources\/graph-profiles.md@ (the+@graph-profiles@ benchmark); the one large case here is a loose guard, as in+"Test.GraphScaleSpec".+-}+module Test.DagStuckSpec (tests) where++import Control.Exception (evaluate)+import qualified Data.Map.Strict as Map+import Data.Set (Set)+import qualified Data.Set as Set+import System.Timeout (timeout)+import Test.QuickCheck (Gen, Property, choose, counterexample, elements, forAll, listOf, vectorOf, (.&&.), (===))+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertEqual, assertFailure, testCase)+import Test.Tasty.QuickCheck (testProperty)++import Salmon.Builtin.Extension (Extension (..), evalDeps)+import Salmon.Op.Dag (Dag)+import qualified Salmon.Op.Dag as Dag+import Salmon.Op.Ref (Ref)++import Test.DagReference (stuckRounds)+import Test.GraphFixture++tests :: TestTree+tests =+    testGroup+        "Dag.stuck"+        [ testGroup+            "cases"+            [ testCase "an acyclic graph has nothing stuck" acyclic+            , testCase "a self-loop is stuck, and so is what waits on it" selfLoop+            , testCase "a two-node cycle is stuck both ways" twoCycle+            , testCase "a tail hanging off a cycle is stuck in one direction only" cycleWithTail+            , testCase "a wait on a Ref the Dag does not hold is no wait" unknownRef+            , testCase "a deep chain and a deep chain of diamonds answer in one pass" deep+            ]+        , testGroup+            "agrees with the rounds it replaced"+            [ testProperty "on the fixture shapes (acyclic): equal, and empty" propFixture+            , testProperty "on the fixture shapes with random extra edges (cycles)" propExtraEdges+            , testProperty "on random digraphs over a fixture's nodes" propRandomDigraph+            , testProperty "on folds with colliding Refs (conflicts)" propConflicts+            , testProperty "for an arbitrary waitsOn (unknown Refs, repeats)" propArbitraryWaits+            , testProperty "every node reported stuck reaches a cycle, and only those are" propReachesCycle+            ]+        ]++-------------------------------------------------------------------------------+-- building Dags++-- | @n@ nodes (fixture nodes @0 .. n-1@) and exactly these edges, given as+-- @(dependency, dependant)@ node numbers. Nodes come from a 'Fan', so they+-- carry real representatives; its own edges are dropped.+withEdges :: Int -> [(Int, Int)] -> Dag Extension+withEdges n edges =+    Dag.fromMagma+        (Dag.dagNodes (dagOf (generate Fan n 0)))+        (Set.fromList [(nodeRef d, nodeRef r) | (d, r) <- edges, d < n, r < n])++-- | A fixture's 'Dag' with extra edges between its nodes.+plus :: Dag Extension -> [(Int, Int)] -> Dag Extension+plus dag extra =+    Dag.fromMagma+        (Dag.dagNodes dag)+        (Dag.dagEdges dag <> Set.fromList [(nodeRef d, nodeRef r) | (d, r) <- extra])++refs :: [Int] -> Set Ref+refs = Set.fromList . fmap nodeRef++directions :: [(String, Dag Extension -> Ref -> [Ref])]+directions = [("up", Dag.dependenciesOf), ("down", Dag.dependantsOf)]++-- | Both directions agree with the reference.+agrees :: Dag Extension -> Property+agrees dag =+    foldr1+        (.&&.)+        [ counterexample dir (Dag.stuck waitsOn dag === stuckRounds waitsOn dag)+        | (dir, waitsOn) <- directions+        ]++-------------------------------------------------------------------------------+-- cases++acyclic :: IO ()+acyclic = do+    let dag = dagOf (generate (Diamonds 2) 31 0)+    assertEqual "going up" Set.empty (Dag.stuck Dag.dependenciesOf dag)+    assertEqual "coming down" Set.empty (Dag.stuck Dag.dependantsOf dag)+    assertEqual "the empty Dag" Set.empty (Dag.stuck Dag.dependenciesOf (Dag.emptyDag :: Dag Extension))++-- node 1 waits on itself; node 0 depends on 1; 1 depends on 2.+selfLoop :: IO ()+selfLoop = do+    let dag = withEdges 3 [(1, 1), (1, 0), (2, 1)]+    assertEqual "up: the loop and what stands on it" (refs [0, 1]) (Dag.stuck Dag.dependenciesOf dag)+    assertEqual "down: the loop and what it stands on" (refs [1, 2]) (Dag.stuck Dag.dependantsOf dag)+    assertEqual "as the rounds" (stuckRounds Dag.dependenciesOf dag) (Dag.stuck Dag.dependenciesOf dag)++twoCycle :: IO ()+twoCycle = do+    let dag = withEdges 3 [(0, 1), (1, 0)]+    assertEqual "up" (refs [0, 1]) (Dag.stuck Dag.dependenciesOf dag)+    assertEqual "down" (refs [0, 1]) (Dag.stuck Dag.dependantsOf dag)++-- a cycle 1 <-> 2, node 0 standing on it (0 depends on 1) and node 3 below+-- it (2 depends on 3), and 4 off to the side.+cycleWithTail :: IO ()+cycleWithTail = do+    let dag = withEdges 5 [(1, 2), (2, 1), (1, 0), (3, 2)]+    assertEqual "up: what stands on the cycle waits on it" (refs [0, 1, 2]) (Dag.stuck Dag.dependenciesOf dag)+    assertEqual "down: what the cycle stands on waits on it" (refs [1, 2, 3]) (Dag.stuck Dag.dependantsOf dag)++unknownRef :: IO ()+unknownRef = do+    let dag = withEdges 2 [(1, 0)]+        waitsOn d r = nodeRef 99 : nodeRef 99 : Dag.dependenciesOf d r+    assertEqual "nothing stuck" Set.empty (Dag.stuck waitsOn dag)+    assertEqual "as the rounds" (stuckRounds waitsOn dag) (Dag.stuck waitsOn dag)++{- | Thirty thousand levels: the rounds took about 2 s at three thousand and+grew with the square of the depth, so this would take minutes; one pass takes+a fraction of a second. The bound is loose, as in "Test.GraphScaleSpec": it+tells one pass from rounds, it times nothing.+-}+deep :: IO ()+deep =+    mapM_+        ( \shape -> do+            let dag = dagOf (generate shape 30001 0)+                k = length (Dag.dagOrder dag)+                -- the bottom node now also depends on the node three above+                -- it: a cycle of four at the very bottom, under everything.+                cyclic = plus dag [(k - 4, k - 1)]+            n <- within (show shape) (evaluate (Set.size (Dag.stuck Dag.dependenciesOf dag)))+            assertEqual (show shape <> ": nothing stuck") 0 n+            up <- within (show shape <> " with a cycle, up") (evaluate (Set.size (Dag.stuck Dag.dependenciesOf cyclic)))+            assertEqual (show shape <> ": up, everything stands on the cycle") k up+            down <- within (show shape <> " with a cycle, down") (evaluate (Set.size (Dag.stuck Dag.dependantsOf cyclic)))+            assertEqual (show shape <> ": down, only the cycle itself") 4 down+        )+        [Chain, Diamonds 2]+  where+    within what act = do+        outcome <- timeout (60 * 1000000) act+        maybe (assertFailure (what <> ": still running after 60s")) pure outcome++-------------------------------------------------------------------------------+-- properties++genShape :: Gen Shape+genShape =+    elements+        [Chain, Fan, Tree 2, Tree 3, Diamonds 2, Diamonds 3, Layered 3 2, Layered 4 4, RandomLayered 5 3]++-- | A shape, a size, a seed, and the node count actually realised.+genFixture :: Gen (Shape, Int, Seed)+genFixture = (,,) <$> genShape <*> choose (1, 200) <*> (fromIntegral <$> choose (0 :: Int, 1000))++nodeCount :: Shape -> Int -> Int+nodeCount shape n = fromIntegral (counts shape n).countNodes++propFixture :: Property+propFixture = forAll genFixture $ \(shape, n, s) ->+    let dag = dagOf (generate shape n s)+     in counterexample (show (shape, n, s)) $+            agrees dag .&&. Dag.stuck Dag.dependenciesOf dag === Set.empty .&&. Dag.stuck Dag.dependantsOf dag === Set.empty++propExtraEdges :: Property+propExtraEdges = forAll genFixture $ \(shape, n, s) ->+    let k = nodeCount shape n+     in forAll (choose (1, 4)) $ \m ->+            forAll (vectorOf m ((,) <$> choose (0, k - 1) <*> choose (0, k - 1))) $ \extra ->+                counterexample (show (shape, n, s)) (agrees (plus (dagOf (generate shape n s)) extra))++propRandomDigraph :: Property+propRandomDigraph = forAll (choose (1, 40)) $ \n ->+    forAll (listOf ((,) <$> choose (0, n - 1) <*> choose (0, n - 1))) $ \edges ->+        agrees (withEdges n edges)++propConflicts :: Property+propConflicts = forAll genFixture $ \(shape, n, s) ->+    forAll (elements [1 / 10, 1 / 3, 1]) $ \fraction ->+        let opts = defaultOptions{optCollisions = fraction}+            dag = Dag.foldDag Dag.sameRepresentative (evalDeps (generateWith opts shape (min n 60) s)) :: Dag Extension+         in counterexample (show (shape, min n 60, s, fraction, length (Dag.dagConflicts dag))) (agrees dag)++-- waitsOn from a random table: node numbers up to twice the node count, so+-- some name nodes the Dag does not hold, and repeats allowed.+propArbitraryWaits :: Property+propArbitraryWaits = forAll (choose (1, 40)) $ \n ->+    forAll (vectorOf n (listOf (choose (0, 2 * n)))) $ \table ->+        let dag = withEdges n []+            m = Map.fromList (zip (fmap nodeRef [0 ..]) (fmap (fmap nodeRef) table))+            waitsOn _ r = Map.findWithDefault [] r m+         in Dag.stuck waitsOn dag === stuckRounds waitsOn dag++{- | Stuck exactly when the node reaches a cycle along @waitsOn@: some node it+can reach (itself included) can reach itself again in one step or more.+Decided by plain reachability, sharing nothing with either version.+-}+propReachesCycle :: Property+propReachesCycle = forAll (choose (1, 30)) $ \n ->+    forAll (listOf ((,) <$> choose (0, n - 1) <*> choose (0, n - 1))) $ \edges ->+        let dag = withEdges n edges+         in foldr1+                (.&&.)+                [ counterexample dir (Dag.stuck waitsOn dag === Set.filter (reachesCycle (waitsOn dag)) (Set.fromList (Dag.dagOrder dag)))+                | (dir, waitsOn) <- directions+                ]+  where+    reachesCycle next r = any (\c -> Set.member c (reach next (next c))) (Set.toList (reach next [r]))++-- | Everything reachable from the starting points, the starting points included.+reach :: (Ref -> [Ref]) -> [Ref] -> Set Ref+reach next = go Set.empty+  where+    go seen [] = seen+    go seen (x : xs)+        | Set.member x seen = go seen xs+        | otherwise = go (Set.insert x seen) (next x <> xs)
test/Test/DebianPackageSpec.hs view
@@ -16,12 +16,83 @@ import Test.Tasty.HUnit (assertBool, assertEqual, testCase)  import Salmon.Actions.UpDown (CheckResult (..))-import Salmon.Builtin.Nodes.Debian.Package (interpretDpkgCatalog)+import Salmon.Builtin.Nodes.Debian.Package (interpretAptPolicy, interpretDpkgCatalog)  tests :: TestTree-tests =+tests = testGroup "Salmon.Builtin.Nodes.Debian.Package" [catalogTests, policyTests]++{- | 'interpretAptPolicy': whether the apt index has a candidate for every+package, which is what decides if @apt-get update@ runs before an install.+The stanzas are real @LC_ALL=C apt-cache policy@ output (Ubuntu 24.04).+-}+policyTests :: TestTree+policyTests =     testGroup-        "Salmon.Builtin.Nodes.Debian.Package.interpretDpkgCatalog"+        "interpretAptPolicy"+        [ testCase "a candidate for every package needs no refresh" $+            assertEqual "" Success (interpretAptPolicy False ["podman", "rsync"] ExitSuccess policy)+        , testCase "Candidate: (none) is no candidate" $+            -- what a fresh cloud image says of a universe package it has+            -- only heard of through another package's dependencies+            assertEqual+                ""+                (Failure "no installation candidate in the apt index for: pgbouncer")+                (interpretAptPolicy False ["podman", "pgbouncer"] ExitSuccess policy)+        , testCase "a name apt prints no stanza for is no candidate" $+            assertEqual+                ""+                (Failure "no installation candidate in the apt index for: nosuchpkg")+                (interpretAptPolicy False ["rsync", "nosuchpkg"] ExitSuccess policy)+        , testCase "an empty index has no candidate for anything" $+            assertEqual+                ""+                (Failure "no installation candidate in the apt index for: podman, rsync")+                (interpretAptPolicy False ["podman", "rsync"] ExitSuccess "")+        , testCase "an installed version is not a candidate" $+            -- "Installed:" and the version table both carry version strings;+            -- only the Candidate line decides+            assertBool "" (isFailure (interpretAptPolicy False ["orphan"] ExitSuccess policy))+        , testCase "after a recent refresh there is nothing more a refresh can do" $+            -- a virtual name (ssh-client) or a typo: left to the install to report+            assertEqual "" Success (interpretAptPolicy True ["pgbouncer", "nosuchpkg"] ExitSuccess policy)+        , testCase "apt-cache failing outright is 'cannot tell'" $+            assertEqual "" Unknown (interpretAptPolicy False ["rsync"] (ExitFailure 100) "")+        ]+  where+    isFailure (Failure _) = True+    isFailure _ = False++    policy =+        "pgbouncer:\n\+        \  Installed: (none)\n\+        \  Candidate: (none)\n\+        \  Version table:\n\+        \podman:\n\+        \  Installed: 4.9.3+ds1-1ubuntu0.2\n\+        \  Candidate: 4.9.3+ds1-1ubuntu0.2\n\+        \  Version table:\n\+        \ *** 4.9.3+ds1-1ubuntu0.2 500\n\+        \        500 http://archive.ubuntu.com/ubuntu noble-updates/universe amd64 Packages\n\+        \        100 /var/lib/dpkg/status\n\+        \     4.9.3+ds1-1build2 500\n\+        \        500 http://archive.ubuntu.com/ubuntu noble/universe amd64 Packages\n\+        \orphan:\n\+        \  Installed: 1.0-1\n\+        \  Candidate: (none)\n\+        \  Version table:\n\+        \ *** 1.0-1 -1\n\+        \        100 /var/lib/dpkg/status\n\+        \rsync:\n\+        \  Installed: (none)\n\+        \  Candidate: 3.2.7-1ubuntu1.5\n\+        \  Version table:\n\+        \     3.2.7-1ubuntu1.5 500\n\+        \        500 http://archive.ubuntu.com/ubuntu noble-updates/main amd64 Packages\n"++catalogTests :: TestTree+catalogTests =+    testGroup+        "interpretDpkgCatalog"         [ testCase "an installed package is satisfied" $             assertEqual "" Success (interpretDpkgCatalog ["rsync"] ExitSuccess catalogue)         , testCase "a virtual name counts as installed when its provider is" $
+ test/Test/DeferredSpec.hs view
@@ -0,0 +1,311 @@+{-# LANGUAGE OverloadedStrings #-}++{- | Layer 0 coverage for "Salmon.Builtin.Nodes.Deferred": a sub-graph built+at @up@ from a value another node of the same pass produced, and for the one+recipe built on it, 'VmProvision.provisionedVmReadingHost', as far as its+/declared/ shape goes (what it does to a machine needs a GCP project).++Everything here is in-process: the "value" is an 'IORef' a node's @up@+writes, which is the whole of what the address GCP picks is to the graph.+-}+module Test.DeferredSpec (tests) where++import Control.Exception (throwIO)+import Data.IORef (IORef, modifyIORef', newIORef, readIORef, writeIORef)+import qualified Data.Map.Strict as Map+import qualified Data.Set as Set+import Data.Text (Text)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, assertEqual, testCase)++import qualified Salmon.Builtin.CommandLine as CLI+import Salmon.Builtin.Extension (Extension, Op, down, dynamics, evalDeps, help, ignoreTrack, nodeps, notes, op, realNoop, ref, up)+import qualified Salmon.Builtin.Nodes.Deferred as Deferred+import qualified Salmon.Builtin.Nodes.Gcp.Compute as Compute+import qualified Salmon.Builtin.Nodes.Gcp.Core as Core+import qualified Salmon.Builtin.Nodes.Keys as Keys+import qualified Salmon.Builtin.Nodes.Self as Self+import Salmon.Op.Actions (Act (..))+import qualified Salmon.Op.Dag as Dag+import Salmon.Op.OpGraph (inject)+import Salmon.Op.Ref (mkRef)+import Salmon.Op.Track (Track (..), trackedGraph)+import Salmon.Reporter (silent)+import qualified SreBox.Gcp.VmProvision as VmProvision++import Test.Harness (runDown, runUp)++tests :: TestTree+tests =+    testGroup+        "Salmon.Builtin.Nodes.Deferred"+        [ testCase "one pass: the producer's up feeds the sub-graph built after it" onePass+        , testCase "declaring the node reads nothing" declarationReadsNothing+        , testCase "a read that finds nothing fails up and builds no graph" unresolvedFailsUp+        , testCase "a failure in the nested walk fails the node" nestedFailureSurfaces+        , testCase "down walks the sub-graph down, before the producer" downWalksSubgraph+        , testCase "down with nothing to read is a no-op, not a failure" downWithoutValue+        , testGroup+            "VmProvision.provisionedVmReadingHost"+            [ testCase "declares the machine, not what names it" readingHostShape+            , testCase "provisionedVm still declares the hand-off as nodes" knownHostShape+            , testCase "provisionedVm: the remote call stands on the upload, the probe and vmp_beforeCall" knownHostOrdering+            , testCase "the deferred hand-off has the same ordering" handOffOrdering+            ]+        , testGroup+            "Self: the remote call is the node an injection lands on"+            [ testCase "uploadAndCallSelf: the call depends on the upload" selfCallAfterUpload+            , testCase "uploadAndCallSelfAsSudo: the call depends on the upload" selfSudoCallAfterUpload+            , testCase "what a recipe injects onto the call precedes the call" selfCallAfterInjected+            ]+        ]++-------------------------------------------------------------------------------++type Log = IORef [Text]++say :: Log -> Text -> IO ()+say l t = modifyIORef' l (t :)++logged :: Log -> IO [Text]+logged l = reverse <$> readIORef l++-- | A node standing for "GCP picks an address": its @up@ makes the value+-- exist, its @down@ takes it away.+producer :: Log -> IORef (Maybe Text) -> Op+producer l cell =+    op "producer" nodeps $ \x ->+        x+            { ref = mkRef "deferred-spec" ("producer" :: Text)+            , up = say l "producer up" >> writeIORef cell (Just "198.51.100.7")+            , down = say l "producer down" >> writeIORef cell Nothing+            }++-- | What needs the value: a node whose effects name it.+consumer :: Log -> Text -> Op+consumer l host =+    op "consumer" nodeps $ \x ->+        x+            { ref = mkRef "deferred-spec" ("consumer" :: Text, host)+            , up = say l ("consumer up " <> host)+            , down = say l ("consumer down " <> host)+            }++late :: IO (Maybe Text) -> (Text -> Op) -> Op+late readIt graph =+    Deferred.deferred+        silent+        Deferred.Deferred+            { Deferred.deferredName = "spec"+            , Deferred.deferredHelp = "uses what the producer made"+            , Deferred.deferredRead = readIt+            , Deferred.deferredGraph = graph+            }++-------------------------------------------------------------------------------++onePass :: IO ()+onePass = do+    l <- newIORef []+    cell <- newIORef Nothing+    ok <- runUp (late (readIORef cell) (consumer l) `inject` producer l cell)+    assertBool "the pass succeeds" ok+    assertEqual "the consumer saw the value the producer made" ["producer up", "consumer up 198.51.100.7"] =<< logged l++declarationReadsNothing :: IO ()+declarationReadsNothing = do+    reads_ <- newIORef (0 :: Int)+    let node = late (modifyIORef' reads_ (+ 1) >> pure (Just "x")) (const realNoop)+        dag = Dag.foldDag Dag.sameRepresentative (evalDeps node) :: Dag.Dag Extension+    assertEqual "one declared node" 1 (Map.size (Dag.dagNodes dag))+    assertEqual "no read at declaration" 0 =<< readIORef reads_++unresolvedFailsUp :: IO ()+unresolvedFailsUp = do+    l <- newIORef []+    ok <- runUp (late (pure Nothing) (consumer l))+    assertBool "the pass reports the failure" (not ok)+    assertEqual "nothing was built from a guess" [] =<< logged l++nestedFailureSurfaces :: IO ()+nestedFailureSurfaces = do+    l <- newIORef []+    let failing host =+            op "failing" nodeps $ \x ->+                x{ref = mkRef "deferred-spec" ("failing" :: Text, host), up = throwIO (userError "nope")}+        dependant =+            op "dependant" nodeps $ \x ->+                x{ref = mkRef "deferred-spec" ("dependant" :: Text), up = say l "dependant up"}+    ok <- runUp (dependant `inject` late (pure (Just "h")) failing)+    assertBool "the outer pass fails" (not ok)+    assertEqual "what stands on the node is not run" [] =<< logged l++downWalksSubgraph :: IO ()+downWalksSubgraph = do+    l <- newIORef []+    cell <- newIORef (Just "198.51.100.7")+    ok <- runDown (late (readIORef cell) (consumer l) `inject` producer l cell)+    assertBool "the teardown succeeds" ok+    assertEqual+        "the sub-graph goes down while the value can still be read"+        ["consumer down 198.51.100.7", "producer down"]+        =<< logged l++downWithoutValue :: IO ()+downWithoutValue = do+    l <- newIORef []+    cell <- newIORef Nothing+    ok <- runDown (late (readIORef cell) (consumer l) `inject` producer l cell)+    assertBool "not a failure" ok+    assertEqual "the producer is still torn down" ["producer down"] =<< logged l++-------------------------------------------------------------------------------++shorthands :: Op -> [Text]+shorthands o = [act.shorthand | act <- Map.elems (Dag.dagNodes dag)]+  where+    dag = Dag.foldDag Dag.sameRepresentative (evalDeps o) :: Dag.Dag Extension++readingHostShape :: IO ()+readingHostShape = do+    reads_ <- newIORef (0 :: Int)+    let node =+            VmProvision.provisionedVmReadingHost+                silent+                ignoreTrack+                ignoreTrack+                (modifyIORef' reads_ (+ 1) >> pure (Just "198.51.100.7"))+                (\_ cfg -> cfg)+                (vmConfig "")+        names = shorthands node+    assertBool "the instance is an ordinary node" ("gcp-instance" `elem` names)+    assertBool "the CA is published by an ordinary node" ("gcp-metadata-ssh-ca" `elem` names)+    assertBool "the hand-off is one deferred node" ("deferred" `elem` names)+    assertBool "no ssh probe is declared: it would have to name a host" ("gcp-ssh-available" `notElem` names)+    assertEqual "the address is not read at declaration" 0 =<< readIORef reads_++knownHostShape :: IO ()+knownHostShape = do+    let names = shorthands (VmProvision.provisionedVm silent ignoreTrack ignoreTrack (vmConfig "198.51.100.7"))+    assertBool "the ssh probe is a declared node" ("gcp-ssh-available" `elem` names)+    assertBool "nothing is deferred" ("deferred" `notElem` names)++{- | The edges of the declared graph, by shorthand: whether every node called+@from@ reaches (transitively, along 'Dag.dagDependencies') some node called+@to@. 'Nothing' when no node is called @from@.+-}+dependsOn :: Op -> Text -> Text -> Maybe Bool+dependsOn o from to =+    case named from of+        [] -> Nothing+        starts -> Just (all reachesTarget starts)+  where+    dag = Dag.foldDag Dag.sameRepresentative (evalDeps o) :: Dag.Dag Extension+    named name = [r | (r, act) <- Map.toList (Dag.dagNodes dag), act.shorthand == name]+    targets = Set.fromList (named to)+    reachesTarget start = not (Set.null (Set.intersection targets (closure Set.empty (direct start))))+    direct r = Map.findWithDefault [] r (Dag.dagDependencies dag)+    closure seen [] = seen+    closure seen (r : rs)+        | r `Set.member` seen = closure seen rs+        | otherwise = closure (Set.insert r seen) (direct r <> rs)++assertDependsOn :: Op -> Text -> Text -> IO ()+assertDependsOn o from to =+    assertEqual (show from <> " depends on " <> show to) (Just True) (dependsOn o from to)++-- | A stand-in for a 'vmp_beforeCall' upload, or for a recipe's own.+marker :: Text -> Op+marker name = op name nodeps $ \a -> a{ref = mkRef "ordering-marker" name}++{- | What the hand-off promises, whichever way it is declared: the binary is+copied once ssh answers, every 'vmp_beforeCall' node waits for ssh too, and+the call runs after all three. An edge to a wrapper beside the call is not+that: siblings are unordered, a failed one does not block the call in a+one-shot pass, and @run serve@ converges them concurrently.+-}+assertHandOffOrdering :: Op -> IO ()+assertHandOffOrdering o = do+    assertDependsOn o "ssh:call" "rsync:sendfile"+    assertDependsOn o "ssh:call" "gcp-ssh-available"+    assertDependsOn o "ssh:call" "before-call"+    assertDependsOn o "rsync:sendfile" "gcp-ssh-available"+    assertDependsOn o "before-call" "gcp-ssh-available"++withBeforeCall :: VmProvision.VmProvisionConfig () -> VmProvision.VmProvisionConfig ()+withBeforeCall cfg = cfg{VmProvision.vmp_beforeCall = const [marker "before-call"]}++knownHostOrdering :: IO ()+knownHostOrdering = do+    let o = VmProvision.provisionedVm silent ignoreTrack ignoreTrack (withBeforeCall (vmConfig "198.51.100.7"))+    assertHandOffOrdering o+    assertDependsOn o "gcp-ssh-available" "gcp-instance"++{- | 'VmProvision.provisionedVmReadingHost' builds this graph inside its+deferred node's @up@, where a declared-shape test cannot see it; it is the+same function, given no nodes for the probe to stand on.+-}+handOffOrdering :: IO ()+handOffOrdering =+    assertHandOffOrdering (VmProvision.handOff silent (withBeforeCall (vmConfig "198.51.100.7")) [])++selfRemote :: Self.Remote+selfRemote = Self.Remote "deployer" "198.51.100.7"++selfCallAfterUpload :: IO ()+selfCallAfterUpload =+    assertDependsOn+        (trackedGraph (Self.uploadAndCallSelf silent silent "tmp" selfRemote (Self.SelfPath "/tmp/w/self") ignoreTrack ignoreTrack CLI.Up ()))+        "ssh:call"+        "rsync:sendfile"++sudoCall :: Op+sudoCall =+    trackedGraph (Self.uploadAndCallSelfAsSudo silent silent "tmp" selfRemote (Self.SelfPath "/tmp/w/self") ignoreTrack ignoreTrack CLI.Up ())++selfSudoCallAfterUpload :: IO ()+selfSudoCallAfterUpload = assertDependsOn sudoCall "ssh:call" "rsync:sendfile"++-- | The shape of @remoteInit \`inject\` uploadSecrets@ in the recipes.+selfCallAfterInjected :: IO ()+selfCallAfterInjected =+    assertDependsOn (sudoCall `inject` marker "uploaded-secrets") "ssh:call" "uploaded-secrets"++vmConfig :: Text -> VmProvision.VmProvisionConfig ()+vmConfig host =+    VmProvision.VmProvisionConfig+        { VmProvision.vmp_name = "toy-vm"+        , VmProvision.vmp_instance = inst+        , VmProvision.vmp_ca = Keys.SSHKeyPair Keys.ED25519 "/tmp/w/keys" "ca"+        , VmProvision.vmp_clientIdentity = Keys.SSHKeyPair Keys.ED25519 "/tmp/w/keys" "client"+        , VmProvision.vmp_sshUser = "deployer"+        , VmProvision.vmp_sshHost = host+        , VmProvision.vmp_sshPort = 22+        , VmProvision.vmp_prerequisites = []+        , VmProvision.vmp_beforeCall = const []+        , VmProvision.vmp_remoteDir = "/home/deployer"+        , VmProvision.vmp_selfPath = Self.SelfPath "/tmp/w/self"+        , VmProvision.vmp_directiveTrack = Track (const realNoop)+        , VmProvision.vmp_directive = ()+        }+  where+    inst =+        Compute.Instance+            { Compute.instanceName = "toy-vm"+            , Compute.instanceProject = Core.Project "p"+            , Compute.instanceZone = Core.Zone "europe-west1-b"+            , Compute.instanceMachineType = Compute.Custom "e2-micro"+            , Compute.instanceBootDisk = Compute.BootDisk 10 Nothing (Just "ubuntu-2404-lts-amd64") (Just "ubuntu-os-cloud")+            , Compute.instanceNetwork = "default"+            , Compute.instanceSubnet = "default"+            , Compute.instanceServiceAccount = Nothing+            , Compute.instanceMetadata = mempty+            , Compute.instanceMetadataFiles = mempty+            , Compute.instanceExternalAddress = Compute.ReservedExternal "toy-ip"+            , Compute.instanceInternalAddress = Compute.EphemeralInternal+            , Compute.instanceTags = []+            , Compute.instancePower = Compute.PoweredOn+            , Compute.instanceScopes = Compute.DefaultScopes+            , Compute.instanceMachineTypeChange = Compute.RefuseMachineTypeChange+            , Compute.instanceRunLimit = Compute.NoRunLimit+            }
+ test/Test/DetachedSpec.hs view
@@ -0,0 +1,265 @@+{-# LANGUAGE OverloadedStrings #-}++{- | Layer 0 coverage for "Salmon.Builtin.Nodes.Detached": real, short-lived+children (@sleep@, @sh@) started in a temporary directory, driven through+'Salmon.Actions.UpDown.upTree'/'downTree'. Every process a case starts is+stopped by that case. No container, unit or VM.+-}+module Test.DetachedSpec (tests) where++import Control.Concurrent (threadDelay)+import Control.Exception (finally)+import qualified Data.ByteString.Char8 as BS8+import Data.IORef (atomicModifyIORef', newIORef, readIORef)+import Data.Maybe (isNothing)+import Data.Text (Text)+import qualified Data.Text as Text+import System.Directory (doesFileExist)+import System.FilePath ((</>))+import System.IO.Temp (withSystemTempDirectory)+import System.Posix.Files (readSymbolicLink)+import System.Posix.Signals (sigKILL, signalProcess)+import System.Posix.Types (CPid (..))+import System.Process (CreateProcess (..), createProcess, getPid, proc, waitForProcess)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, assertEqual, assertFailure, testCase)++import qualified Salmon.Actions.UpDown as UpDown+import Salmon.Actions.UpDown (Report (Blocked, Done, Failed, Skip))+import Salmon.Builtin.Extension (Extension (..))+import qualified Salmon.Builtin.Nodes.Binary as Binary+import Salmon.Builtin.Nodes.Detached hiding (Report)+import Salmon.Builtin.Nodes.Detached (Report (Forgot, Killing))+import Salmon.Op.Actions (Act (..), Actions (..))+import Salmon.Op.OpGraph (OpGraph (..))+import Salmon.Op.Supervision (millis)+import Salmon.Reporter (ReporterM (..), silent)++import Test.Harness (runDownCapturing, runUpCapturing)++tests :: TestTree+tests =+    testGroup+        "Salmon.Builtin.Nodes.Detached"+        [ testCase "up starts it detached; a second up skips it; down stops it and removes the pid file" upSkipDown+        , testCase "kill it, and up starts it again" killedRestarts+        , testCase "a pid file naming an unrelated live process: down signals nothing" unrelatedPid+        , testCase "one that ignores SIGTERM is killed, with the process group" escalation+        , testCase "a changed command line replaces the running process" drift+        , testCase "pid file round trip; a bare pid is not a record" pidFileFormat+        , testCase "/proc/PID/stat with a command name holding ') '" statParsing+        , testCase "notes name env variables, never their values" notesNoEnvValues+        ]++-------------------------------------------------------------------------------++sleeper :: FilePath -> [String] -> Detached+sleeper dir args =+    (defaultDetached "sleeper" "sleep" args (dir </> "sleeper.pid") (dir </> "sleeper.log"))+        { detached_stop = Stop (detached_stop (defaultDetached "" "" [] "" "")).stop_signal (millis 500)+        }++outcomes :: [UpDown.Report Extension] -> [Text]+outcomes = concatMap one+  where+    one r = case r of+        Done a -> ["Done " <> a.shorthand]+        Skip a -> ["Skip " <> a.shorthand]+        Failed a _ -> ["Failed " <> a.shorthand]+        Blocked a -> ["Blocked " <> a.shorthand]+        _ -> []++recorded :: Detached -> IO PidRecord+recorded d = do+    m <- readPidFile d.detached_pidFile+    case m of+        Just (Just rec) -> pure rec+        other -> assertFailure ("no pid record: " <> show other) >> error "unreachable"++-- | Gone, or a zombie nobody reaped yet.+goneWithin :: Int -> Int -> IO Bool+goneWithin ms pid = do+    i <- identify pid+    case i of+        Nothing -> pure True+        Just ident | ident.id_zombie -> pure True+        _ | ms <= 0 -> pure False+        _ -> threadDelay 20000 >> goneWithin (ms - 20) pid++alive :: Int -> IO Bool+alive pid = maybe False (not . (.id_zombie)) <$> identify pid++-- | Last resort so a failing case never leaves its child behind.+cleanup :: Detached -> IO ()+cleanup d = do+    m <- readPidFile d.detached_pidFile+    case m of+        Just (Just rec) -> stopRecorded silent "cleanup" d.detached_stop rec+        _ -> pure ()++-------------------------------------------------------------------------------++upSkipDown :: IO ()+upSkipDown = withSystemTempDirectory "detached" $ \dir -> do+    let d = sleeper dir ["30"]+    flip finally (cleanup d) $ do+        r1 <- runUpCapturing (detached silent d)+        assertEqual "first up" ["Done detached"] (outcomes r1)+        rec <- recorded d+        ident <- identify rec.pr_pid+        case ident of+            Nothing -> assertFailure "not running after up"+            Just i -> do+                assertEqual "leads its own process group (new session)" rec.pr_pid i.id_pgrp+                assertEqual "argv" ["sleep", "30"] i.id_argv+        stdin0 <- readSymbolicLink ("/proc" </> show rec.pr_pid </> "fd" </> "0")+        assertEqual "stdin" "/dev/null" stdin0+        r2 <- runUpCapturing (detached silent d)+        assertEqual "second up" ["Skip detached"] (outcomes r2)+        rec2 <- recorded d+        assertEqual "same process" rec.pr_pid rec2.pr_pid+        r3 <- runDownCapturing (detached silent d)+        assertEqual "down" ["Done detached"] (outcomes r3)+        gone <- goneWithin 2000 rec.pr_pid+        assertBool "process gone" gone+        exists <- doesFileExist d.detached_pidFile+        assertBool "pid file removed" (not exists)++killedRestarts :: IO ()+killedRestarts = withSystemTempDirectory "detached" $ \dir -> do+    let d = sleeper dir ["30"]+    flip finally (cleanup d) $ do+        _ <- runUpCapturing (detached silent d)+        rec <- recorded d+        signalProcess sigKILL (CPid (fromIntegral rec.pr_pid))+        gone <- goneWithin 2000 rec.pr_pid+        assertBool "killed" gone+        v <- judge d+        case v of+            NotOurs _ -> pure ()+            other -> assertFailure ("expected NotOurs, got " <> show other)+        r <- runUpCapturing (detached silent d)+        assertEqual "up again" ["Done detached"] (outcomes r)+        rec2 <- recorded d+        assertBool "a new process" (rec2.pr_pid /= rec.pr_pid)+        isAlive <- alive rec2.pr_pid+        assertBool "running" isAlive+        _ <- runDownCapturing (detached silent d)+        pure ()++unrelatedPid :: IO ()+unrelatedPid = withSystemTempDirectory "detached" $ \dir -> do+    let d = sleeper dir ["30"]+    -- our own unrelated process, which down must not touch+    (_, _, _, ph) <- Binary.detachedStdin (proc "sleep" ["31"]){close_fds = True} createProcess+    Just (CPid p) <- getPid ph+    let pid = fromIntegral p+    flip finally (signalProcess sigKILL (CPid p) >> waitForProcess ph) $ do+        Just ident <- identify pid+        let real = PidRecord pid ident.id_start ident.id_boot ident.id_argv Nothing+            cases =+                [ ("a bare pid", BS8.pack (show pid) <> "\n")+                , ("another command line", renderPidFile real{pr_argv = ["sleep", "30"]})+                , ("another start time", renderPidFile real{pr_start = ident.id_start + 1})+                , ("another boot", renderPidFile real{pr_boot = "not-this-boot"})+                ]+        mapM_+            ( \(label, contents) -> do+                BS8.writeFile d.detached_pidFile contents+                forgot <- newIORef (0 :: Int)+                let r = ReporterM $ \ev -> case ev of+                        Forgot _ _ -> atomicModifyIORef' forgot (\n -> (n + 1, ()))+                        _ -> pure ()+                out <- runDownCapturing (detached r d)+                assertEqual (label <> ": down") ["Done detached"] (outcomes out)+                threadDelay 100000+                stillAlive <- alive pid+                assertBool (label <> ": the unrelated process is untouched") stillAlive+                exists <- doesFileExist d.detached_pidFile+                assertBool (label <> ": pid file forgotten") (not exists)+                n <- readIORef forgot+                assertEqual (label <> ": reported Forgot") 1 n+            )+            cases++escalation :: IO ()+escalation = withSystemTempDirectory "detached" $ \dir -> do+    let childFile = dir </> "child.pid"+        d =+            (defaultDetached "stubborn" "sh" ["-c", "trap '' TERM; sleep 30 & echo $! > " <> childFile <> "; wait"] (dir </> "s.pid") (dir </> "s.log"))+                { detached_stop = Stop (detached_stop (defaultDetached "" "" [] "" "")).stop_signal (millis 300)+                }+    flip finally (cleanup d) $ do+        _ <- runUpCapturing (detached silent d)+        rec <- recorded d+        child <- waitForFile 3000 childFile+        events <- newIORef []+        let r = ReporterM $ \ev -> atomicModifyIORef' events (\es -> (es <> [ev], ()))+        out <- runDownCapturing (detached r d)+        assertEqual "down" ["Done detached"] (outcomes out)+        es <- readIORef events+        assertBool ("escalated to SIGKILL: " <> show es) (any isKilling es)+        g1 <- goneWithin 2000 rec.pr_pid+        g2 <- goneWithin 2000 child+        assertBool "the leader is gone" g1+        assertBool "its forked child went with the group" g2+  where+    isKilling Killing{} = True+    isKilling _ = False+    waitForFile :: Int -> FilePath -> IO Int+    waitForFile ms path = do+        exists <- doesFileExist path+        contents <- if exists then BS8.readFile path else pure ""+        case BS8.readInt contents of+            Just (n, _) -> pure n+            Nothing+                | ms <= 0 -> assertFailure "the child never wrote its pid" >> error "unreachable"+                | otherwise -> threadDelay 20000 >> waitForFile (ms - 20) path++drift :: IO ()+drift = withSystemTempDirectory "detached" $ \dir -> do+    let d = sleeper dir ["30"]+        d' = sleeper dir ["31"]+    flip finally (cleanup d' >> cleanup d) $ do+        _ <- runUpCapturing (detached silent d)+        rec <- recorded d+        v <- judge d'+        case v of+            Drifted _ _ -> pure ()+            other -> assertFailure ("expected Drifted, got " <> show other)+        out <- runUpCapturing (detached silent d')+        assertEqual "up with the new command line" ["Done detached"] (outcomes out)+        g <- goneWithin 2000 rec.pr_pid+        assertBool "the old process is stopped" g+        rec' <- recorded d'+        Just i <- identify rec'.pr_pid+        assertEqual "the new one runs" ["sleep", "31"] i.id_argv+        _ <- runDownCapturing (detached silent d')+        pure ()++pidFileFormat :: IO ()+pidFileFormat = do+    let rec = PidRecord 4242 123456 "b00t" ["srv", "--port", "80 80", "line\nbreak"] (Just "/srv")+        rendered = renderPidFile rec+    assertEqual "first line is the pid" (Just "4242") (case BS8.lines rendered of (l : _) -> Just l; _ -> Nothing)+    assertEqual "round trip" (Just rec) (parsePidFile rendered)+    assertBool "a bare pid proves nothing" (isNothing (parsePidFile "4242\n"))+    assertBool "garbage" (isNothing (parsePidFile "hello\n{}\n"))++statParsing :: IO ()+statParsing = do+    let line = "777 (we) ird) name) S 1 778 778 0 -1 4194560 0 0 0 0 0 0 0 0 20 0 1 0 98765 0 0\n"+    assertEqual "state, pgrp, start" (Just ('S', 778, 98765)) (parseStat line)+    assertBool "truncated" (isNothing (parseStat "1 (x) S 1"))++notesNoEnvValues :: IO ()+notesNoEnvValues = do+    let d = (sleeper "/nonexistent" ["30"]){detached_env = [("API_TOKEN", "s3cr3t-value")]}+    case (detached silent d).node of+        Actions act -> do+            let ext = act.extension+                text = Text.unlines (ext.help : ext.notes)+            assertBool "names the variable" ("API_TOKEN" `Text.isInfixOf` text)+            assertBool "never its value" (not ("s3cr3t" `Text.isInfixOf` text))+            assertEqual "ref" (detachedRef d) ext.ref+        Actionless -> assertFailure "not an action node"
+ test/Test/EdgeListReference.hs view
@@ -0,0 +1,195 @@+{-# LANGUAGE OverloadedRecordDot #-}+{-# LANGUAGE ScopedTypeVariables #-}++{- | 'Salmon.Op.Dag.record', 'Salmon.Op.Dag.fromMagma',+'Salmon.Op.Dag.transposeOf', 'Salmon.Op.Dag.mergeDag' and+'Salmon.Actions.Query.outline' as they were before their edge lists stopped+searching and appending on the right (the profiles' finding 3), kept+verbatim as the reference "Test.EdgeListSpec" compares the current ones with.++Each is quadratic in the edges of one node: keep their inputs small. Nothing+outside the tests uses this module.+-}+module Test.EdgeListReference (+    recordAppending,+    fromMagmaAppending,+    transposeOfAppending,+    mergeDagAppending,+    OutlineAppending,+    outlineAppending,+    appendingRefs,+    appendingRoots,+    appendingChildren,+    appendingFirstPaths,+) where++import Control.Comonad.Cofree (Cofree (..))+import Data.Foldable (toList)+import Data.List (foldl')+import qualified Data.List as List+import Data.Map.Strict (Map)+import qualified Data.Map.Strict as Map+import Data.Set (Set)+import qualified Data.Set as Set+import Data.Text (Text)++import Salmon.Builtin.Extension (Extension (..), Op)+import Salmon.Op.Actions+import Salmon.Op.Dag (Conflict (..), Dag (..), dagOrder, dependenciesOf, emptyDag, representativeOf)+import Salmon.Op.Graph+import Salmon.Op.OpGraph+import Salmon.Op.Ref++-- | 'Salmon.Op.Dag.record', as it was.+recordAppending ::+    (Act ext -> Act ext -> Bool) ->+    Ref ->+    Act ext ->+    [Ref] ->+    Dag ext ->+    Dag ext+recordAppending same aref act predRefs dag =+    Dag+        { dagNodes = Map.insert aref act (dagNodes dag)+        , dagDependencies = foldl' addDependency deps0 predRefs+        , dagDependants = foldl' addDependant dependants0 predRefs+        , dagOrderRev = order'+        , dagConflicts = conflicts'+        }+  where+    previous = Map.lookup aref (dagNodes dag)++    conflicts'+        | Just old <- previous, not (same old act) =+            Conflict aref act old : dagConflicts dag+        | otherwise = dagConflicts dag++    order'+        | Map.member aref (dagNodes dag) = dagOrderRev dag+        | otherwise = aref : dagOrderRev dag++    deps0 = Map.insertWith (\_ old -> old) aref [] (dagDependencies dag)+    dependants0 = Map.insertWith (\_ old -> old) aref [] (dagDependants dag)++    addDependency m d = Map.insertWith snoc aref [d] (Map.insertWith (\_ old -> old) d [] m)+    addDependant m d = Map.insertWith snoc d [aref] m++    snoc [new] old+        | new `elem` old = old+        | otherwise = old <> [new]+    snoc new old = old <> filter (`notElem` old) new++-- | 'Salmon.Op.Dag.fromMagma', as it was.+fromMagmaAppending :: Map Ref (Act ext) -> Set (Ref, Ref) -> Dag ext+fromMagmaAppending magma edges = foldl' add emptyDag (Map.keys magma)+  where+    add dag r = recordAppending (\_ _ -> True) r (magma Map.! r) (deps r) dag++    deps :: Ref -> [Ref]+    deps r = Map.findWithDefault [] r incoming++    incoming :: Map Ref [Ref]+    incoming =+        Map.fromListWith+            (flip (<>))+            [ (dependant, [dependency])+            | (dependency, dependant) <- Set.toList edges+            , Map.member dependency magma+            , Map.member dependant magma+            ]++-- | 'Salmon.Op.Dag.transposeOf', as it was.+transposeOfAppending :: Map Ref [Ref] -> Map Ref [Ref]+transposeOfAppending deps =+    Map.union+        (Map.fromListWith (flip (<>)) [(d, [r]) | (r, ds) <- Map.toList deps, d <- ds])+        (Map.map (const []) deps)++-- | 'Salmon.Op.Dag.mergeDag', as it was.+mergeDagAppending :: (Act ext -> Act ext -> Bool) -> Dag ext -> Dag ext -> Dag ext+mergeDagAppending same into from = foldl' step into (dagOrder from)+  where+    step dag r =+        case representativeOf from r of+            Nothing -> dag+            Just act -> recordAppending same r act (dependenciesOf from r) dag++-------------------------------------------------------------------------------++-- | 'Salmon.Actions.Query.Outline', as it was, with its fields open.+data OutlineAppending = OutlineAppending+    { oaEntries :: !(Map Ref EntryAppending)+    , oaRootsRev :: ![Ref]+    , oaOrderRev :: ![Ref]+    }++data EntryAppending = EntryAppending+    { eaShorthand :: !Text+    , eaHelp :: !Text+    , eaFirstPath :: ![Text]+    , eaChildrenRev :: ![Ref]+    }++-- | 'Salmon.Actions.Query.outline', as it was.+outlineAppending :: Cofree Graph Op -> OutlineAppending+outlineAppending = go Nothing [] (OutlineAppending Map.empty [] [])+  where+    go :: Maybe Ref -> [Text] -> OutlineAppending -> Cofree Graph Op -> OutlineAppending+    go parent pfx acc (x :< g) =+        case x.node of+            Actionless -> List.foldl' (go parent pfx) acc (toList g)+            Actions act ->+                let ref' = act.extension.ref+                 in case Map.lookup ref' acc.oaEntries of+                        Nothing ->+                            let path = pfx <> [shorthand act]+                                entered =+                                    acc+                                        { oaEntries =+                                            Map.insert ref' (EntryAppending (shorthand act) act.extension.help path []) acc.oaEntries+                                        , oaOrderRev = ref' : acc.oaOrderRev+                                        }+                             in List.foldl' (go (Just ref') path) (link parent ref' entered) (toList g)+                        Just e ->+                            List.foldl' (peek ref' e.eaFirstPath) (link parent ref' acc) (toList g)++    peek :: Ref -> [Text] -> OutlineAppending -> Cofree Graph Op -> OutlineAppending+    peek ref' path acc c@(x :< g) =+        case x.node of+            Actionless -> List.foldl' (peek ref' path) acc (toList g)+            Actions act+                | linked (Just ref') act.extension.ref acc -> acc+                | otherwise -> go (Just ref') path acc c++    linked :: Maybe Ref -> Ref -> OutlineAppending -> Bool+    linked Nothing ref' acc = ref' `elem` acc.oaRootsRev+    linked (Just parent) ref' acc =+        maybe False (elem ref' . (.eaChildrenRev)) (Map.lookup parent acc.oaEntries)++    link :: Maybe Ref -> Ref -> OutlineAppending -> OutlineAppending+    link parent ref' acc+        | linked parent ref' acc = acc+        | otherwise = case parent of+            Nothing -> acc{oaRootsRev = ref' : acc.oaRootsRev}+            Just p ->+                acc{oaEntries = Map.adjust (\e -> e{eaChildrenRev = ref' : e.eaChildrenRev}) p acc.oaEntries}++-- | What 'Salmon.Actions.Query.outlineRefs' reads.+appendingRefs :: OutlineAppending -> Set Ref+appendingRefs = Map.keysSet . (.oaEntries)++-- | What 'Salmon.Actions.Query.outlineRoots' reads.+appendingRoots :: OutlineAppending -> [Ref]+appendingRoots o = reverse o.oaRootsRev++-- | What 'Salmon.Actions.Query.outlineChildren' reads.+appendingChildren :: OutlineAppending -> Ref -> [Ref]+appendingChildren o r = maybe [] (reverse . (.eaChildrenRev)) (Map.lookup r o.oaEntries)++-- | 'Salmon.Actions.Query.outlineFirstPaths', as it was.+appendingFirstPaths :: OutlineAppending -> [([Text], Ref, Text)]+appendingFirstPaths o =+    [ (e.eaFirstPath, r, e.eaHelp)+    | r <- reverse o.oaOrderRev+    , Just e <- [Map.lookup r o.oaEntries]+    ]
+ test/Test/EdgeListSpec.hs view
@@ -0,0 +1,387 @@+{-# LANGUAGE OverloadedStrings #-}++{- | Layer 0 coverage for the edge lists of 'Salmon.Op.Dag' and+'Salmon.Actions.Query.Outline' (the profiles' finding 3): 'Dag.record',+'Dag.mergeDag', 'Dag.foldDag', 'Dag.fromMagma', 'Dag.transposeOf' and+'Query.outline' no longer search a node's edge list on each insert or append+to it on the right, and must give exactly what they gave before, edge order+included.++"Test.EdgeListReference" keeps the appending versions verbatim. The unit+cases pin the order on the cases that matter (a fan, a node over many, a+repeated edge, a node declared twice in either order); the properties+compare the current versions with the reference on random inputs and on+the fixture graphs; the last case is the scale guard, at a size the+appending versions could not finish in the bound.+-}+module Test.EdgeListSpec (tests) where++import Control.Exception (evaluate)+import Data.List (foldl', sort)+import qualified Data.Map.Strict as Map+import Data.Map.Strict (Map)+import Data.Maybe (fromJust)+import qualified Data.Set as Set+import Data.Text (Text)+import qualified Data.Text as Text+import System.Timeout (timeout)+import Test.QuickCheck (Gen, Property, chooseInt, conjoin, counterexample, elements, forAll, label, listOf, oneof, suchThat, vectorOf, (===))+import Test.Tasty (TestTree, localOption, testGroup)+import Test.Tasty.HUnit (assertEqual, assertFailure, testCase)+import Test.Tasty.QuickCheck (QuickCheckTests (..), testProperty)++import qualified Salmon.Actions.Query as Query+import Salmon.Builtin.Extension (Extension, Op, dynamics, evalDeps, help, nodeps, notes, op, opAct, ref)+import Salmon.Op.Actions (Act (..))+import qualified Salmon.Op.Dag as Dag+import Salmon.Op.Ref (Ref, mkRef)++import Test.DagReference (foldDagPerOccurrenceWith)+import Test.EdgeListReference+import Test.GraphFixture++tests :: TestTree+tests =+    testGroup+        "edge lists (Dag.record, fromMagma, transposeOf, Query.outline)"+        [ testGroup+            "unit"+            [ testCase "a fan: the root's dependencies in declaration order, each leaf's one dependant" fanOrder+            , testCase "a node many depend on: its dependants in the order they were recorded" manyDependants+            , testCase "a repeated edge and a self-edge are recorded once, where first seen" repeatedEdges+            , testCase "a node declared twice keeps its first edges first, in either order" twoOrders+            , testCase "recording onto a built Dag extends its lists where they stood" ontoABuiltDag+            , testCase "fromMagma and transposeOf on a fan and a node many depend on" fromMagmaAndTranspose+            , testCase "the outline of a fan lists its children in the order they were met" outlineOfAFan+            ]+        , testGroup+            "equal to the appending versions"+            [ localOption (QuickCheckTests 2000) $+                testProperty "record: any run of records, from empty or onto a built Dag" prop_recordMatches+            , localOption (QuickCheckTests 1000) $+                testProperty "mergeDag: any two Dags" prop_mergeDagMatches+            , localOption (QuickCheckTests 1000) $+                testProperty "fromMagma: any magma and edge set" prop_fromMagmaMatches+            , localOption (QuickCheckTests 1000) $+                testProperty "transposeOf: any dependency map, repeats included" prop_transposeOfMatches+            , localOption (QuickCheckTests 500) $+                testProperty "foldDag and outline: the fixture graphs" prop_fixturesMatch+            ]+        , testGroup+            "laws"+            [ localOption (QuickCheckTests 1000) $+                testProperty "transposeOf twice is the identity on a closed map without repeats" prop_transposeTwice+            , localOption (QuickCheckTests 1000) $+                testProperty "fromMagma is unchanged by an edge already present, and inverts dagEdges" prop_fromMagmaIdempotent+            ]+        , testCase "a fan and a node many depend on, 100 000 nodes each, within a minute per operation" scaleGuard+        ]++-------------------------------------------------------------------------------+-- Nodes.++siteRef :: Int -> Ref+siteRef = mkRef "edgelistspec" . siteName++siteName :: Int -> Text+siteName s = "site" <> Text.pack (show s)++-- | The act of a node at a site, with one of a few descriptions (so two+-- declarations of a site can differ, which is a conflict).+actAt :: Int -> Int -> Act Extension+actAt site description =+    fromJust . opAct $+        op (siteName site) nodeps $ \x ->+            x{ref = siteRef site, help = "description " <> Text.pack (show description)}++-- | Everything a 'Dag.Dag' holds that can be compared: representatives by+-- their help, both edge maps and the order as they are, conflicts by ref and+-- the two helps.+type View = (Map Ref Text, Map Ref [Ref], Map Ref [Ref], [Ref], [(Ref, Text, Text)])++view :: Dag.Dag Extension -> View+view d =+    ( fmap helpOf (Dag.dagNodes d)+    , Dag.dagDependencies d+    , Dag.dagDependants d+    , Dag.dagOrder d+    , [(c.conflictRef, helpOf c.conflictKept, helpOf c.conflictReplaced) | c <- Dag.dagConflicts d]+    )+  where+    helpOf :: Act Extension -> Text+    helpOf act = act.extension.help++-- | One 'Dag.record' call: site, description, dependencies (as sites).+type Step = (Int, Int, [Int])++runSteps ::+    ((Act Extension -> Act Extension -> Bool) -> Ref -> Act Extension -> [Ref] -> Dag.Dag Extension -> Dag.Dag Extension) ->+    Dag.Dag Extension ->+    [Step] ->+    Dag.Dag Extension+runSteps rec = foldl' (\d (s, desc, ds) -> rec Dag.sameRepresentative (siteRef s) (actAt s desc) (fmap siteRef ds) d)++-------------------------------------------------------------------------------+-- Unit cases.++fanOrder :: IO ()+fanOrder = do+    let n = 50+        root = generate Fan n 0+        dag = Dag.foldDag Dag.sameRepresentative (evalDeps root) :: Dag.Dag Extension+        old = foldDagPerOccurrenceWith recordAppending Dag.sameRepresentative (evalDeps root)+    assertEqual "the root depends on every leaf, in declaration order" (fmap nodeRef [1 .. n - 1]) (Dag.dependenciesOf dag (nodeRef 0))+    assertEqual "each leaf has the root as its one dependant" [[nodeRef 0]] (Set.toList (Set.fromList [Dag.dependantsOf dag (nodeRef i) | i <- [1 .. n - 1]]))+    assertEqual "what the appending fold gives" (view old) (view dag)+    assertEqual "mergeDag onto itself, as the appending one" (view (mergeDagAppending Dag.sameRepresentative dag dag)) (view (Dag.mergeDag Dag.sameRepresentative dag dag))++manyDependants :: IO ()+manyDependants = do+    -- sites 1..40 each depend on site 0, recorded in a shuffled order.+    let order = [17, 3, 40, 1, 22, 9, 30, 2] <> [s | s <- [1 .. 40], s `notElem` [17, 3, 40, 1, 22, 9, 30, 2]]+        steps = [(s, 0, [0]) | s <- order]+        new = runSteps Dag.record Dag.emptyDag steps+        old = runSteps recordAppending Dag.emptyDag steps+    assertEqual "site 0's dependants, in the order recorded" (fmap siteRef order) (Dag.dependantsOf new (siteRef 0))+    assertEqual "site 0 is a key of both maps, though never recorded itself" (Just [], Nothing) (Map.lookup (siteRef 0) (Dag.dagDependencies new), Dag.representativeOf new (siteRef 0) >> Just ())+    assertEqual "what the appending record gives" (view old) (view new)++repeatedEdges :: IO ()+repeatedEdges = do+    let steps = [(1, 0, [2, 3, 2, 1, 3, 2]), (1, 0, [3, 2, 4]), (2, 0, [3, 3])]+        new = runSteps Dag.record Dag.emptyDag steps+        old = runSteps recordAppending Dag.emptyDag steps+    assertEqual "each dependency once, first seen first, the self-edge included" (fmap siteRef [2, 3, 1, 4]) (Dag.dependenciesOf new (siteRef 1))+    assertEqual "a dependant once however often its edge is recorded" (fmap siteRef [1, 2]) (Dag.dependantsOf new (siteRef 3))+    assertEqual "what the appending record gives" (view old) (view new)++twoOrders :: IO ()+twoOrders = do+    let first = [(1, 0, [2, 3]), (1, 1, [4, 2])]+        second = [(1, 1, [4, 2]), (1, 0, [2, 3])]+        a = runSteps Dag.record Dag.emptyDag first+        b = runSteps Dag.record Dag.emptyDag second+    assertEqual "declared over 2, 3 then over 4, 2" (fmap siteRef [2, 3, 4]) (Dag.dependenciesOf a (siteRef 1))+    assertEqual "declared over 4, 2 then over 2, 3" (fmap siteRef [4, 2, 3]) (Dag.dependenciesOf b (siteRef 1))+    assertEqual "one conflict each way" (1, 1) (length (Dag.dagConflicts a), length (Dag.dagConflicts b))+    assertEqual "first order, as the appending record" (view (runSteps recordAppending Dag.emptyDag first)) (view a)+    assertEqual "second order, as the appending record" (view (runSteps recordAppending Dag.emptyDag second)) (view b)++ontoABuiltDag :: IO ()+ontoABuiltDag = do+    let base = runSteps recordAppending Dag.emptyDag [(1, 0, [2, 3]), (4, 0, [3]), (5, 0, [])]+        more = [(1, 0, [5, 2]), (6, 0, [3, 1]), (3, 0, [])]+    assertEqual+        "lists extended at their end; untouched entries kept"+        (view (runSteps recordAppending base more))+        (view (runSteps Dag.record base more))+    assertEqual "site 3's dependants: the old ones, then the new" (fmap siteRef [1, 4, 6]) (Dag.dependantsOf (runSteps Dag.record base more) (siteRef 3))++fromMagmaAndTranspose :: IO ()+fromMagmaAndTranspose = do+    let n = 60+        magma = Map.fromList [(siteRef s, actAt s 0) | s <- [0 .. n]]+        fan = Set.fromList [(siteRef s, siteRef 0) | s <- [1 .. n]]+        rfan = Set.fromList [(siteRef 0, siteRef s) | s <- [1 .. n]]+    assertEqual "fan: as the appending fromMagma" (view (fromMagmaAppending magma fan)) (view (Dag.fromMagma magma fan))+    assertEqual "a node many depend on: as the appending fromMagma" (view (fromMagmaAppending magma rfan)) (view (Dag.fromMagma magma rfan))+    let deps = Map.fromList ((siteRef 0, fmap siteRef ([1 .. n] <> [7, 7])) : [(siteRef s, [siteRef 0]) | s <- [1 .. n]])+    assertEqual "transposeOf, a repeated edge included, as the appending one" (transposeOfAppending deps) (Dag.transposeOf deps)++outlineOfAFan :: IO ()+outlineOfAFan = do+    let n = 50+        cograph = evalDeps (generate Fan n 0)+        new = Query.outline cograph+        old = outlineAppending cograph+    assertEqual "the root's children, in declaration order" (fmap nodeRef [1 .. n - 1]) (Query.outlineChildren new (nodeRef 0))+    assertEqual "one root" [nodeRef 0] (Query.outlineRoots new)+    assertEqual "as the appending outline" (outlineView old) (outlineViewNew new)++-------------------------------------------------------------------------------+-- Properties against the appending versions.++genStep :: Int -> Gen Step+genStep sites = do+    s <- chooseInt (0, sites - 1)+    desc <- elements [0, 0, 0, 1, 2]+    k <- chooseInt (0, 5)+    ds <- vectorOf k (chooseInt (0, sites - 1))+    pure (s, desc, ds)++genSteps :: Gen (Int, [Step])+genSteps = do+    sites <- chooseInt (1, 12)+    steps <- listOf (genStep sites)+    pure (sites, steps)++prop_recordMatches :: Property+prop_recordMatches =+    forAll genSteps $ \(sites, steps) ->+        forAll (chooseInt (0, length steps)) $ \cut ->+            let (before, after) = splitAt cut steps+                -- a base built by the reference, then the rest by each.+                base = runSteps recordAppending Dag.emptyDag before+             in label (if sites <= 3 then "few sites (many repeats)" else "more sites") $+                    conjoin+                        [ counterexample "from empty" (view (runSteps recordAppending Dag.emptyDag steps) === view (runSteps Dag.record Dag.emptyDag steps))+                        , counterexample "onto a built Dag" (view (runSteps recordAppending base after) === view (runSteps Dag.record base after))+                        ]++prop_mergeDagMatches :: Property+prop_mergeDagMatches =+    forAll genSteps $ \(sites, steps) ->+        forAll (vectorOf (length steps `div` 2 + 1) (genStep sites)) $ \others ->+            let into = runSteps recordAppending Dag.emptyDag steps+                from = runSteps recordAppending Dag.emptyDag others+             in conjoin+                    [ view (mergeDagAppending Dag.sameRepresentative into from) === view (Dag.mergeDag Dag.sameRepresentative into from)+                    , counterexample "into an empty Dag" (view (mergeDagAppending Dag.sameRepresentative Dag.emptyDag from) === view (Dag.mergeDag Dag.sameRepresentative Dag.emptyDag from))+                    ]++-- | A magma over some of the sites and edges between any of them (so some+-- edges name a site the magma lacks, which 'Dag.fromMagma' drops).+genMagmaEdges :: Gen (Map Ref (Act Extension), Set.Set (Ref, Ref))+genMagmaEdges = do+    sites <- chooseInt (1, 15)+    held <- listOf (chooseInt (0, sites - 1))+    edges <- listOf ((,) <$> chooseInt (0, sites) <*> chooseInt (0, sites))+    pure+        ( Map.fromList [(siteRef s, actAt s 0) | s <- held]+        , Set.fromList [(siteRef a, siteRef b) | (a, b) <- edges]+        )++prop_fromMagmaMatches :: Property+prop_fromMagmaMatches =+    forAll genMagmaEdges $ \(magma, edges) ->+        view (fromMagmaAppending magma edges) === view (Dag.fromMagma magma edges)++genDeps :: Gen (Map Ref [Ref])+genDeps = do+    sites <- chooseInt (1, 15)+    keys <- listOf (chooseInt (0, sites - 1))+    entries <- traverse (\k -> (,) k <$> listOf (chooseInt (0, sites))) keys+    pure (Map.fromList [(siteRef k, fmap siteRef ds) | (k, ds) <- entries])++prop_transposeOfMatches :: Property+prop_transposeOfMatches =+    forAll genDeps $ \deps -> transposeOfAppending deps === Dag.transposeOf deps++-- | Fixture graphs small enough for the per-occurrence fold.+genFixture :: Gen (Shape, Op)+genFixture = do+    shape <-+        oneof+            [ pure Chain+            , pure Fan+            , Tree <$> chooseInt (1, 4)+            , Diamonds <$> chooseInt (1, 4)+            , Layered <$> chooseInt (1, 5) <*> chooseInt (1, 3)+            , RandomLayered <$> chooseInt (1, 5) <*> chooseInt (1, 3)+            , pure (Layered 8 8)+            ]+            `suchThat` valid+    size <- chooseInt (0, 80) `suchThat` (\n -> (counts shape n).countOccurrences <= 3000)+    seed <- fromIntegral <$> chooseInt (0, maxBound)+    form <- elements [AsVertices, AsConnect, AsOverlay]+    pure (shape, generateWith defaultOptions{optEdges = form} shape size seed)+  where+    valid (Layered w k) = k <= w+    valid (RandomLayered w k) = k <= w+    valid _ = True++prop_fixturesMatch :: Property+prop_fixturesMatch =+    forAll genFixture $ \(shape, root) ->+        let cograph = evalDeps root+            -- no conflict test: the fixture graphs have no collisions here,+            -- and this compares the edges and the order, which the per-node+            -- fold gives exactly as the per-occurrence one did.+            new = Dag.foldDag (\_ _ -> True) cograph :: Dag.Dag Extension+            old = foldDagPerOccurrenceWith recordAppending (\_ _ -> True) cograph+         in label (takeWhile (/= ' ') (show shape)) $+                conjoin+                    [ counterexample "foldDag: order" (Dag.dagOrder old === Dag.dagOrder new)+                    , counterexample "foldDag: dependencies" (Dag.dagDependencies old === Dag.dagDependencies new)+                    , counterexample "foldDag: dependants" (Dag.dagDependants old === Dag.dagDependants new)+                    , counterexample "outline" (outlineView (outlineAppending cograph) === outlineViewNew (Query.outline cograph))+                    ]++-- | What an outline is read by: its nodes, its roots, each node's children+-- (order included) and the first paths with their help.+type OutlineView = ([Ref], [Ref], [(Ref, [Ref])], [([Text], Ref, Text)])++outlineView :: OutlineAppending -> OutlineView+outlineView o =+    let refs = Set.toList (appendingRefs o)+     in (refs, appendingRoots o, [(r, appendingChildren o r) | r <- refs], appendingFirstPaths o)++outlineViewNew :: Query.Outline -> OutlineView+outlineViewNew o =+    let refs = Set.toList (Query.outlineRefs o)+     in (refs, Query.outlineRoots o, [(r, Query.outlineChildren o r) | r <- refs], Query.outlineFirstPaths o)++-------------------------------------------------------------------------------+-- Laws.++{- | Transposing twice gives back a dependency map whose every target is a+key and whose lists have no repeats, up to the order inside each list (a+transposed list is in key order); exactly, when each list is in key order.+-}+prop_transposeTwice :: Property+prop_transposeTwice =+    forAll genDeps $ \deps0 ->+        let keys = Set.fromList (Map.keys deps0 <> concat (Map.elems deps0))+            closed = Map.union (fmap (Set.toList . Set.fromList) deps0) (Map.fromSet (const []) keys)+            sorted = fmap sort closed+         in conjoin+                [ counterexample "as sets" (fmap Set.fromList (Dag.transposeOf (Dag.transposeOf closed)) === fmap Set.fromList closed)+                , counterexample "exactly, lists in key order" (Dag.transposeOf (Dag.transposeOf sorted) === sorted)+                ]++prop_fromMagmaIdempotent :: Property+prop_fromMagmaIdempotent =+    forAll (genMagmaEdges `suchThat` (not . Set.null . snd)) $ \(magma, edges) ->+        forAll (elements (Set.toList edges)) $ \e ->+            let dag = Dag.fromMagma magma edges+             in conjoin+                    [ counterexample "re-inserting an edge" (view (Dag.fromMagma magma (Set.insert e edges)) === view dag)+                    , counterexample "fromMagma . dagEdges" (view (Dag.fromMagma (Dag.dagNodes dag) (Dag.dagEdges dag)) === view dag)+                    ]++-------------------------------------------------------------------------------+-- Scale.++{- | A fan of 100 000 nodes (one node over every other) and its mirror (every+node over one), through each operation this feature touched. The appending+versions were quadratic here: a fan of 10 000 took 1.3 s to fold, 2.8 s+through 'Dag.fromMagma' and 2.0 s through 'Dag.mergeDag' (the profiles'+finding 3), so 100 000 would take minutes. Like "Test.GraphScaleSpec" this+times nothing: the bound is a minute per operation, where well under a+second is expected.+-}+scaleGuard :: IO ()+scaleGuard = do+    let n = 100000+        fan = generate Fan n 0+    folded <- within "foldDag over a fan" (evaluate (Dag.foldDag Dag.sameRepresentative (evalDeps fan) :: Dag.Dag Extension))+    assertEqual "the fan's root depends on every leaf" (n - 1) (length (Dag.dependenciesOf folded (nodeRef 0)))+    rebuilt <- within "fromMagma over a fan" (evaluate (Dag.fromMagma (Dag.dagNodes folded) (Dag.dagEdges folded)))+    assertEqual "fromMagma: every edge" (n - 1) (Set.size (Dag.dagEdges rebuilt))+    merged <- within "mergeDag of a fan onto itself" (evaluate (Dag.mergeDag Dag.sameRepresentative rebuilt folded))+    assertEqual "mergeDag: nothing added" (Dag.dagEdges folded) (Dag.dagEdges merged)+    outlined <- within "outline of a fan" (evaluate (length (Query.outlineChildren (Query.outline (evalDeps fan)) (nodeRef 0))))+    assertEqual "outline: every leaf under the root" (n - 1) outlined+    let magma = Dag.dagNodes folded+        mirror = Set.fromList [(nodeRef 0, nodeRef i) | i <- [1 .. n - 1]]+    rdag <- within "fromMagma, every node over one" (evaluate (Dag.fromMagma magma mirror))+    assertEqual "every leaf stands on the root" (n - 1) (length (Dag.dependantsOf rdag (nodeRef 0)))+    transposed <- within "transposeOf, every node over one" (evaluate (Dag.transposeOf (Dag.dagDependencies rdag)))+    assertEqual "transposeOf gives the dependants" (Dag.dagDependants rdag) transposed+  where+    within :: String -> IO a -> IO a+    within what act = do+        outcome <- timeout (60 * 1000000) act+        case outcome of+            Just x -> pure x+            Nothing -> assertFailure (what <> ": still running after a minute, which is what a quadratic edge list looks like")
test/Test/EtcdSpec.hs view
@@ -50,6 +50,19 @@             assertEqual "" True (ok (seedDecision self [(head others, Just (fmap listedOf members))]))         , testCase "seed: a cluster that does not list us is refused" $             assertEqual "" False (ok (seedDecision self [(head others, Just (fmap listedOf others))]))+        , testCase "join: the config says existing and still lists every member" $ do+            let t = Text.unpack (renderConfigFor Join cfg)+            assertBool t ("initial-cluster-state: existing" `isInfixOf` t)+            assertBool t (not ("initial-cluster-state: new" `isInfixOf` t))+            assertBool t ("name: b" `isInfixOf` t)+        , testCase "join: nobody answering is refused" $+            assertEqual "" True (either (const True) (const False) (joinDecision self [(m, Nothing) | m <- others]))+        , testCase "join: a cluster not listing us is added through its answerer" $+            assertEqual "" (Right (AddVia (head others))) (joinDecision self [(head others, Just (fmap listedOf others))])+        , testCase "join: a cluster already listing us is not asked to add again" $+            assertEqual "" (Right AlreadyListed) (joinDecision self [(head others, Just (fmap listedOf members))])+        , testCase "join: skips unreachable members and uses one that answers" $+            assertEqual "" (Right (AddVia (others !! 1))) (joinDecision self [(head others, Nothing), (others !! 1, Just (fmap listedOf others))])         ]   where     cfgText = Text.unpack (renderConfig cfg)
test/Test/FilesystemSpec.hs view
@@ -24,7 +24,11 @@ import qualified Data.Text as Text import Data.Bits ((.&.)) import System.Directory (+    copyFile,     createDirectory,+    createDirectoryLink,+    getHomeDirectory,+    renameDirectory,     createDirectoryIfMissing,     createFileLink,     doesDirectoryExist,@@ -33,15 +37,17 @@     pathIsSymbolicLink,     removeFile,  )-import System.FilePath ((</>))+import System.FilePath (takeDirectory, (</>)) import qualified System.Posix.Files as Posix import qualified System.Posix.User as PosixUser import Test.Tasty (TestTree, testGroup) import Test.Tasty.HUnit (assertBool, assertEqual, testCase)  import Salmon.Actions.UpDown (CheckResult (..), Report (..))-import Salmon.Builtin.Extension (Extension)-import Salmon.Op.Actions (shorthand)+import Salmon.Builtin.Extension (Extension, Op, opAct)+import Salmon.Builtin.Extension (help, notes, ref)+import Salmon.Op.Actions (extension, shorthand)+import Salmon.Op.Ref (mkRef) import qualified Salmon.Builtin.Nodes.Filesystem as FS  import Test.Harness (runDown, runUp, runUpCapturing, withTempDir)@@ -74,6 +80,19 @@             , testCase "ownedMode is applied to the top entry only, never descendants" ownedModeNotAppliedToDescendants             , testCase "a dangling symlink inside the tree is chowned itself, never followed" ownedTreeDoesNotFollowSymlinks             ]+        , testGroup+            "scratchDir: down removes the tree, guarded by identity"+            [ testCase "up, a file inside, down: gone, and a second down also succeeds" scratchAcceptance+            , testCase "check: missing before up, Success after, and a second up is a no-op" scratchCheckAndIdempotentUp+            , testCase "a directory salmon did not create is never taken over nor removed" scratchRefusesPreExisting+            , testCase "a claim copied into a recreated directory does not match" scratchRefusesCopiedClaim+            , testCase "a symlink at the path is refused, its target untouched" scratchRefusesSymlinkPath+            , testCase "a symlink inside the tree is removed, never followed" scratchDoesNotFollowInnerSymlinks+            , testCase "a deleted claim makes down refuse" scratchRefusesMissingClaim+            , testCase "root, top-level, relative and dotted paths are refused" scratchPathRefusals+            , testCase "the home directory and its ancestors are refused" scratchRefusesHome+            , testCase "dir's rendering is unchanged by the new variant" dirRenderingUnchanged+            ]         ]  -- | The node under test, and the check it carries, for one path/contents pair.@@ -332,3 +351,124 @@  skips :: [Report Extension] -> [Text] skips rs = [shorthand act | Skip act <- rs, shorthand act == "file-contents"]++-------------------------------------------------------------------------------++scratchAt :: FilePath -> Op+scratchAt = FS.scratchDir . FS.Directory++-- | The customer's acceptance criterion, verbatim.+scratchAcceptance :: IO ()+scratchAcceptance = withTempDir $ \d -> do+    let dir = d </> "rig" </> "logs"+    assertBool "up succeeded" =<< runUp (scratchAt dir)+    createDirectoryIfMissing True (dir </> "nested")+    writeFile (dir </> "child.log") "written by a child process\n"+    writeFile (dir </> "nested" </> "app.pid") "4242\n"+    assertBool "down succeeded" =<< runDown (scratchAt dir)+    assertEqual "the directory is gone" False =<< doesDirectoryExist dir+    assertBool "its parent stays" =<< doesDirectoryExist (d </> "rig")+    assertBool "a second down also succeeds" =<< runDown (scratchAt dir)++scratchCheckAndIdempotentUp :: IO ()+scratchCheckAndIdempotentUp = withTempDir $ \d -> do+    let dir = d </> "scratch"+    before <- FS.checkScratchDir dir+    assertBool ("missing before up: " <> show before) (isFailure before)+    assertBool "up" =<< runUp (scratchAt dir)+    writeFile (dir </> "kept.log") "x\n"+    assertEqual "Success after up" Success =<< FS.checkScratchDir dir+    assertBool "a second up succeeds" =<< runUp (scratchAt dir)+    assertBool "and leaves the contents alone" =<< doesFileExist (dir </> "kept.log")++scratchRefusesPreExisting :: IO ()+scratchRefusesPreExisting = withTempDir $ \d -> do+    let dir = d </> "theirs"+    createDirectory dir+    writeFile (dir </> "precious") "not salmon's\n"+    assertEqual "up refuses to adopt it" False =<< runUp (scratchAt dir)+    assertEqual "down refuses to remove it" False =<< runDown (scratchAt dir)+    assertBool "the contents survive" =<< doesFileExist (dir </> "precious")+    assertEqual "and no claim was written" False =<< doesFileExist (dir </> FS.scratchClaimFile)++scratchRefusesCopiedClaim :: IO ()+scratchRefusesCopiedClaim = withTempDir $ \d -> do+    let dir = d </> "scratch"+    assertBool "up" =<< runUp (scratchAt dir)+    renameDirectory dir (d </> "moved-away")+    createDirectory dir+    copyFile (d </> "moved-away" </> FS.scratchClaimFile) (dir </> FS.scratchClaimFile)+    writeFile (dir </> "precious") "someone else's\n"+    assertEqual "down refuses: same path, same claim text, another inode" False =<< runDown (scratchAt dir)+    assertBool "the contents survive" =<< doesFileExist (dir </> "precious")++scratchRefusesSymlinkPath :: IO ()+scratchRefusesSymlinkPath = withTempDir $ \d -> do+    let target = d </> "target"+    let link = d </> "link"+    createDirectory target+    writeFile (target </> "precious") "x\n"+    createDirectoryLink target link+    assertEqual "up refuses a symlink" False =<< runUp (scratchAt link)+    assertEqual "down refuses a symlink" False =<< runDown (scratchAt link)+    assertBool "the target survives" =<< doesFileExist (target </> "precious")+    -- a dangling symlink is not absence either+    let dangling = d </> "dangling"+    createDirectoryLink (d </> "nowhere") dangling+    assertEqual "up refuses a dangling symlink" False =<< runUp (scratchAt dangling)+    assertBool "which stays a symlink" =<< pathIsSymbolicLink dangling++scratchDoesNotFollowInnerSymlinks :: IO ()+scratchDoesNotFollowInnerSymlinks = withTempDir $ \d -> do+    let outside = d </> "outside"+    let dir = d </> "scratch"+    createDirectory outside+    writeFile (outside </> "precious") "x\n"+    assertBool "up" =<< runUp (scratchAt dir)+    createDirectoryLink outside (dir </> "points-out")+    assertBool "down" =<< runDown (scratchAt dir)+    assertEqual "the tree is gone" False =<< doesDirectoryExist dir+    assertBool "the symlink's target survives" =<< doesFileExist (outside </> "precious")++scratchRefusesMissingClaim :: IO ()+scratchRefusesMissingClaim = withTempDir $ \d -> do+    let dir = d </> "scratch"+    assertBool "up" =<< runUp (scratchAt dir)+    removeFile (dir </> FS.scratchClaimFile)+    writeFile (dir </> "something") "x\n"+    assertEqual "down refuses" False =<< runDown (scratchAt dir)+    assertBool "and deletes nothing" =<< doesFileExist (dir </> "something")++scratchPathRefusals :: IO ()+scratchPathRefusals = do+    let refused p = assertBool ("refused: " <> p) (FS.scratchPathRefusal p /= Nothing)+    mapM_ refused ["/", "/tmp", "/tmp/", "tmp/x", "", "/tmp/../etc/x", "/tmp/./x"]+    assertEqual "a nested absolute path is fine" Nothing (FS.scratchPathRefusal "/tmp/x")+    assertEqual "a deeper one too" Nothing (FS.scratchPathRefusal "/var/lib/rig/logs")++scratchRefusesHome :: IO ()+scratchRefusesHome = do+    home <- getHomeDirectory+    r <- FS.scratchPathRefusalIO home+    assertBool ("home refused: " <> home) (r /= Nothing)+    case FS.scratchPathRefusal home of+        Just _ -> pure () -- e.g. /root: already refused as top-level+        Nothing -> assertBool "home's parent refused" . (/= Nothing) =<< FS.scratchPathRefusalIO (takeDirectory home)++-- | Pins what 'FS.dir' renders, so adding a variant never changes it (a+-- changed representative restarts what stands on it).+dirRenderingUnchanged :: IO ()+dirRenderingUnchanged =+    case opAct (FS.dir (FS.Directory "/srv/x")) of+        Nothing -> assertBool "dir has an action" False+        Just act -> do+            assertEqual "shorthand" "directory" (shorthand act)+            assertEqual "help" "ensures /srv/x exists, including subdirs" (help (extension act))+            assertEqual "ref" (mkRef "directory" ("/srv/x" :: FilePath)) (ref (extension act))+            assertEqual+                "notes"+                [ "create dir recursively"+                , "does not delete contents of the directory"+                , "reapplies rather than parking under supervision; see supReapply"+                ]+                (notes (extension act))
test/Test/FollowCacheSpec.hs view
@@ -47,7 +47,7 @@ import Salmon.Op.Track (Track (..)) import Salmon.Reporter (Reporter) -import Test.Harness (capture, withTempDir)+import Test.Harness (capture, replaceFile, withTempDir)  tests :: TestTree tests =@@ -184,7 +184,7 @@ publishAt :: FilePath -> Label -> Text -> Maybe UTCTime -> [[String]] -> IO () publishAt root lbl did published seeds = do     createDirectoryIfMissing True (registryDir root)-    LByteString.writeFile (Follow.documentPath (registryDir root) lbl) (encode (Document did (fmap SeedWords seeds) published))+    replaceFile (Follow.documentPath (registryDir root) lbl) (encode (Document did (fmap SeedWords seeds) published))  publish :: FilePath -> Label -> Text -> [[String]] -> IO () publish root lbl did = publishAt root lbl did Nothing
test/Test/FollowRegistrySpec.hs view
@@ -57,7 +57,7 @@ import Salmon.Op.Track (Track (..)) import Salmon.Reporter (Reporter) -import Test.Harness (capture, withTempDir)+import Test.Harness (capture, replaceFile, withTempDir)  tests :: TestTree tests =@@ -515,7 +515,7 @@                 | "evil" `Text.isInfixOf` Text.decodeUtf8Lenient (LByteString.toStrict bytes) = pure (Left "mentions evil")                 | otherwise = pure (Right bytes)             knobs = Knobs (Just cache) refusing-            publish bytes = createDirectoryIfMissing True reg >> LByteString.writeFile (Follow.documentPath reg web) bytes+            publish bytes = createDirectoryIfMissing True reg >> replaceFile (Follow.documentPath reg web) bytes         publish (document "web@1" [["a"]])         (w, _, freports, ()) <- withFollowing root (Follow.directoryRegistry reg) knobs [web] $ \d -> do             waitFor "the file" (fileExists root "a")
test/Test/FollowSchedulerSpec.hs view
@@ -48,7 +48,7 @@ import Salmon.Op.Track (Track (..)) import Salmon.Reporter (Reporter) -import Test.Harness (capture, withTempDir)+import Test.Harness (capture, replaceFile, withTempDir)  tests :: TestTree tests =@@ -324,7 +324,7 @@ publish :: FilePath -> Label -> Text -> [[String]] -> IO () publish root lbl did seeds = do     createDirectoryIfMissing True (registryDir root)-    LByteString.writeFile (Follow.documentPath (registryDir root) lbl) (encode (Document did (fmap SeedWords seeds) Nothing))+    replaceFile (Follow.documentPath (registryDir root) lbl) (encode (Document did (fmap SeedWords seeds) Nothing))  waitFor :: String -> IO Bool -> IO () waitFor what cond = do
test/Test/FollowSignatureSpec.hs view
@@ -50,7 +50,7 @@ import Salmon.Op.Track (Track (..)) import Salmon.Reporter (Reporter) -import Test.Harness (capture, withTempDir)+import Test.Harness (capture, replaceFile, withTempDir)  tests :: TestTree tests =@@ -389,7 +389,7 @@             reg = root </> "reg"             cache = root </> "cache"             verifier = Signature.signedVerifier Signature.RefuseUnlabelled (anyLabel [Signature.publicKey key])-            publish bytes = createDirectoryIfMissing True reg >> LByteString.writeFile (Follow.documentPath reg web) bytes+            publish bytes = createDirectoryIfMissing True reg >> replaceFile (Follow.documentPath reg web) bytes         good <- signFor key "web" (document "web@1" [["a"]])         let evil = withDocument (retitle "web@evil") good         publish good@@ -545,7 +545,7 @@             reg = root </> "reg"             cache = root </> "cache"             verifier = Signature.signedVerifier Signature.RefuseUnlabelled (anyLabel [Signature.publicKey key])-            publish bytes = createDirectoryIfMissing True reg >> LByteString.writeFile (Follow.documentPath reg prod) bytes+            publish bytes = createDirectoryIfMissing True reg >> replaceFile (Follow.documentPath reg prod) bytes         forCanary <- signFor key "canary" (document "canary@1" [["a"]])         publish forCanary         (_, sreports, freports, ()) <- withFollowing root reg verifier [prod] $ \d -> do
test/Test/FollowSpec.hs view
@@ -17,7 +17,7 @@ import Control.Concurrent (forkIO, threadDelay) import Control.Concurrent.MVar (newEmptyMVar, putMVar) import Control.Concurrent.STM (TChan, atomically, newTChanIO, readTChan, writeTChan)-import Control.Exception (SomeException, throwIO, try)+import Control.Exception (IOException, SomeException, finally, throwIO, try) import Data.Aeson (FromJSON, ToJSON, eitherDecode, encode, toJSON) import qualified Data.ByteString.Lazy as LByteString import qualified Data.Map.Strict as Map@@ -27,6 +27,7 @@ import GHC.Generics (Generic) import System.Directory (createDirectoryIfMissing, doesFileExist) import System.FilePath ((</>))+import System.IO (IOMode (..), hClose, openBinaryFile, withBinaryFile) import System.Timeout (timeout) import Test.Tasty (TestTree, testGroup) import Test.Tasty.HUnit (assertBool, assertEqual, assertFailure, testCase)@@ -44,7 +45,7 @@ import Salmon.Op.Track (Track (..)) import Salmon.Reporter (Reporter) -import Test.Harness (capture, withTempDir)+import Test.Harness (capture, replaceFile, withTempDir)  tests :: TestTree tests =@@ -55,6 +56,8 @@         , testCase "a seed dropped from the document goes down" droppedSeedGoesDown         , testCase "two labels: a seed stays up while any label still carries it" unionAcrossLabels         , testCase "a document that cannot be read is reported and the loop keeps serving" malformedIsReported+        , testCase "publishing while the fetcher is mid-read is not refused" publishUnderAReader+        , testCase "a document held open for writing is a failed fetch, and is applied once released" lockedDocumentIsAFailedFetch         , testCase "a seed the binary cannot parse or configure is reported and the rest is applied" badSeedIsContained         , testCase "the document format round-trips and refuses what it does not understand" documentFormat         ]@@ -191,10 +194,16 @@  -- | Write a document naming these seeds (one file name each) under a label. publish :: FilePath -> Label -> Text -> [[String]] -> IO ()-publish root lbl did seeds = do-    createDirectoryIfMissing True (registryDir root)-    LByteString.writeFile (Follow.documentPath (registryDir root) lbl) (encode (Document did (fmap SeedWords seeds) Nothing))+publish root lbl did seeds = writeDocument root lbl (encode (Document did (fmap SeedWords seeds) Nothing)) +{- | Put these bytes in place as a label's document, by 'replaceFile' (a+rename) and never in place: the fetcher runs in this same process, and+while a document stays malformed it re-reads it every round, so an in-place+write would race its read (see 'publishUnderAReader'). The temp file's name+starts with a dot, which no label can, so it is never a document. -}+writeDocument :: FilePath -> Label -> LByteString.ByteString -> IO ()+writeDocument root lbl = replaceFile (Follow.documentPath (registryDir root) lbl)+ -- | Poll until the condition holds, or fail after a generous bound. waitFor :: String -> IO Bool -> IO () waitFor what cond = do@@ -325,8 +334,7 @@ malformedIsReported :: IO () malformedIsReported =     withTempDir $ \root -> do-        createDirectoryIfMissing True (registryDir root)-        LByteString.writeFile (Follow.documentPath (registryDir root) (label "web")) "{\"salmon\": 1, \"id\": \"x\", \"seeds\": [{\"neither\": 1}]}"+        writeDocument root (label "web") "{\"salmon\": 1, \"id\": \"x\", \"seeds\": [{\"neither\": 1}]}"         (w, _, freports, ()) <- withFollowing root [label "web"] $ \d -> do             waitFor "the complaint" (any isMalformed <$> followReports d)             threadDelay (3 * interval)@@ -341,6 +349,49 @@   where     isMalformed Follow.Malformed{} = True     isMalformed _ = False++{- | The race behind "resource busy (file is locked)" in+'malformedIsReported', made deterministic. GHC locks files per process:+while one thread has a file open for reading, another cannot open it for+writing. The fetcher runs in the test's process and, while a document stays+malformed, re-reads it every round, so a test that rewrote the document in+place could hit the fetcher mid-read. Holding a read handle stands in for+that read: writing in place is refused, 'writeDocument' is not. -}+publishUnderAReader :: IO ()+publishUnderAReader =+    withTempDir $ \root -> do+        publish root (label "web") "web@1" [["a"]]+        let path = Follow.documentPath (registryDir root) (label "web")+        withBinaryFile path ReadMode $ \_ -> do+            inPlace <- try (LByteString.writeFile path "{}") :: IO (Either IOException ())+            assertBool "an in-place write under a reader is refused (what the old helper did)" (either (const True) (const False) inPlace)+            publish root (label "web") "web@2" [["b"]]+        doc <- eitherDecode <$> LByteString.readFile path+        assertEqual "the renamed document is in place" (Right "web@2") (fmap docId (doc :: Either String Document))++{- | The other side of the same lock, and the half that would be a defect in+"Salmon.Actions.Follow" if it went wrong: the fetcher's read refused because+this process has the document open for writing. That is a throw from the+registry, which a round contains as 'Follow.FetchFailed'; the loop keeps+serving and applies the document at the first round after it is released. -}+lockedDocumentIsAFailedFetch :: IO ()+lockedDocumentIsAFailedFetch =+    withTempDir $ \root -> do+        publish root (label "web") "web@1" [["a"]]+        writer <- openBinaryFile (Follow.documentPath (registryDir root) (label "web")) AppendMode+        (_, _, freports, ()) <-+            flip finally (hClose writer) $+                withFollowing root [label "web"] $ \d -> do+                    waitFor "the failed fetch" (any isFetchFailed <$> followReports d)+                    assertFile root "a" False+                    hClose writer+                    waitFor "the file" (fileExists root "a")+        assertBool "the failed fetch names the lock" $+            any (Text.isInfixOf "locked") [why | Follow.FetchFailed _ why <- freports]+        assertEqual "one injection, once the document could be read" ["web@1"] [did | Follow.Injected _ did _ _ _ <- freports]+  where+    isFetchFailed Follow.FetchFailed{} = True+    isFetchFailed _ = False  {- | A seed the binary's own parser rejects (here, an empty word list) and a seed whose 'Configure' throws are both reported as bad seeds by the loop; the
test/Test/GcpSpec.hs view
@@ -9,884 +9,4567 @@ -} module Test.GcpSpec (tests) where -import Data.Aeson (Value (..), encode, object, (.=))-import qualified Data.Aeson.Key as Key-import qualified Data.Aeson.KeyMap as KeyMap-import qualified Data.ByteString.Lazy as LByteString-import Data.Char (isAsciiLower, isDigit)-import Data.List (isInfixOf, isSubsequenceOf, nub)-import qualified Data.Text as Text-import qualified Data.Text.Encoding as Text-import Data.Foldable (toList)-import qualified Data.Map as Map-import GHC.IO.Exception (ExitCode (..))-import System.Process (readProcessWithExitCode)-import System.Process.ListLike (CmdSpec (..), CreateProcess, cmdspec)-import Test.Tasty (TestTree, testGroup)-import Test.Tasty.HUnit (assertBool, assertEqual, testCase)--import Salmon.Actions.UpDown (CheckResult (..))-import Salmon.Builtin.Nodes.Binary (prepare)-import qualified Salmon.Builtin.Nodes.Gcp.ArtifactRegistry as ArtifactRegistry-import qualified Salmon.Builtin.Nodes.Gcp.Billing as Billing-import qualified Salmon.Builtin.Nodes.Gcp.CloudRun as CloudRun-import qualified Salmon.Builtin.Nodes.Gcp.Compute as Compute-import qualified Salmon.Builtin.Nodes.Gcp.Core as Core-import qualified Salmon.Builtin.Nodes.Gcp.Iam as Iam-import qualified Salmon.Builtin.Nodes.Gcp.LoadBalancing as LoadBalancing-import qualified Salmon.Builtin.Nodes.Gcp.Monitoring as Monitoring-import qualified Salmon.Builtin.Nodes.Gcp.ResourceManager as ResourceManager-import qualified Salmon.Builtin.Nodes.Gcp.SecretManager as SecretManager-import qualified Salmon.Builtin.Nodes.Gcp.ServiceUsage as ServiceUsage-import qualified Salmon.Builtin.Nodes.Gcp.Storage as Storage-import qualified Salmon.Builtin.Nodes.Rsync as Rsync-import qualified Salmon.Builtin.Nodes.Ssh as Ssh-import qualified SreBox.Gcp.CloudRunAlerts as CloudRunAlerts--tests :: TestTree-tests =-    testGroup-        "Salmon.Builtin.Nodes.Gcp"-        [ testGroup "Core.interpretAdc" adcTests-        , testGroup "Compute.interpretInstanceStatus" instanceTests-        , testGroup "Storage.interpretBucketDescribe" bucketTests-        , testGroup "ArtifactRegistry.interpretRepoDescribe" repoTests-        , testGroup "CloudRun.interpretServiceDescribe" cloudRunTests-        , testGroup "Iam" iamTests-        , testGroup "LoadBalancing" lbTests-        , testGroup "ServiceUsage" serviceUsageTests-        , testGroup "Billing.interpretBillingDescribe" billingTests-        , testGroup "ResourceManager" projectTests-        , testGroup "Compute (tier 2 resources)" vmTests-        , testGroup "Compute (tier 3 resources)" lbBackendTests-        , testGroup "SecretManager" secretTests-        , testGroup "CloudRun options" cloudRunOptionTests-        , testGroup "Ssh.ClientOpts" clientOptsTests-        , testGroup "Monitoring" monitoringTests-        , testGroup "SreBox.Gcp.CloudRunAlerts" cloudRunAlertsTests-        ]---- | Extracts the argument list of a prepared gcloud 'CreateProcess', for--- asserting on rendered command-line shape without actually invoking gcloud.-processArgs :: CreateProcess -> [String]-processArgs p = case cmdspec p of-    RawCommand _path args -> args-    ShellCommand s -> [s]--isFailure :: CheckResult -> Bool-isFailure (Failure _) = True-isFailure _ = False-----------------------------------------------------------------------------------adcTests :: [TestTree]-adcTests =-    [ testCase "a printed token means ADC is usable" $-        assertEqual "" Success (Core.interpretAdc ExitSuccess)-    , testCase "no token means ADC is not configured" $-        assertBool "" (isFailure (Core.interpretAdc (ExitFailure 1)))-    ]-----------------------------------------------------------------------------------instanceTests :: [TestTree]-instanceTests =-    [ testCase "RUNNING is satisfied" $-        assertEqual "" Success (Compute.interpretInstanceStatus on ExitSuccess "RUNNING")-    , testCase "TERMINATED needs bringing up" $-        assertBool "" (isFailure (Compute.interpretInstanceStatus on ExitSuccess "TERMINATED"))-    , testCase "transitional states are Unknown, not Failure" $ do-        assertEqual "provisioning" Unknown (Compute.interpretInstanceStatus on ExitSuccess "PROVISIONING")-        assertEqual "staging" Unknown (Compute.interpretInstanceStatus on ExitSuccess "STAGING")-        assertEqual "stopping" Unknown (Compute.interpretInstanceStatus on ExitSuccess "STOPPING")-    , testCase "an unrecognized status is a Failure, not a crash" $-        assertBool "" (isFailure (Compute.interpretInstanceStatus on ExitSuccess "SOME-NEW-STATUS"))-    , testCase "describe failing outright (e.g. instance absent) is a Failure" $-        assertBool "" (isFailure (Compute.interpretInstanceStatus on (ExitFailure 1) ""))-    , testCase "up creates an absent instance" $-        assertEqual "" Compute.CreateInstance (Compute.planInstanceUp on (ExitFailure 1) "")-    , testCase "up starts a stopped instance rather than re-creating it" $-        assertEqual "" Compute.StartInstance (Compute.planInstanceUp on ExitSuccess "TERMINATED")-    , testCase "up resumes a suspended instance" $-        assertEqual "" Compute.ResumeInstance (Compute.planInstanceUp on ExitSuccess "SUSPENDED")-    , testCase "up leaves a running instance alone" $-        assertEqual "" Compute.AlreadyThere (Compute.planInstanceUp on ExitSuccess "RUNNING")-    , testCase "up refuses to act on a transitional status" $-        assertEqual "" (Compute.CannotActYet "STOPPING") (Compute.planInstanceUp on ExitSuccess "STOPPING")-    , testCase "declared stopped: TERMINATED is satisfied and RUNNING is not" $ do-        assertEqual "" Success (Compute.interpretInstanceStatus off ExitSuccess "TERMINATED")-        assertBool "" (isFailure (Compute.interpretInstanceStatus off ExitSuccess "RUNNING"))-    , testCase "declared stopped: up stops a running instance and leaves a stopped one alone" $ do-        assertEqual "" Compute.StopInstance (Compute.planInstanceUp off ExitSuccess "RUNNING")-        assertEqual "" Compute.AlreadyThere (Compute.planInstanceUp off ExitSuccess "TERMINATED")-        assertEqual "" Compute.CreateInstance (Compute.planInstanceUp off (ExitFailure 1) "")-    , testCase "declared stopped: a suspended instance is not stopped blindly" $-        assertBool "" (case Compute.planInstanceUp off ExitSuccess "SUSPENDED" of Compute.CannotActYet _ -> True; _ -> False)-    , testCase "for down, a stopped instance is still present; an absent one is not" $ do-        assertEqual "" Success (Compute.interpretInstancePresence ExitSuccess "TERMINATED")-        assertBool "" (isFailure (Compute.interpretInstancePresence (ExitFailure 1) ""))-    , testCase "stop is rendered like start" $-        assertEqual-            ""-            (RawCommand "gcloud" ["compute", "instances", "stop", "toy-vm", "--zone", "europe-west1-b", "--project", "p"])-            (cmdspec (prepare Compute.computeCommand (Compute.InstancesStop toyInstance)))-    ]-  where-    on = Compute.PoweredOn-    off = Compute.PoweredOff-    toyInstance =-        Compute.Instance-            { Compute.instanceName = "toy-vm"-            , Compute.instanceProject = Core.Project "p"-            , Compute.instanceZone = Core.Zone "europe-west1-b"-            , Compute.instanceMachineType = Compute.Custom "e2-micro"-            , Compute.instanceBootDisk = Compute.BootDisk 10 Nothing (Just "ubuntu-2404-lts-amd64") (Just "ubuntu-os-cloud")-            , Compute.instanceNetwork = "default"-            , Compute.instanceSubnet = "default"-            , Compute.instanceServiceAccount = Nothing-            , Compute.instanceMetadata = Map.fromList [("enable-oslogin", "FALSE")]-            , Compute.instanceMetadataFiles = Map.fromList [("startup-script", "/tmp/w/startup-script.sh")]-            , Compute.instanceAddress = Just "toy-ip"-            , Compute.instanceTags = ["toy-ssh"]-            , Compute.instancePower = Compute.PoweredOn-            }-----------------------------------------------------------------------------------bucketTests :: [TestTree]-bucketTests =-    [ testCase "describe succeeding means the bucket exists" $-        assertEqual "" Success (Storage.interpretBucketDescribe "my-bucket" ExitSuccess)-    , testCase "describe failing means the bucket is absent" $-        assertBool "" (isFailure (Storage.interpretBucketDescribe "my-bucket" (ExitFailure 1)))-    ]-----------------------------------------------------------------------------------repoTests :: [TestTree]-repoTests =-    [ testCase "gcloud artifacts takes --location, not --region" $-        assertEqual-            ""-            ["artifacts", "repositories", "create", "my-repo", "--repository-format", "docker", "--location", "us-west1", "--project", "p"]-            ( processArgs-                ( prepare-                    ArtifactRegistry.artifactRegistryCommand-                    (ArtifactRegistry.ReposCreate (ArtifactRegistry.ArtifactRepo "my-repo" (Core.Project "p") (Core.Region "us-west1") ArtifactRegistry.Docker))-                )-            )-    , testCase "describe succeeding means the repo exists" $-        assertEqual "" Success (ArtifactRegistry.interpretRepoDescribe "my-repo" ExitSuccess)-    , testCase "describe failing means the repo is absent" $-        assertBool "" (isFailure (ArtifactRegistry.interpretRepoDescribe "my-repo" (ExitFailure 1)))-    ]-----------------------------------------------------------------------------------cloudRunTests :: [TestTree]-cloudRunTests =-    [ testCase "describe succeeding with what was declared is satisfied" $-        assertEqual "" Success (verdict declared (describeJson "us-docker.pkg.dev/p/r/img:1" (Just "sa@p.iam.gserviceaccount.com") [plain "A" "1", secret "S"]))-    , testCase "a stale image is not satisfied, and the reason names both" $-        case verdict declared (describeJson "us-docker.pkg.dev/p/r/img:0" (Just "sa@p.iam.gserviceaccount.com") [plain "A" "1"]) of-            Failure why -> do-                assertBool (Text.unpack why) ("img:0" `Text.isInfixOf` why)-                assertBool (Text.unpack why) ("img:1" `Text.isInfixOf` why)-            other -> assertBool (show other) False-    , -- the substring match this replaces called these the same-      testCase "img:1 is not img:10" $ do-        assertBool "" (isFailure (verdict declared (describeJson "us-docker.pkg.dev/p/r/img:10" (Just "sa@p.iam.gserviceaccount.com") [plain "A" "1"])))-        assertBool "" (isFailure (verdict declared{CloudRun.crsImage = "us-docker.pkg.dev/p/r/img:10"} (describeJson "us-docker.pkg.dev/p/r/img:1" (Just "sa@p.iam.gserviceaccount.com") [plain "A" "1"])))-    , testCase "an image that merely appears elsewhere in the output is not the image" $-        assertBool "" (isFailure (verdict declared (Text.replace "\"containers\"" "\"note\": \"us-docker.pkg.dev/p/r/img:1\", \"containers\"" (describeJson "other:2" (Just "sa@p.iam.gserviceaccount.com") [plain "A" "1"]))))-    , testCase "a changed service account is drift" $-        assertBool "" (isFailure (verdict declared (describeJson "us-docker.pkg.dev/p/r/img:1" (Just "someone-else@p.iam.gserviceaccount.com") [plain "A" "1"])))-    , testCase "a service with no service account at all is drift" $-        assertBool "" (isFailure (verdict declared (describeJson "us-docker.pkg.dev/p/r/img:1" Nothing [plain "A" "1"])))-    , testCase "a changed, missing and extra environment variable are each drift, named and never quoted" $ do-        let why env = case verdict declared (describeJson "us-docker.pkg.dev/p/r/img:1" (Just "sa@p.iam.gserviceaccount.com") env) of-                Failure w -> w-                other -> Text.pack (show other)-        assertBool "" ("A" `Text.isInfixOf` why [plain "A" "changed-value-9"])-        assertBool "the value is not in the reason" (not ("changed-value-9" `Text.isInfixOf` why [plain "A" "changed-value-9"]))-        assertBool "" ("environment variable A is missing" `Text.isInfixOf` why [])-        assertBool "" ("environment variable EXTRA is set but not declared" `Text.isInfixOf` why [plain "A" "1", plain "EXTRA" "x"])-    , testCase "a secret-bound variable is not a plain one: extra secrets are not env drift" $-        assertEqual "" Success (verdict declared (describeJson "us-docker.pkg.dev/p/r/img:1" (Just "sa@p.iam.gserviceaccount.com") [plain "A" "1", secret "PGRST_JWT_SECRET"]))-    , testCase "output that is not the expected JSON is Unknown, not a redeploy" $ do-        assertEqual "" Unknown (verdict declared "image: us-docker.pkg.dev/p/r/img:1\n")-        assertEqual "" Unknown (verdict declared "{\"spec\": {}}")-    , testCase "describe failing means the service is absent" $-        assertBool "" (isFailure (CloudRun.interpretServiceDescribe declared (ExitFailure 1) ""))-    , testCase "the describe asks for JSON" $-        assertBool "" ("--format=json" `elem` processArgs (prepare CloudRun.cloudRunCommand (CloudRun.RunDescribe declared)))-    , testCase "for down, a service on a stale image is still present" $ do-        -- down deletes what exists; the image only matters for up.-        assertEqual "" Success (CloudRun.interpretServicePresence ExitSuccess "image: us-docker.pkg.dev/p/r/img:1\n")-        assertBool "" (isFailure (CloudRun.interpretServicePresence (ExitFailure 1) ""))-    ]--declared :: CloudRun.CloudRunService-declared =-    CloudRun.CloudRunService-        { CloudRun.crsName = "svc"-        , CloudRun.crsProject = Core.Project "p"-        , CloudRun.crsRegion = Core.Region "europe-west1"-        , CloudRun.crsImage = "us-docker.pkg.dev/p/r/img:1"-        , CloudRun.crsEnv = Map.fromList [("A", "1")]-        , CloudRun.crsServiceAccount = "sa@p.iam.gserviceaccount.com"-        , CloudRun.crsIngress = CloudRun.All-        , CloudRun.crsMaxInstances = Nothing-        , CloudRun.crsOptions = CloudRun.defaultCloudRunOptions-        }--verdict :: CloudRun.CloudRunService -> Text.Text -> CheckResult-verdict svc = CloudRun.interpretServiceDescribe svc ExitSuccess--plain :: Text.Text -> Text.Text -> Value-plain k v = object ["name" .= k, "value" .= v]--secret :: Text.Text -> Value-secret k = object ["name" .= k, "valueFrom" .= object ["secretKeyRef" .= object ["name" .= ("s" :: Text.Text), "key" .= ("latest" :: Text.Text)]]]---- | The parts of @gcloud run services describe --format=json@ the check reads.-describeJson :: Text.Text -> Maybe Text.Text -> [Value] -> Text.Text-describeJson image sa env =-    Text.decodeUtf8 . LByteString.toStrict . encode $-        object-            [ "spec"-                .= object-                    [ "template"-                        .= object-                            [ "spec"-                                .= object-                                    ( [ "containers" .= [object ["image" .= image, "env" .= env]]-                                      ]-                                        <> maybe [] (\a -> ["serviceAccountName" .= a]) sa-                                    )-                            ]-                    ]-            , "status" .= object ["latestReadyRevisionName" .= ("svc-00001" :: Text.Text)]-            ]-----------------------------------------------------------------------------------iamTests :: [TestTree]-iamTests =-    [ testCase "service account describe succeeding means it exists" $-        assertEqual "" Success (Iam.interpretServiceAccountDescribe "sa-1" ExitSuccess)-    , testCase "service account describe failing means it is absent" $-        assertBool "" (isFailure (Iam.interpretServiceAccountDescribe "sa-1" (ExitFailure 1)))-    , testCase "a binding present in the policy is satisfied" $-        assertEqual-            ""-            Success-            ( Iam.interpretBindingPolicy-                binding-                ExitSuccess-                "bindings:\n- members:\n  - serviceAccount:sa-1@p.iam.gserviceaccount.com\n  role: roles/storage.objectViewer\n"-            )-    , testCase "a binding absent from the policy is not satisfied" $-        assertBool-            ""-            ( isFailure-                ( Iam.interpretBindingPolicy-                    binding-                    ExitSuccess-                    "bindings:\n- members:\n  - user:someone@example.com\n  role: roles/viewer\n"-                )-            )-    , testCase "get-iam-policy failing outright is not satisfied" $-        assertBool "" (isFailure (Iam.interpretBindingPolicy binding (ExitFailure 1) ""))-    , testCase "a secrets/ resource binds against the secrets group" $-        assertEqual-            ""-            ["secrets", "add-iam-policy-binding", "my-secret", "--member", "serviceAccount:sa-1@p.iam.gserviceaccount.com", "--role", "roles/secretmanager.secretAccessor"]-            (processArgs (prepare Iam.iamCommand (Iam.IamPolicyAddBinding secretBinding)))-    , testCase "an artifacts/repositories/ resource carries its location as a trailing flag, after the resource" $-        assertEqual-            ""-            ["artifacts", "repositories", "add-iam-policy-binding", "my-repo", "--location", "us-west1", "--member", "serviceAccount:sa-1@p.iam.gserviceaccount.com", "--role", "roles/uploader"]-            (processArgs (prepare Iam.iamCommand (Iam.IamPolicyAddBinding repoBinding)))-    , testCase "a project-qualified repository passes --project rather than relying on gcloud's configured project" $-        assertEqual-            ""-            ["artifacts", "repositories", "get-iam-policy", "my-repo", "--location", "us-west1", "--project", "p"]-            (processArgs (prepare Iam.iamCommand (Iam.IamPolicyGetBinding (binding {Iam.iamResource = "projects/p/locations/us-west1/repositories/my-repo"}))))-    , testCase "a project-qualified secret passes --project" $-        assertEqual-            ""-            ["secrets", "get-iam-policy", "my-secret", "--project", "p"]-            (processArgs (prepare Iam.iamCommand (Iam.IamPolicyGetBinding (binding {Iam.iamResource = "projects/p/secrets/my-secret"}))))-    , testCase "a bucket resource is rendered as the gs:// URL gcloud storage requires" $-        assertEqual-            ""-            ["storage", "buckets", "get-iam-policy", "gs://my-bucket"]-            (processArgs (prepare Iam.iamCommand (Iam.IamPolicyGetBinding (binding {Iam.iamResource = "buckets/my-bucket"}))))-    , testCase "role describe succeeding means the custom role exists" $-        assertEqual "" Success (Iam.interpretRoleDescribe "registryUploader" ExitSuccess)-    , testCase "role describe failing means the custom role is absent" $-        assertBool "" (isFailure (Iam.interpretRoleDescribe "registryUploader" (ExitFailure 1)))-    , testCase "a custom role is created from its definition file" $-        assertEqual-            ""-            ["iam", "roles", "create", "registryUploader", "--file", "infra/roles/registryUploader.yaml", "--project", "p"]-            (processArgs (prepare Iam.iamCommand (Iam.RolesCreate role)))-    , testCase "a service account key is written to its target path" $-        assertEqual-            ""-            ["iam", "service-accounts", "keys", "create", "secrets/gh-ci/uploader.key.json", "--iam-account", "uploader@p.iam.gserviceaccount.com", "--project", "p"]-            (processArgs (prepare Iam.iamCommand (Iam.ServiceAccountKeysCreate key)))-    ]-  where-    binding =-        Iam.IamBinding-            { Iam.iamPrincipal = Iam.ServiceAccount "sa-1@p.iam.gserviceaccount.com"-            , Iam.iamRole = "roles/storage.objectViewer"-            , Iam.iamResource = "projects/p"-            }-    secretBinding =-        binding-            { Iam.iamRole = "roles/secretmanager.secretAccessor"-            , Iam.iamResource = "secrets/my-secret"-            }-    repoBinding =-        binding-            { Iam.iamRole = "roles/uploader"-            , Iam.iamResource = "artifacts/repositories/us-west1/my-repo"-            }-    role =-        Iam.CustomRole-            { Iam.roleId = "registryUploader"-            , Iam.roleProject = Core.Project "p"-            , Iam.roleDefinitionFile = "infra/roles/registryUploader.yaml"-            }-    key =-        Iam.ServiceAccountKey-            { Iam.sakProject = Core.Project "p"-            , Iam.sakAccountId = "uploader"-            , Iam.sakPath = "secrets/gh-ci/uploader.key.json"-            }-----------------------------------------------------------------------------------serviceUsageTests :: [TestTree]-serviceUsageTests =-    [ testCase "the API appearing in the enabled listing is satisfied" $-        assertEqual-            ""-            Success-            (ServiceUsage.interpretServiceList (ServiceUsage.Api "run.googleapis.com") ExitSuccess "NAME\nrun.googleapis.com\n")-    , testCase "the API absent from the enabled listing is not satisfied" $-        assertBool-            ""-            (isFailure (ServiceUsage.interpretServiceList (ServiceUsage.Api "run.googleapis.com") ExitSuccess "NAME\n"))-    , testCase "listing failing outright is not satisfied" $-        assertBool "" (isFailure (ServiceUsage.interpretServiceList (ServiceUsage.Api "run.googleapis.com") (ExitFailure 1) ""))-    ]-----------------------------------------------------------------------------------secretTests :: [TestTree]-secretTests =-    [ testCase "a version is added from a file, never from argv" $ do-        -- argv is world-readable through /proc for the life of the process.-        let args = processArgs (prepare SecretManager.secretManagerCommand (SecretManager.VersionsAdd version))-        assertBool (show args) (["--data-file", "/certs/db.key"] `isSubsequenceOf` args)-        assertBool (show args) (["secrets", "versions", "add", "db-key"] `isSubsequenceOf` args)-    , testCase "reading back compares the exact bytes, trailing newline included" $ do-        -- Trimming would make a node that had just uploaded its own PEM-        -- report a difference on every later pass.-        assertEqual "" Success (SecretManager.interpretSecretContents "s" "abc\n" ExitSuccess "abc\n")-        assertBool "" (isFailure (SecretManager.interpretSecretContents "s" "abc\n" ExitSuccess "abc"))-        assertBool "" (isFailure (SecretManager.interpretSecretContents "s" "abc" (ExitFailure 1) ""))-    , testCase "a secret that does not describe is absent" $ do-        assertEqual "" Success (SecretManager.interpretSecretDescribe "s" ExitSuccess)-        assertBool "" (isFailure (SecretManager.interpretSecretDescribe "s" (ExitFailure 1)))-    ]-  where-    sec = SecretManager.Secret "db-key" (Core.Project "p") "automatic"-    version = SecretManager.SecretVersion sec "/certs/db.key"--cloudRunOptionTests :: [TestTree]-cloudRunOptionTests =-    [ testCase "every secret rides on ONE --set-secrets flag" $ do-        -- gcloud treats a repeated --set-secrets as a replacement, so the-        -- one-flag-per-binding form silently deploys with only the last.-        let args = processArgs (prepare CloudRun.cloudRunCommand (CloudRun.RunDeploy svc))-            flags = length (filter (== "--set-secrets") args)-        assertEqual (show args) 1 flags-        assertBool (show args) ("/opt/vault/cert.pem=svc-cert:latest,PGRST_JWT_SECRET=svc-jwt:latest" `elem` args)-    , testCase "resource knobs are passed only when set" $ do-        let bare = processArgs (prepare CloudRun.cloudRunCommand (CloudRun.RunDeploy svc{CloudRun.crsOptions = CloudRun.defaultCloudRunOptions}))-        assertBool (show bare) (not ("--set-secrets" `elem` bare))-        assertBool (show bare) (not ("--cpu" `elem` bare))-        assertBool (show bare) (not ("--allow-unauthenticated" `elem` bare))-        assertBool (show bare) (not ("--no-invoker-iam-check" `elem` bare))-        let full = processArgs (prepare CloudRun.cloudRunCommand (CloudRun.RunDeploy svc))-        assertBool (show full) (["--cpu", "1000m"] `isSubsequenceOf` full)-        assertBool (show full) (["--memory", "256Mi"] `isSubsequenceOf` full)-        assertBool (show full) (["--concurrency", "80"] `isSubsequenceOf` full)-    , testCase "disabling the invoker IAM check is a deploy flag, not an IAM write" $ do-        -- Under iam.allowedPolicyMemberDomains, --allow-unauthenticated-        -- deploys and only warns that allUsers was refused; the flag form is-        -- part of the spec, so it either lands or the deploy fails.-        let opts = CloudRun.defaultCloudRunOptions{CloudRun.croInvokerIamCheckDisabled = True}-            args = processArgs (prepare CloudRun.cloudRunCommand (CloudRun.RunDeploy svc{CloudRun.crsOptions = opts}))-        assertBool (show args) ("--no-invoker-iam-check" `elem` args)-        assertBool (show args) (not ("--allow-unauthenticated" `elem` args))-    ]-  where-    svc =-        CloudRun.CloudRunService-            { CloudRun.crsName = "svc"-            , CloudRun.crsProject = Core.Project "p"-            , CloudRun.crsRegion = Core.Region "europe-west1"-            , CloudRun.crsImage = "img:1"-            , CloudRun.crsEnv = Map.empty-            , CloudRun.crsServiceAccount = "sa@p.iam.gserviceaccount.com"-            , CloudRun.crsIngress = CloudRun.All-            , CloudRun.crsMaxInstances = Just 1-            , CloudRun.crsOptions =-                CloudRun.defaultCloudRunOptions-                    { CloudRun.croSecrets =-                        [ CloudRun.SecretFile "/opt/vault/cert.pem" "svc-cert" "latest"-                        , CloudRun.SecretEnvVar "PGRST_JWT_SECRET" "svc-jwt" "latest"-                        ]-                    , CloudRun.croCpu = Just "1000m"-                    , CloudRun.croMemory = Just "256Mi"-                    , CloudRun.croConcurrency = Just 80-                    }-            }--lbBackendTests :: [TestTree]-lbBackendTests =-    [ testCase "a proxy-only subnet is created ACTIVE, with its purpose" $ do-        let args = processArgs (prepare Compute.computeCommand (Compute.SubnetsCreate proxySubnet))-        assertBool (show args) (["--purpose", "REGIONAL_MANAGED_PROXY"] `isSubsequenceOf` args)-        assertBool (show args) (["--role", "ACTIVE"] `isSubsequenceOf` args)-        assertBool (show args) (["--range", "192.168.100.0/24"] `isSubsequenceOf` args)-    , testCase "an ordinary subnet is not given a role" $ do-        let args = processArgs (prepare Compute.computeCommand (Compute.SubnetsCreate proxySubnet{Compute.subnetPurpose = Compute.PrivateSubnet}))-        assertBool (show args) (not ("--role" `elem` args))-    , testCase "a subnet of the wrong purpose is not the subnet that was asked for" $ do-        assertEqual "" Success (Compute.interpretSubnetDescribe "s" "REGIONAL_MANAGED_PROXY" ExitSuccess "REGIONAL_MANAGED_PROXY")-        assertBool "a plain subnet under that name" (isFailure (Compute.interpretSubnetDescribe "s" "REGIONAL_MANAGED_PROXY" ExitSuccess "PRIVATE"))-        assertBool "absent" (isFailure (Compute.interpretSubnetDescribe "s" "REGIONAL_MANAGED_PROXY" (ExitFailure 1) ""))-    , testCase "gcloud leaving an ordinary subnet's purpose empty still satisfies PRIVATE" $-        assertEqual "" Success (Compute.interpretSubnetDescribe "s" "PRIVATE" ExitSuccess "")-    , testCase "the instance group is unmanaged and zonal" $ do-        let args = processArgs (prepare Compute.computeCommand (Compute.InstanceGroupsCreate group))-        assertBool (show args) (["instance-groups", "unmanaged", "create", "ig"] `isSubsequenceOf` args)-        assertBool (show args) (["--zone", "europe-west1-b"] `isSubsequenceOf` args)-    , testCase "membership is read off the listing's last path segment" $ do-        let listing = "https://www.googleapis.com/compute/v1/projects/p/zones/europe-west1-b/instances/web\n"-        assertEqual "" Success (Compute.interpretGroupMembership "web" ExitSuccess listing)-        -- the whole reason not to use a substring match-        assertBool "a longer name containing this one" (isFailure (Compute.interpretGroupMembership "web" ExitSuccess "projects/p/zones/z/instances/web-canary\n"))-        assertBool "empty listing" (isFailure (Compute.interpretGroupMembership "web" ExitSuccess ""))-        assertBool "listing failed" (isFailure (Compute.interpretGroupMembership "web" (ExitFailure 1) ""))-    ]-  where-    proxySubnet =-        Compute.Subnet-            { Compute.subnetName = "proxy"-            , Compute.subnetProject = Core.Project "p"-            , Compute.subnetRegion = Core.Region "europe-west1"-            , Compute.subnetNetwork = "default"-            , Compute.subnetRange = "192.168.100.0/24"-            , Compute.subnetPurpose = Compute.RegionalManagedProxy-            }-    group = Compute.InstanceGroup "ig" (Core.Project "p") (Core.Zone "europe-west1-b")-----------------------------------------------------------------------------------lbTests :: [TestTree]-lbTests =-    [ testCase "describe succeeding means the url map exists" $-        assertEqual "" Success (LoadBalancing.interpretLbDescribe ExitSuccess)-    , testCase "describe failing means the load balancer is absent" $-        assertBool "" (isFailure (LoadBalancing.interpretLbDescribe (ExitFailure 1)))-    , testCase "shellQuote neutralizes a value that would otherwise break out of quoting" $ do-        assertEqual "no special characters" "'tenant-1'" (LoadBalancing.shellQuote "tenant-1")-        assertEqual-            "an embedded single quote and shell metacharacters stay inside the quoting"-            "'tenant'\\''; rm -rf / #'"-            (LoadBalancing.shellQuote "tenant'; rm -rf / #")-    , testCase "create/delete run the script with bash, not as a gcloud subcommand" $ do-        assertBool "create" (isBash (prepare LoadBalancing.loadBalancingCommand (LoadBalancing.LbCreate alb)))-        assertBool "delete" (isBash (prepare LoadBalancing.loadBalancingCommand (LoadBalancing.LbDelete alb)))-    , testCase "scripts never swallow failures with || true" $ do-        let scripts = concatMap (processArgs . prepare LoadBalancing.loadBalancingCommand) [LoadBalancing.LbCreate alb, LoadBalancing.LbDelete alb]-        assertBool "" (not (any ("|| true" `isInfixOf`) scripts))-    , testCase "a zonal instance group is addressed by zone, not by the balancer's region" $ do-        -- The bug this pins: every gcloud call naming the group used to get-        -- the balancer's --region, which an unmanaged (zonal) group rejects-        -- outright -- so the one backend kind made of VMs salmon declared-        -- could never be attached at all.-        assertBool script ("--instance-group-zone='europe-west1-b'" `isInfixOf` script)-        assertBool script (not ("--instance-group-region" `isInfixOf` script))-        assertBool script ("set-named-ports 'ig' --project=\"$PROJECT\" --zone='europe-west1-b'" `isInfixOf` script)-    , testCase "exists() is a bare predicate, so each caller says where its resource lives" $ do-        -- It used to append --project/--region to whatever it was handed,-        -- which silently made every describe a regional one.-        assertBool script ("exists() { \"$@\" >/dev/null 2>&1; }" `isInfixOf` script)-        assertBool script ("exists gcloud compute url-maps describe 'web-url-map' --project=\"$PROJECT\" --region=\"$REGION\"" `isInfixOf` script)-    , testCase "a regional instance group keeps the regional flag" $ do-        let regionalScript = createScript alb{LoadBalancing.albBackends = [LoadBalancing.InstanceGroupBackend "ig" (LoadBalancing.InstanceGroupRegion "europe-west1") [8080]]}-        assertBool regionalScript ("--instance-group-region='europe-west1'" `isInfixOf` regionalScript)-    , testCase "rendered scripts parse as bash" $ do-        let scripts = [s' | cmd <- [LoadBalancing.LbCreate alb, LoadBalancing.LbDelete alb], (_ : s' : _) <- [processArgs (prepare LoadBalancing.loadBalancingCommand cmd)]]-        mapM_-            ( \script -> do-                (code, _, err) <- readProcessWithExitCode "bash" ["-n", "-c", script] ""-                assertEqual err ExitSuccess code-            )-            scripts-    ]-  where-    isBash p = case cmdspec p of-        RawCommand "bash" ("-c" : _) -> True-        _ -> False-    createScript a = case processArgs (prepare LoadBalancing.loadBalancingCommand (LoadBalancing.LbCreate a)) of-        (_ : s : _) -> s-        other -> error (show other)-    script = createScript alb-    alb =-        LoadBalancing.ApplicationLoadBalancer-            { LoadBalancing.albName = "web"-            , LoadBalancing.albProject = Core.Project "p"-            , LoadBalancing.albRegion = Core.Region "europe-west1"-            , LoadBalancing.albNetwork = Just "default"-            , LoadBalancing.albBackends =-                [ LoadBalancing.InstanceGroupBackend "ig" (LoadBalancing.InstanceGroupZone "europe-west1-b") [8080, 8081]-                , LoadBalancing.CloudRunBackend "svc"-                ]-            , LoadBalancing.albHealthCheck = Just (LoadBalancing.HealthCheck "hc" 8080)-            }-----------------------------------------------------------------------------------billingTests :: [TestTree]-billingTests =-    [ testCase "a project linked and billing-enabled is satisfied" $-        assertEqual-            ""-            Success-            (Billing.interpretBillingDescribe account ExitSuccess "billingAccountName: billingAccounts/XXXXXX-XXXXXX-XXXXXX\nbillingEnabled: true\nname: projects/p\n")-    , testCase "a project linked to a different account is not satisfied" $-        assertBool-            ""-            (isFailure (Billing.interpretBillingDescribe account ExitSuccess "billingAccountName: billingAccounts/OTHER-ACCOUNT\nbillingEnabled: true\n"))-    , testCase "a project with billing disabled is not satisfied" $-        assertBool-            ""-            (isFailure (Billing.interpretBillingDescribe account ExitSuccess "billingAccountName: billingAccounts/XXXXXX-XXXXXX-XXXXXX\nbillingEnabled: false\n"))-    , testCase "describe failing outright is not satisfied" $-        assertBool "" (isFailure (Billing.interpretBillingDescribe account (ExitFailure 1) ""))-    ]-  where-    account = Billing.BillingAccount "XXXXXX-XXXXXX-XXXXXX"-----------------------------------------------------------------------------------projectTests :: [TestTree]-projectTests =-    [ testCase "an ACTIVE project is satisfied" $-        assertEqual "" Success (ResourceManager.interpretProjectState "p" ExitSuccess "ACTIVE")-    , testCase "a project pending deletion is not satisfied, and says why" $-        case ResourceManager.interpretProjectState "p" ExitSuccess "DELETE_REQUESTED" of-            Failure msg -> assertBool "mentions the id cannot be reused" ("cannot be reused" `isInfixOf` show msg)-            other -> assertBool ("expected Failure, got " <> show other) False-    , testCase "describe failing means the project is absent" $-        assertBool "" (isFailure (ResourceManager.interpretProjectState "p" (ExitFailure 1) ""))-    , testCase "create passes the parent and labels" $ do-        let args =-                processArgs $-                    prepare-                        ResourceManager.resourceManagerCommand-                        ( ResourceManager.ProjectsCreate-                            (ResourceManager.ProjectSpec (Core.Project "p") (ResourceManager.Folder "123") (Map.fromList [("purpose", "salmon-toy")]))-                        )-        assertEqual "" ["projects", "create", "p", "--folder", "123", "--labels", "purpose=salmon-toy"] args-    ]-----------------------------------------------------------------------------------vmTests :: [TestTree]-vmTests =-    [ testCase "an address is reserved regionally, and read back as a bare IP" $ do-        assertEqual-            "create"-            ["compute", "addresses", "create", "toy-ip", "--region", "europe-west1", "--project", "p"]-            (processArgs (prepare Compute.computeCommand (Compute.AddressesCreate addr)))-        assertEqual-            "describe asks for the address itself, which is what a driver needs"-            ["compute", "addresses", "describe", "toy-ip", "--region", "europe-west1", "--format=value(address)", "--project", "p"]-            (processArgs (prepare Compute.computeCommand (Compute.AddressesDescribe addr)))-    , testCase "a reserved address with no IP yet is not satisfied" $-        assertBool "" (isFailure (Compute.interpretAddressDescribe "toy-ip" ExitSuccess ""))-    , testCase "a reserved address with an IP is satisfied" $-        assertEqual "" Success (Compute.interpretAddressDescribe "toy-ip" ExitSuccess "34.1.2.3")-    , testCase "a firewall rule carries its allow, ranges and target tags" $-        assertEqual-            ""-            [ "compute", "firewall-rules", "create", "toy-ssh"-            , "--network", "default", "--allow", "tcp:22"-            , "--source-ranges", "0.0.0.0/0", "--project", "p"-            , "--target-tags", "toy-ssh"-            ]-            (processArgs (prepare Compute.computeCommand (Compute.FirewallCreate fw)))-    , testCase "an instance boots from an image family, in its publisher's project" $-        assertBool-            "--image-family and --image-project are passed"-            (["--image-family", "ubuntu-2404-lts-amd64"] `isSubsequenceOf` args && ["--image-project", "ubuntu-os-cloud"] `isSubsequenceOf` args)-    , testCase "a multi-line startup script goes through --metadata-from-file" $-        assertBool-            "a newline-bearing value cannot ride in --metadata KEY=VALUE"-            (["--metadata-from-file", "startup-script=/tmp/w/startup-script.sh"] `isSubsequenceOf` args)-    , testCase "the instance claims the reserved address by name" $-        assertBool "" (["--address", "toy-ip"] `isSubsequenceOf` args)-    ]-  where-    args = processArgs (prepare Compute.computeCommand (Compute.InstancesCreate inst))-    addr = Compute.Address "toy-ip" (Core.Project "p") (Core.Region "europe-west1")-    fw =-        Compute.FirewallRule-            { Compute.firewallName = "toy-ssh"-            , Compute.firewallProject = Core.Project "p"-            , Compute.firewallNetwork = "default"-            , Compute.firewallAllow = "tcp:22"-            , Compute.firewallSourceRanges = ["0.0.0.0/0"]-            , Compute.firewallTargetTags = ["toy-ssh"]-            }-    inst =-        Compute.Instance-            { Compute.instanceName = "toy-vm"-            , Compute.instanceProject = Core.Project "p"-            , Compute.instanceZone = Core.Zone "europe-west1-b"-            , Compute.instanceMachineType = Compute.Custom "e2-micro"-            , Compute.instanceBootDisk = Compute.BootDisk 10 Nothing (Just "ubuntu-2404-lts-amd64") (Just "ubuntu-os-cloud")-            , Compute.instanceNetwork = "default"-            , Compute.instanceSubnet = "default"-            , Compute.instanceServiceAccount = Nothing-            , Compute.instanceMetadata = Map.fromList [("enable-oslogin", "FALSE")]-            , Compute.instanceMetadataFiles = Map.fromList [("startup-script", "/tmp/w/startup-script.sh")]-            , Compute.instanceAddress = Just "toy-ip"-            , Compute.instanceTags = ["toy-ssh"]-            , Compute.instancePower = Compute.PoweredOn-            }-----------------------------------------------------------------------------------clientOptsTests :: [TestTree]-clientOptsTests =-    [ testCase "no options means ssh authenticates as it always did" $-        assertEqual "" [] (Ssh.clientArgs Ssh.noClientOpts)-    , testCase "an identity is offered exclusively" $-        assertEqual-            "IdentitiesOnly, or an agent key can be tried first and the cert never reached"-            ["-i", "/w/ssh/toy-client", "-o", "IdentitiesOnly=yes"]-            (Ssh.clientArgs Ssh.noClientOpts{Ssh.optIdentity = Just "/w/ssh/toy-client"})-    , testCase "a known-hosts file comes with accept-new" $-        assertEqual-            ""-            ["-o", "UserKnownHostsFile=/w/ssh/known_hosts", "-o", "StrictHostKeyChecking=accept-new"]-            (Ssh.clientArgs Ssh.noClientOpts{Ssh.optKnownHosts = Just "/w/ssh/known_hosts"})-    , testCase "rsync carries the same options through --rsh" $-        assertEqual-            "rsync has no -i of its own"-            [ "--copy-links"-            , "--rsh"-            , "ssh -i /w/ssh/toy-client -o IdentitiesOnly=yes -o UserKnownHostsFile=/w/ssh/known_hosts -o StrictHostKeyChecking=accept-new"-            , "/local/bin"-            , "salmon@1.2.3.4:/home/salmon/bin"-            ]-            (processArgs (prepare Rsync.rsyncRun (Rsync.SendFile "/local/bin" (Rsync.Remote "salmon" "1.2.3.4") "/home/salmon/bin" opts)))-    , testCase "a directory upload carries them too" $-        assertEqual-            "sendDirWith is sendFileWith's --rsh treatment, recursively"-            [ "--copy-links"-            , "--recursive"-            , "--rsh"-            , "ssh -i /w/ssh/toy-client -o IdentitiesOnly=yes -o UserKnownHostsFile=/w/ssh/known_hosts -o StrictHostKeyChecking=accept-new"-            , "/local/files"-            , "salmon@1.2.3.4:/home/salmon/files"-            ]-            (processArgs (prepare Rsync.rsyncRun (Rsync.SendDir "/local/files" (Rsync.Remote "salmon" "1.2.3.4") "/home/salmon/files" opts)))-    , testCase "a directory upload without options is the same command as before" $-        assertEqual-            ""-            ["--copy-links", "--recursive", "/local/files", "salmon@1.2.3.4:/home/salmon/files"]-            (processArgs (prepare Rsync.rsyncRun (Rsync.SendDir "/local/files" (Rsync.Remote "salmon" "1.2.3.4") "/home/salmon/files" Ssh.noClientOpts)))-    , testCase "a changed host key is recognised as such" $-        assertBool-            "the one ssh failure that never resolves by waiting"-            (Ssh.isHostKeyMismatch "@@@@\nWARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!\n")-    , testCase "an ordinary refusal is not a host key mismatch" $-        assertBool-            "a VM still booting must be waited out, not have its host key forgotten"-            (not (Ssh.isHostKeyMismatch "salmon@1.2.3.4: Permission denied (publickey)."))-    ]-  where-    opts = Ssh.ClientOpts (Just "/w/ssh/toy-client") (Just "/w/ssh/known_hosts")-----------------------------------------------------------------------------------monitoringTests :: [TestTree]-monitoringTests =-    [ testCase "an email channel is created with its type and address as channel labels" $ do-        let args = processArgs (prepare Monitoring.monitoringCommand (Monitoring.ChannelsCreate channel))-        assertBool (show args) (["beta", "monitoring", "channels", "create"] `isSubsequenceOf` args)-        assertBool (show args) (["--type", "email"] `isSubsequenceOf` args)-        assertBool (show args) (["--channel-labels", "email_address=ops@example.org"] `isSubsequenceOf` args)-        assertBool (show args) (["--display-name", "ops mail"] `isSubsequenceOf` args)-        assertBool (show args) (["--project", "p"] `isSubsequenceOf` args)-    , testCase "channels and policies are looked up by display name, as JSON" $ do-        let cargs = processArgs (prepare Monitoring.monitoringCommand (Monitoring.ChannelsList channel))-            pargs = processArgs (prepare Monitoring.monitoringCommand (Monitoring.PoliciesList policy))-        assertBool (show cargs) (["--filter", "display_name=\"ops mail\"", "--format", "json"] `isSubsequenceOf` cargs)-        assertBool (show pargs) (["--filter", "display_name=\"svc: 5xx ratio\"", "--format", "json"] `isSubsequenceOf` pargs)-    , testCase "channel lookup: failed, absent, present matching, present with another address" $ do-        assertEqual "" (Monitoring.LookupFailed "exit 1: boom") (Monitoring.lookupChannel channel (ExitFailure 1) "" "boom\n")-        assertEqual "" Monitoring.Absent (Monitoring.lookupChannel channel ExitSuccess "[]" "")-        assertEqual-            ""-            (Monitoring.Present (Monitoring.FoundChannel "projects/p/notificationChannels/1" True))-            (Monitoring.lookupChannel channel ExitSuccess (channelJson "ops@example.org") "")-        assertEqual-            ""-            (Monitoring.Present (Monitoring.FoundChannel "projects/p/notificationChannels/1" False))-            (Monitoring.lookupChannel channel ExitSuccess (channelJson "other@example.org") "")-        -- and as a check: only the matching one is satisfied-        assertEqual "" Success (Monitoring.interpretChannelList channel (ExitSuccess, channelJson "ops@example.org", ""))-        assertBool "" (isFailure (Monitoring.interpretChannelList channel (ExitSuccess, channelJson "other@example.org", "")))-        assertBool "" (isFailure (Monitoring.interpretChannelList channel (ExitSuccess, "[]", "")))-        assertBool "" (isFailure (Monitoring.interpretChannelList channel (ExitFailure 1, "", "")))-    , testCase "the 5xx condition is a ratio: numerator on the 5xx class, denominator on every request, same service" $ do-        let v = Monitoring.renderCondition target (Monitoring.ServerErrorRatio 0.05 300)-            threshold = fieldAt ["conditionThreshold"] v-            filt = textAt ["conditionThreshold", "filter"] v-            denom = textAt ["conditionThreshold", "denominatorFilter"] v-        assertBool (show filt) (maybe False ("metric.labels.response_code_class=\"5xx\"" `Text.isInfixOf`) filt)-        assertBool (show filt) (maybe False ("resource.labels.service_name=\"svc\"" `Text.isInfixOf`) filt)-        assertBool (show filt) (maybe False ("resource.labels.location=\"europe-west1\"" `Text.isInfixOf`) filt)-        assertBool (show denom) (maybe False (\d -> "request_count" `Text.isInfixOf` d && not ("5xx" `Text.isInfixOf` d)) denom)-        assertEqual "" (Just "300s") (textAt ["conditionThreshold", "duration"] v)-        assertBool (show threshold) (threshold /= Nothing)-    , testCase "latency and memory read the 99th percentile; instance count sums active instances" $ do-        let lat = Monitoring.renderCondition target (Monitoring.RequestLatencyP99 2000 300)-            mem = Monitoring.renderCondition target (Monitoring.MemoryUtilization 0.9 300)-            cnt = Monitoring.renderCondition target (Monitoring.InstanceCount 3 300)-        assertEqual "" (Just "ALIGN_PERCENTILE_99") (textAt ["conditionThreshold", "aggregations", "0", "perSeriesAligner"] lat)-        assertBool "" (maybe False ("request_latencies" `Text.isInfixOf`) (textAt ["conditionThreshold", "filter"] lat))-        assertEqual "" (Just "ALIGN_PERCENTILE_99") (textAt ["conditionThreshold", "aggregations", "0", "perSeriesAligner"] mem)-        assertBool "" (maybe False ("memory/utilizations" `Text.isInfixOf`) (textAt ["conditionThreshold", "filter"] mem))-        assertEqual "" (Just "REDUCE_SUM") (textAt ["conditionThreshold", "aggregations", "0", "crossSeriesReducer"] cnt)-        assertBool "" (maybe False ("metric.labels.state=\"active\"" `Text.isInfixOf`) (textAt ["conditionThreshold", "filter"] cnt))-    , testCase "the rendered policy names its channels and carries the fingerprint as a user label" $ do-        let names = ["projects/p/notificationChannels/1"]-            v = Monitoring.renderPolicy policy names-        assertEqual "" (Just "svc: 5xx ratio") (textAt ["displayName"] v)-        assertEqual "" (Just "OR") (textAt ["combiner"] v)-        assertEqual "" (Just "projects/p/notificationChannels/1") (textAt ["notificationChannels", "0"] v)-        assertEqual "" (Just (Monitoring.policyFingerprint policy names)) (textAt ["userLabels", Monitoring.fingerprintLabel] v)-        -- and it is what --policy carries, inline-        let args = processArgs (prepare Monitoring.monitoringCommand (Monitoring.PoliciesCreate policy names))-        assertBool (show args) (["alpha", "monitoring", "policies", "create", "--policy"] `isSubsequenceOf` args)-        assertBool (show args) (Text.unpack (Text.decodeUtf8 (LByteString.toStrict (encode v))) `elem` args)-    , testCase "the fingerprint is label-safe, and moves with a threshold or a channel id" $ do-        let names = ["projects/p/notificationChannels/1"]-            fp = Monitoring.policyFingerprint policy names-        assertEqual (show fp) 16 (Text.length fp)-        assertBool (show fp) (Text.all (\c -> isAsciiLower c || isDigit c) fp)-        assertBool "same declaration, same fingerprint" (fp == Monitoring.policyFingerprint policy names)-        assertBool "threshold" (fp /= Monitoring.policyFingerprint policy{Monitoring.apConditions = [Monitoring.ServerErrorRatio 0.1 300]} names)-        assertBool "channel id" (fp /= Monitoring.policyFingerprint policy ["projects/p/notificationChannels/2"])-        assertBool "documentation" (fp /= Monitoring.policyFingerprint policy{Monitoring.apDocumentation = "other"} names)-    , testCase "policy lookup: a matching fingerprint is satisfied, anything else is not" $ do-        let names = ["projects/p/notificationChannels/1"]-            fp = Monitoring.policyFingerprint policy names-        assertEqual "" Success (Monitoring.interpretPolicyList policy names (ExitSuccess, policyJson (Just fp), ""))-        assertBool "edited or older" (isFailure (Monitoring.interpretPolicyList policy names (ExitSuccess, policyJson (Just "0000000000000000"), "")))-        assertBool "no label" (isFailure (Monitoring.interpretPolicyList policy names (ExitSuccess, policyJson Nothing, "")))-        assertBool "absent" (isFailure (Monitoring.interpretPolicyList policy names (ExitSuccess, "[]", "")))-        assertBool "unreachable" (isFailure (Monitoring.interpretPolicyList policy names (ExitFailure 1, "", "")))-        assertEqual-            ""-            (Monitoring.Present (Monitoring.FoundPolicy "projects/p/alertPolicies/9" (Just fp)))-            (Monitoring.lookupPolicy policy ExitSuccess (policyJson (Just fp)) "")-    , testCase "an update names the policy found, a delete the resource, both under the project" $ do-        let up = processArgs (prepare Monitoring.monitoringCommand (Monitoring.PoliciesUpdate "projects/p/alertPolicies/9" policy []))-            del = processArgs (prepare Monitoring.monitoringCommand (Monitoring.PoliciesDelete (Core.Project "p") "projects/p/alertPolicies/9"))-            cdel = processArgs (prepare Monitoring.monitoringCommand (Monitoring.ChannelsDelete (Core.Project "p") "projects/p/notificationChannels/1"))-        assertBool (show up) (["policies", "update", "projects/p/alertPolicies/9", "--policy"] `isSubsequenceOf` up)-        assertBool (show del) (["policies", "delete", "projects/p/alertPolicies/9", "--quiet", "--project", "p"] `isSubsequenceOf` del)-        assertBool (show cdel) (["channels", "delete", "projects/p/notificationChannels/1", "--quiet"] `isSubsequenceOf` cdel)-    ]-  where-    channel = Monitoring.NotificationChannel (Core.Project "p") "ops mail" (Monitoring.Email "ops@example.org")-    target = Monitoring.CloudRunTarget (Core.Project "p") (Core.Region "europe-west1") "svc"-    policy =-        Monitoring.AlertPolicy-            { Monitoring.apProject = Core.Project "p"-            , Monitoring.apDisplayName = "svc: 5xx ratio"-            , Monitoring.apTarget = target-            , Monitoring.apConditions = [Monitoring.ServerErrorRatio 0.05 300]-            , Monitoring.apChannels = [channel]-            , Monitoring.apDocumentation = "doc"-            }-    channelJson address =-        "[{\"name\": \"projects/p/notificationChannels/1\", \"type\": \"email\", \"displayName\": \"ops mail\", \"labels\": {\"email_address\": \"" <> Text.encodeUtf8 address <> "\"}, \"enabled\": true}]"-    policyJson mfp =-        "[{\"name\": \"projects/p/alertPolicies/9\", \"displayName\": \"svc: 5xx ratio\", \"combiner\": \"OR\""-            <> maybe "" (\fp -> ", \"userLabels\": {\"salmon-fingerprint\": \"" <> Text.encodeUtf8 fp <> "\"}") mfp-            <> "}]"---- | A field of a JSON value by path; an array index is spelled as a number.-fieldAt :: [Text.Text] -> Value -> Maybe Value-fieldAt [] v = Just v-fieldAt (k : ks) (Object o) = KeyMap.lookup (Key.fromText k) o >>= fieldAt ks-fieldAt (k : ks) (Array xs) = case reads (Text.unpack k) of-    [(i, "")] | i >= 0, i < length xs -> fieldAt ks (toList xs !! i)-    _ -> Nothing-fieldAt _ _ = Nothing--textAt :: [Text.Text] -> Value -> Maybe Text.Text-textAt ks v = case fieldAt ks v of-    Just (String t) -> Just t-    _ -> Nothing--cloudRunAlertsTests :: [TestTree]-cloudRunAlertsTests =-    [ testCase "three policies without a maximum, four with; one channel shared by all" $ do-        let without = CloudRunAlerts.standardPolicies cfg{CloudRunAlerts.cra_maxInstances = Nothing}-            with = CloudRunAlerts.standardPolicies cfg-        assertEqual "" 3 (length without)-        assertEqual "" 4 (length with)-        assertEqual "" 1 (length (nub (concatMap (.apChannels) with)))-        assertBool "" (any (\p -> p.apConditions == [Monitoring.InstanceCount 2 300]) with)-    , testCase "display names are distinct per service, so two services' alerts are distinct resources" $ do-        let a = map (.apDisplayName) (CloudRunAlerts.standardPolicies cfg)-            b = map (.apDisplayName) (CloudRunAlerts.standardPolicies cfg{CloudRunAlerts.cra_service = "other"})-        assertEqual "" 4 (length (nub a))-        assertBool (show (a, b)) (null (filter (`elem` b) a))-        assertBool "" (all ("svc: " `Text.isPrefixOf`) a)-    , testCase "the defaults are the documented ones" $ do-        let t = CloudRunAlerts.defaultAlertThresholds-        assertEqual "" 0.05 t.at_errorRatio-        assertEqual "" 2000 t.at_latencyP99Ms-        assertEqual "" 0.9 t.at_memoryUtilization-        assertEqual "" 300 t.at_duration-    ]-  where-    cfg =-        CloudRunAlerts.CloudRunAlertsConfig-            { CloudRunAlerts.cra_project = Core.Project "p"-            , CloudRunAlerts.cra_region = Core.Region "europe-west1"-            , CloudRunAlerts.cra_service = "svc"-            , CloudRunAlerts.cra_email = "ops@example.org"-            , CloudRunAlerts.cra_channelName = "ops mail"-            , CloudRunAlerts.cra_maxInstances = Just 2-            , CloudRunAlerts.cra_thresholds = CloudRunAlerts.defaultAlertThresholds-            }+import Control.Exception (try)+import Data.Aeson (Value (..), decodeStrict, encode, object, toJSONList, (.=))+import qualified Data.Aeson.Key as Key+import qualified Data.Aeson.KeyMap as KeyMap+import qualified Data.ByteString as ByteString+import qualified Data.ByteString.Lazy as LByteString+import Data.Bits (xor)+import Data.Char (isAsciiLower, isDigit)+import Data.List (foldl', isInfixOf, isPrefixOf, isSubsequenceOf, nub, sort, tails)+import qualified Data.Text as Text+import qualified Data.Text.Encoding as Text+import Data.Foldable (toList)+import Data.Time.Clock (UTCTime, addUTCTime)+import Data.Time.Clock.POSIX (posixSecondsToUTCTime)+import Data.Time.Format (defaultTimeLocale, formatTime)+import Data.Time.LocalTime (hoursToTimeZone, utcToZonedTime)+import qualified Data.Map as Map+import qualified Data.Set as Set+import Data.Word (Word64)+import GHC.IO.Exception (ExitCode (..))+import System.Directory (createDirectory)+import System.Environment (getEnv)+import System.FilePath ((</>))+import System.IO.Temp (withSystemTempDirectory)+import System.Process (CreateProcess (env), proc, readCreateProcessWithExitCode, readProcessWithExitCode)+import System.Process.ListLike (CmdSpec (..), CreateProcess, cmdspec)+import Test.QuickCheck (Gen, Property, arbitrary, choose, conjoin, counterexample, elements, forAll, listOf, listOf1, oneof, property, (.&&.), (===), (==>))+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, assertEqual, testCase)+import Test.Tasty.QuickCheck (testProperty)++import Salmon.Actions.UpDown (CheckResult (..))+import Salmon.Builtin.Extension (Extension (..), Op, evalDeps, ignoreTrack, nodeps, op)+import Salmon.Builtin.Nodes.Binary (prepare)+import qualified Salmon.Builtin.Nodes.Gcp.ArtifactRegistry as ArtifactRegistry+import qualified Salmon.Builtin.Nodes.Gcp.Billing as Billing+import qualified Salmon.Builtin.Nodes.Gcp.CloudDns as CloudDns+import qualified Salmon.Builtin.Nodes.Gcp.CloudRun as CloudRun+import qualified Salmon.Builtin.Nodes.Gcp.Compute as Compute+import qualified Salmon.Builtin.Nodes.Gcp.Core as Core+import qualified Salmon.Builtin.Nodes.Gcp.Iam as Iam+import qualified Salmon.Builtin.Nodes.Gcp.LoadBalancing as LoadBalancing+import qualified Salmon.Builtin.Nodes.Gcp.Monitoring as Monitoring+import qualified Salmon.Builtin.Nodes.Gcp.ResourceManager as ResourceManager+import qualified Salmon.Builtin.Nodes.Gcp.SecretManager as SecretManager+import qualified Salmon.Builtin.Nodes.Gcp.ServiceUsage as ServiceUsage+import qualified Salmon.Builtin.Nodes.Gcp.Storage as Storage+import qualified Salmon.Builtin.Nodes.Rsync as Rsync+import qualified Salmon.Builtin.Nodes.Ssh as Ssh+import Salmon.Op.Actions (Act (..))+import qualified Salmon.Op.Dag as Dag+import Salmon.Op.OpGraph (inject)+import Salmon.Op.Ref (mkRef)+import Salmon.Reporter (silent)+import qualified SreBox.Gcp.CloudRunAlerts as CloudRunAlerts+import qualified SreBox.Gcp.VmProvision as VmProvision++tests :: TestTree+tests =+    testGroup+        "Salmon.Builtin.Nodes.Gcp"+        [ testGroup "Core.interpretAdc" adcTests+        , testGroup "Core.declaredAccount" accountTests+        , testGroup "Compute.interpretInstanceStatus" instanceTests+        , testGroup "Compute.instanceScopes" instanceScopeTests+        , testGroup "Compute.instanceMachineType" instanceMachineTypeTests+        , testGroup "Compute.instanceRunLimit" instanceRunLimitTests+        , testGroup "Storage.interpretBucketDescribe" bucketTests+        , testGroup "Storage bucket settings" bucketSettingsTests+        , testGroup "Storage bucket contents" bucketContentsTests+        , testGroup "ArtifactRegistry.interpretRepoDescribe" repoTests+        , testGroup "CloudRun.interpretServiceDescribe" cloudRunTests+        , testGroup "Iam" iamTests+        , testGroup "LoadBalancing" lbTests+        , testGroup "ServiceUsage" serviceUsageTests+        , testGroup "Billing.interpretBillingDescribe" billingTests+        , testGroup "ResourceManager" projectTests+        , testGroup "Compute (tier 2 resources)" vmTests+        , testGroup "Compute (tier 3 resources)" lbBackendTests+        , testGroup "SecretManager" secretTests+        , testGroup "CloudRun options" cloudRunOptionTests+        , testGroup "Ssh.ClientOpts" clientOptsTests+        , testGroup "Monitoring" monitoringTests+        , testGroup "CloudDns" cloudDnsTests+        , testGroup "CloudDns record sets" cloudDnsRecordTests+        , testGroup "SreBox.Gcp.CloudRunAlerts" cloudRunAlertsTests+        , testGroup "SreBox.Gcp.VmProvision.caTrustStartupScript" caTrustScriptTests+        ]++-- | Extracts the argument list of a prepared gcloud 'CreateProcess', for+-- asserting on rendered command-line shape without actually invoking gcloud.+processArgs :: CreateProcess -> [String]+processArgs p = case cmdspec p of+    RawCommand _path args -> args+    ShellCommand s -> [s]++isFailure :: CheckResult -> Bool+isFailure (Failure _) = True+isFailure _ = False++-------------------------------------------------------------------------------++adcTests :: [TestTree]+adcTests =+    [ testCase "a printed token means ADC is usable" $+        assertEqual "" Success (Core.interpretAdc ExitSuccess)+    , testCase "no token means ADC is not configured" $+        assertBool "" (isFailure (Core.interpretAdc (ExitFailure 1)))+    , testCase "every gcloud invocation is non-interactive" $ do+        -- no node's process reads the pass's standard input, so a prompt+        -- could never be answered: gcloud is told not to ask.+        assertEqual "" ["config", "get-value", "account", "--quiet"] (processArgs (Core.gcloudProc Core.activeAccountArgs))+        assertEqual "said once, wherever the caller put it" ["x", "--quiet", "--project", "p"] (processArgs (Core.gcloudProc ["x", "--quiet", "--project", "p"]))+    ]++-------------------------------------------------------------------------------++accountTests :: [TestTree]+accountTests =+    [ testCase "the active account is read from the core/account property" $+        assertEqual "" ["config", "get-value", "account"] Core.activeAccountArgs+    , testCase "the declared account being active is satisfied" $+        assertEqual "" Success (verdict ExitSuccess "deployer@example.com\n")+    , testCase "addresses compare without regard to case" $+        assertEqual "" Success (verdict ExitSuccess "Deployer@Example.com\n")+    , testCase "another active account is refused, naming both" $+        case verdict ExitSuccess "someone-else@example.com\n" of+            Failure why -> do+                assertBool "names the active one" ("someone-else@example.com" `Text.isInfixOf` why)+                assertBool "names the declared one" ("deployer@example.com" `Text.isInfixOf` why)+            other -> assertBool ("expected Failure, got " <> show other) False+    , testCase "an account that merely contains the declared one is refused" $+        assertBool "" (isFailure (verdict ExitSuccess "not-deployer@example.com\n"))+    , testCase "no active account is refused (empty stdout, or the old (unset) on stdout)" $ do+        assertBool "empty" (isFailure (verdict ExitSuccess ""))+        assertBool "unset" (isFailure (verdict ExitSuccess "(unset)\n"))+    , testCase "gcloud failing is a Failure, never a pass" $+        assertBool "" (isFailure (verdict (ExitFailure 1) "deployer@example.com\n"))+    , testCase "--account is appended to an argument list" $+        assertEqual+            ""+            ["auth", "print-access-token", "--account", "deployer@example.com"]+            (Core.withAccount declared ["auth", "print-access-token"])+    ]+  where+    declared = Core.Account "deployer@example.com"+    verdict = Core.interpretAccount declared++-------------------------------------------------------------------------------++instanceTests :: [TestTree]+instanceTests =+    [ testCase "RUNNING is satisfied" $+        assertEqual "" Success (Compute.interpretInstanceStatus on ExitSuccess "RUNNING")+    , testCase "TERMINATED needs bringing up" $+        assertBool "" (isFailure (Compute.interpretInstanceStatus on ExitSuccess "TERMINATED"))+    , testCase "transitional states are Unknown, not Failure" $ do+        assertEqual "provisioning" Unknown (Compute.interpretInstanceStatus on ExitSuccess "PROVISIONING")+        assertEqual "staging" Unknown (Compute.interpretInstanceStatus on ExitSuccess "STAGING")+        assertEqual "stopping" Unknown (Compute.interpretInstanceStatus on ExitSuccess "STOPPING")+    , testCase "an unrecognized status is a Failure, not a crash" $+        assertBool "" (isFailure (Compute.interpretInstanceStatus on ExitSuccess "SOME-NEW-STATUS"))+    , testCase "describe failing outright (e.g. instance absent) is a Failure" $+        assertBool "" (isFailure (Compute.interpretInstanceStatus on (ExitFailure 1) ""))+    , testCase "up creates an absent instance" $+        assertEqual "" Compute.CreateInstance (Compute.planInstanceUp on (ExitFailure 1) "")+    , testCase "up starts a stopped instance rather than re-creating it" $+        assertEqual "" Compute.StartInstance (Compute.planInstanceUp on ExitSuccess "TERMINATED")+    , testCase "up resumes a suspended instance" $+        assertEqual "" Compute.ResumeInstance (Compute.planInstanceUp on ExitSuccess "SUSPENDED")+    , testCase "up leaves a running instance alone" $+        assertEqual "" Compute.AlreadyThere (Compute.planInstanceUp on ExitSuccess "RUNNING")+    , testCase "up refuses to act on a transitional status" $+        assertEqual "" (Compute.CannotActYet "STOPPING") (Compute.planInstanceUp on ExitSuccess "STOPPING")+    , testCase "declared stopped: TERMINATED is satisfied and RUNNING is not" $ do+        assertEqual "" Success (Compute.interpretInstanceStatus off ExitSuccess "TERMINATED")+        assertBool "" (isFailure (Compute.interpretInstanceStatus off ExitSuccess "RUNNING"))+    , testCase "declared stopped: up stops a running instance and leaves a stopped one alone" $ do+        assertEqual "" Compute.StopInstance (Compute.planInstanceUp off ExitSuccess "RUNNING")+        assertEqual "" Compute.AlreadyThere (Compute.planInstanceUp off ExitSuccess "TERMINATED")+        assertEqual "" Compute.CreateInstance (Compute.planInstanceUp off (ExitFailure 1) "")+    , testCase "declared stopped: a suspended instance is not stopped blindly" $+        assertBool "" (case Compute.planInstanceUp off ExitSuccess "SUSPENDED" of Compute.CannotActYet _ -> True; _ -> False)+    , testCase "for down, a stopped instance is still present; an absent one is not" $ do+        assertEqual "" Success (Compute.interpretInstancePresence ExitSuccess "TERMINATED")+        assertBool "" (isFailure (Compute.interpretInstancePresence (ExitFailure 1) ""))+    , testCase "stop is rendered like start" $+        assertEqual+            ""+            (RawCommand "gcloud" ["compute", "instances", "stop", "toy-vm", "--zone", "europe-west1-b", "--project", "p", "--quiet"])+            (cmdspec (prepare Compute.computeCommand (Compute.InstancesStop toyInstance)))+    ]+  where+    on = Compute.PoweredOn+    off = Compute.PoweredOff+    toyInstance =+        Compute.Instance+            { Compute.instanceName = "toy-vm"+            , Compute.instanceProject = Core.Project "p"+            , Compute.instanceZone = Core.Zone "europe-west1-b"+            , Compute.instanceMachineType = Compute.Custom "e2-micro"+            , Compute.instanceBootDisk = Compute.BootDisk 10 Nothing (Just "ubuntu-2404-lts-amd64") (Just "ubuntu-os-cloud")+            , Compute.instanceNetwork = "default"+            , Compute.instanceSubnet = "default"+            , Compute.instanceServiceAccount = Nothing+            , Compute.instanceMetadata = Map.fromList [("enable-oslogin", "FALSE")]+            , Compute.instanceMetadataFiles = Map.fromList [("startup-script", "/tmp/w/startup-script.sh")]+            , Compute.instanceExternalAddress = Compute.ReservedExternal "toy-ip"+            , Compute.instanceInternalAddress = Compute.EphemeralInternal+            , Compute.instanceTags = ["toy-ssh"]+            , Compute.instancePower = Compute.PoweredOn+            , Compute.instanceScopes = Compute.DefaultScopes+            , Compute.instanceMachineTypeChange = Compute.RefuseMachineTypeChange+            , Compute.instanceRunLimit = Compute.NoRunLimit+            }++-------------------------------------------------------------------------------++instanceScopeTests :: [TestTree]+instanceScopeTests =+    [ testCase "an instance declaring no scope is the command, ref, help and notes it was" $ do+        assertEqual+            "create"+            [ "compute", "instances", "create", "toy-vm"+            , "--machine-type", "e2-micro"+            , "--boot-disk-size", "10GB"+            , "--network", "default"+            , "--subnet", "default"+            , "--zone", "europe-west1-b"+            , "--project", "p"+            , "--image-family", "ubuntu-2404-lts-amd64"+            , "--image-project", "ubuntu-os-cloud"+            , "--metadata", "enable-oslogin=FALSE"+            , "--metadata-from-file", "startup-script=/tmp/w/startup-script.sh"+            , "--address", "toy-ip"+            , "--tags", "toy-ssh"+            , "--quiet"+            ]+            (createArgs inst)+        let act = node inst+        assertEqual "ref" (mkRef "gcp-instance" ("p" :: Text.Text, "europe-west1-b" :: Text.Text, "toy-vm" :: Text.Text)) act.extension.ref+        assertEqual "help" "creates GCE instance toy-vm" act.extension.help+        assertEqual "notes" [] act.extension.notes+    , testCase "declared scopes are passed as written, after everything else" $+        assertEqual+            ""+            (init (createArgs inst) <> ["--scopes", "storage-rw,https://www.googleapis.com/auth/logging.write", "--quiet"])+            (createArgs (scoped ["storage-rw", "https://www.googleapis.com/auth/logging.write"]))+    , testCase "an empty declaration is no scope at all" $+        assertEqual "" (init (createArgs inst) <> ["--no-scopes", "--quiet"]) (createArgs (scoped []))+    , testCase "declared scopes are the same node, described differently" $ do+        assertEqual "ref" (node inst).extension.ref (node (scoped ["storage-rw"])).extension.ref+        assertEqual "help" (node inst).extension.help (node (scoped ["storage-rw"])).extension.help+        assertEqual "notes" ["scopes: storage-rw"] (node (scoped ["storage-rw"])).extension.notes+        assertEqual "none" ["scopes: none"] (node (scoped [])).extension.notes+        assertBool "another set is another description" ((node (scoped ["storage-rw"])).extension.notes /= (node (scoped ["storage-ro"])).extension.notes)+    , testCase "an alias stands for its URIs, a URI for itself" $ do+        assertEqual "alias" (Right ["https://www.googleapis.com/auth/devstorage.read_write"]) (Compute.scopeUris "storage-rw")+        assertEqual "uri" (Right ["https://www.googleapis.com/auth/cloud-platform"]) (Compute.scopeUris "https://www.googleapis.com/auth/cloud-platform")+        assertEqual "default is several" (Right 7) (length <$> Compute.scopeUris "default")+    , testCase "an unknown alias, an empty scope and a scope holding a separator are refused" $ do+        assertEqual "nothing wrong" [] (Compute.scopeProblems (Compute.DeclaredScopes ["storage-rw", "cloud-platform"]))+        assertEqual "undeclared" [] (Compute.scopeProblems Compute.DefaultScopes)+        assertEqual "each one named" 3 (length (Compute.scopeProblems (Compute.DeclaredScopes ["storage-rw", "storage-write", "", "a,b"])))+        assertEqual "no set to compare" Nothing (Compute.declaredScopeUris (Compute.DeclaredScopes ["storage-write"]))+    , testCase "the scopes are asked for in one describe" $+        assertEqual+            ""+            ["compute", "instances", "describe", "toy-vm", "--format=value(serviceAccounts[].scopes)", "--zone", "europe-west1-b", "--project", "p", "--quiet"]+            (processArgs (prepare Compute.computeCommand (Compute.InstancesDescribeScopes inst)))+    , testCase "live scopes are read whatever separates them" $ do+        let want = Set.fromList [rw, logging]+        assertEqual "semicolons" want (Compute.parseLiveScopes (rw <> ";" <> logging <> "\n"))+        assertEqual "commas" want (Compute.parseLiveScopes (logging <> "," <> rw))+        assertEqual "a list" want (Compute.parseLiveScopes ("['" <> rw <> "', '" <> logging <> "']"))+        assertEqual "no service account" Set.empty (Compute.parseLiveScopes "\n")+    , testCase "an instance holding the declared scopes is satisfied, in any order and spelling" $ do+        let declared = Compute.DeclaredScopes ["logging-write", rw]+        assertEqual "" Success (Compute.interpretInstanceScopes declared ExitSuccess (rw <> ";" <> logging))+        assertEqual "none, none" Success (Compute.interpretInstanceScopes (Compute.DeclaredScopes []) ExitSuccess "")+    , testCase "an instance holding other scopes is a failure naming both sets" $+        case Compute.interpretInstanceScopes (Compute.DeclaredScopes ["storage-rw"]) ExitSuccess ro of+            Failure t -> do+                assertBool (Text.unpack t) (ro `Text.isInfixOf` t)+                assertBool (Text.unpack t) (rw `Text.isInfixOf` t)+                assertBool (Text.unpack t) ("stopped" `Text.isInfixOf` t)+            other -> assertBool (show other) False+    , testCase "more scopes than declared, or fewer, is not satisfied" $ do+        assertBool "more" (isFailure (Compute.interpretInstanceScopes (Compute.DeclaredScopes ["storage-rw"]) ExitSuccess (rw <> ";" <> logging)))+        assertBool "fewer" (isFailure (Compute.interpretInstanceScopes (Compute.DeclaredScopes ["storage-rw", "logging-write"]) ExitSuccess rw))+        assertBool "none held" (isFailure (Compute.interpretInstanceScopes (Compute.DeclaredScopes ["storage-rw"]) ExitSuccess ""))+    , testCase "a failing describe is a failure, and nothing declared is never one" $ do+        assertBool "" (isFailure (Compute.interpretInstanceScopes (Compute.DeclaredScopes ["storage-rw"]) (ExitFailure 1) ""))+        assertEqual "" Success (Compute.interpretInstanceScopes Compute.DefaultScopes (ExitFailure 1) "")+        assertEqual "" Success (Compute.interpretInstanceScopes Compute.DefaultScopes ExitSuccess ro)+    , testCase "an undeclarable scope fails the check and the up before any gcloud call" $ do+        let act = node (scoped ["storage-write"])+        verdict <- act.extension.check+        assertBool (show verdict) (isFailure verdict)+        r <- try act.extension.up+        case r of+            Left e -> assertBool (show e) ("storage-write" `isInfixOf` show (e :: IOError))+            Right () -> assertBool "up went through" False+    ]+  where+    rw = "https://www.googleapis.com/auth/devstorage.read_write"+    ro = "https://www.googleapis.com/auth/devstorage.read_only"+    logging = "https://www.googleapis.com/auth/logging.write"+    createArgs i = processArgs (prepare Compute.computeCommand (Compute.InstancesCreate i))+    node i = only (Compute.gceInstance silent ignoreTrack i)+    only o = case Map.elems (Dag.dagNodes (Dag.foldDag Dag.sameRepresentative (evalDeps o))) of+        [act] -> act+        acts -> error ("expected one node, got " <> show (length acts))+    scoped scopes = inst{Compute.instanceScopes = Compute.DeclaredScopes scopes}+    inst =+        Compute.Instance+            { Compute.instanceName = "toy-vm"+            , Compute.instanceProject = Core.Project "p"+            , Compute.instanceZone = Core.Zone "europe-west1-b"+            , Compute.instanceMachineType = Compute.Custom "e2-micro"+            , Compute.instanceBootDisk = Compute.BootDisk 10 Nothing (Just "ubuntu-2404-lts-amd64") (Just "ubuntu-os-cloud")+            , Compute.instanceNetwork = "default"+            , Compute.instanceSubnet = "default"+            , Compute.instanceServiceAccount = Nothing+            , Compute.instanceMetadata = Map.fromList [("enable-oslogin", "FALSE")]+            , Compute.instanceMetadataFiles = Map.fromList [("startup-script", "/tmp/w/startup-script.sh")]+            , Compute.instanceExternalAddress = Compute.ReservedExternal "toy-ip"+            , Compute.instanceInternalAddress = Compute.EphemeralInternal+            , Compute.instanceTags = ["toy-ssh"]+            , Compute.instancePower = Compute.PoweredOn+            , Compute.instanceScopes = Compute.DefaultScopes+            , Compute.instanceMachineTypeChange = Compute.RefuseMachineTypeChange+            , Compute.instanceRunLimit = Compute.NoRunLimit+            }++-------------------------------------------------------------------------------++{- | The machine type of an existing instance: compared by the check, and+either refused or (with the declaration's permission) reconciled by @up@.+Everything here is the pure half -- the parser, the comparison, the plan and+what is rendered; no gcloud, real or fake, is run.+-}+instanceMachineTypeTests :: [TestTree]+instanceMachineTypeTests =+    [ testCase "the machine type is asked for in one describe" $+        assertEqual+            ""+            ["compute", "instances", "describe", "toy-vm", "--format=value(machineType.basename())", "--zone", "europe-west1-b", "--project", "p", "--quiet"]+            (processArgs (prepare Compute.computeCommand (Compute.InstancesDescribeMachineType inst)))+    , testCase "set-machine-type names the declared type and never prompts" $+        assertEqual+            ""+            ["compute", "instances", "set-machine-type", "toy-vm", "--machine-type", "e2-micro", "--zone", "europe-west1-b", "--project", "p", "--quiet"]+            (processArgs (prepare Compute.computeCommand (Compute.InstancesSetMachineType inst)))+    , testCase "a live type is read as a bare name or as the URL GCP describes it with" $ do+        assertEqual "name" (Right "e2-medium") (Compute.parseLiveMachineType "e2-medium\n")+        assertEqual "url" (Right "e2-medium") (Compute.parseLiveMachineType "https://www.googleapis.com/compute/v1/projects/p/zones/europe-west1-b/machineTypes/e2-medium\n")+        assertEqual "custom" (Right "e2-custom-2-4096") (Compute.parseLiveMachineType "e2-custom-2-4096")+    , testCase "an output that is not one machine type name is not read as one" $ do+        assertBool "empty" (isLeft (Compute.parseLiveMachineType ""))+        assertBool "blank" (isLeft (Compute.parseLiveMachineType " \n"))+        assertBool "two words" (isLeft (Compute.parseLiveMachineType "e2-medium e2-small"))+        assertBool "an error message" (isLeft (Compute.parseLiveMachineType "ERROR: (gcloud.compute.instances.describe) not found"))+        assertBool "a trailing slash" (isLeft (Compute.parseLiveMachineType "zones/z/machineTypes/"))+        assertBool "other characters" (isLeft (Compute.parseLiveMachineType "E2_MEDIUM"))+    , testCase "an instance running the declared type is satisfied, under either declaration" $ do+        assertEqual "" Success (Compute.interpretInstanceMachineType refuse (Compute.Custom "e2-micro") ExitSuccess "e2-micro\n")+        assertEqual "" Success (Compute.interpretInstanceMachineType mayStop Compute.E2Medium ExitSuccess "e2-medium\n")+        assertEqual "declared as a path" Success (Compute.interpretInstanceMachineType refuse (Compute.Custom "zones/z/machineTypes/e2-micro") ExitSuccess "e2-micro\n")+    , testCase "an instance running another type is a failure naming both" $+        case Compute.interpretInstanceMachineType refuse Compute.E2Standard2 ExitSuccess "e2-medium\n" of+            Failure t -> do+                assertBool (Text.unpack t) ("runs machine type e2-medium" `Text.isInfixOf` t)+                assertBool (Text.unpack t) ("declared e2-standard-2" `Text.isInfixOf` t)+                assertBool (Text.unpack t) ("does not permit stopping it" `Text.isInfixOf` t)+            other -> assertBool (show other) False+    , testCase "a type that could not be read is a failure, never a pass" $ do+        assertBool "describe failed" (isFailure (Compute.interpretInstanceMachineType refuse Compute.E2Medium (ExitFailure 1) "e2-medium"))+        assertBool "empty" (isFailure (Compute.interpretInstanceMachineType mayStop Compute.E2Medium ExitSuccess ""))+    , testCase "without the permission, up refuses a differing type and plans no call" $ do+        let differs = Compute.MachineTypeDiffers "e2-medium" "e2-standard-2"+        case planTyped refuse on "RUNNING" differs of+            Left t -> do+                assertBool (Text.unpack t) ("e2-medium" `Text.isInfixOf` t)+                assertBool (Text.unpack t) ("e2-standard-2" `Text.isInfixOf` t)+            Right steps -> assertBool (show steps) False+        -- a stopped instance of another type is not started either: the+        -- declaration did not ask for that machine+        assertBool "stopped" (isLeft (planTyped refuse on "TERMINATED" differs))+        assertBool "declared stopped" (isLeft (planTyped refuse off "RUNNING" differs))+    , testCase "with the permission, up stops, sets the type and starts again" $ do+        let differs = Compute.MachineTypeDiffers "e2-medium" "e2-standard-2"+        assertEqual "running" (Right [Compute.StopStep, Compute.SetMachineTypeStep, Compute.StartStep]) (planTyped mayStop on "RUNNING" differs)+        assertEqual "already stopped" (Right [Compute.SetMachineTypeStep, Compute.StartStep]) (planTyped mayStop on "TERMINATED" differs)+        assertEqual "declared stopped, running" (Right [Compute.StopStep, Compute.SetMachineTypeStep]) (planTyped mayStop off "RUNNING" differs)+        assertEqual "declared stopped, stopped" (Right [Compute.SetMachineTypeStep]) (planTyped mayStop off "TERMINATED" differs)+    , testCase "with the permission, a suspended or unsettled instance is not resized" $ do+        let differs = Compute.MachineTypeDiffers "e2-medium" "e2-standard-2"+        assertBool "suspended" (isLeft (planTyped mayStop on "SUSPENDED" differs))+        assertBool "stopping" (isLeft (planTyped mayStop on "STOPPING" differs))+    , testCase "a type that could not be read stops nothing, even with the permission" $+        assertBool "" (isLeft (planTyped mayStop on "RUNNING" (Compute.MachineTypeUndetermined "no machine type could be read")))+    , testCase "the permission is a note naming the type; the node is the same node" $ do+        let permitted = node inst{Compute.instanceMachineTypeChange = Compute.MayStopToResize}+        assertEqual "ref" (node inst).extension.ref permitted.extension.ref+        assertEqual "help" (node inst).extension.help permitted.extension.help+        assertEqual "notes" ["machine type: e2-micro (may stop to resize)"] permitted.extension.notes+        assertEqual "without it, the notes it had" [] (node inst).extension.notes+    , testProperty "a machine type name is read back from a bare name, a path or a URL, whatever surrounds it" $+        forAll genTypeName $ \name ->+            forAll (elements ["", "zones/europe-west1-b/machineTypes/", "https://www.googleapis.com/compute/v1/projects/p/zones/europe-west1-b/machineTypes/"]) $ \prefix ->+                forAll genBlank $ \before ->+                    forAll genBlank $ \after ->+                        Compute.parseLiveMachineType (before <> prefix <> name <> after) === Right name+    , testProperty "whatever the output, what is read is a well-formed name the output ends with" $+        \junk ->+            let out = Text.pack junk+             in case Compute.parseLiveMachineType out of+                    Left _ -> property True+                    Right name ->+                        counterexample (show name) $+                            not (Text.null name)+                                .&&. Text.all (`elem` typeNameChars) name+                                .&&. name `Text.isSuffixOf` Text.strip out+    , testProperty "rendering a declared type and reading it back is the declared type" $+        forAll genMachineType $ \declared ->+            Compute.compareMachineType declared ExitSuccess (Compute.renderMachineType declared <> "\n") === Compute.MachineTypeAsDeclared+    , testProperty "the comparison is equality of names, and names both when they differ" $+        forAll genMachineType $ \declared ->+            forAll genTypeName $ \live ->+                let wanted = Compute.renderMachineType declared+                 in Compute.compareMachineType declared ExitSuccess live+                        === if live == wanted then Compute.MachineTypeAsDeclared else Compute.MachineTypeDiffers live wanted+    , testProperty "a failed describe decides nothing, whatever it printed" $+        forAll genMachineType $ \declared ->+            forAll genTypeName $ \out ->+                case Compute.compareMachineType declared (ExitFailure 1) out of+                    Compute.MachineTypeUndetermined _ -> True+                    _ -> False+    , testProperty "the check passes exactly when the types are equal, and its failure names both" $+        forAll genChange $ \change ->+            forAll genMachineType $ \declared ->+                forAll genTypeName $ \live ->+                    let wanted = Compute.renderMachineType declared+                     in case Compute.interpretInstanceMachineType change declared ExitSuccess live of+                            Success -> live === wanted+                            Failure t -> counterexample (Text.unpack t) (live /= wanted .&&. live `Text.isInfixOf` t .&&. wanted `Text.isInfixOf` t)+                            other -> counterexample (show other) False+    , testProperty "an instance of the declared type gets what up did before, with or without the permission" $+        forAll genChange $ \change ->+            forAll genPower $ \power ->+                forAll genStatus $ \status ->+                    mapLeft (const ()) (planTyped change power status Compute.MachineTypeAsDeclared)+                        === previousUp power (Compute.planInstanceUp power ExitSuccess status)+    , testProperty "an absent instance is created as before" $+        forAll genPower $ \power ->+            mapLeft (const ()) (Compute.instanceUpSteps power Compute.CreateInstance) === previousUp power Compute.CreateInstance+    , testProperty "without the permission the type is never set, and a declared-running machine is never stopped" $+        forAll genPower $ \power ->+            forAll genStatus $ \status ->+                forAll genVerdict $ \verdict ->+                    case planTyped refuse power status verdict of+                        Left _ -> property True+                        Right steps ->+                            counterexample (show steps) $+                                verdict === Compute.MachineTypeAsDeclared+                                    .&&. Compute.SetMachineTypeStep `notElem` steps+                                    .&&. (Compute.StopStep `notElem` steps || power == off)+    , testProperty "without the permission a differing or unread type is always a refusal" $+        forAll genPower $ \power ->+            forAll genStatus $ \status ->+                forAll genVerdict $ \verdict ->+                    verdict /= Compute.MachineTypeAsDeclared ==> isLeft (planTyped refuse power status verdict)+    , testProperty "a stop is only ever planned by a stopped declaration or by the permission meeting a differing type" $+        forAll genChange $ \change ->+            forAll genPower $ \power ->+                forAll genStatus $ \status ->+                    forAll genVerdict $ \verdict ->+                        case planTyped change power status verdict of+                            Right steps+                                | Compute.StopStep `elem` steps ->+                                    counterexample (show steps) (power == off || (change == mayStop && differing verdict))+                            _ -> property True+    , testProperty "an unread type plans nothing under either declaration" $+        forAll genChange $ \change ->+            forAll genPower $ \power ->+                forAll genStatus $ \status ->+                    isLeft (planTyped change power status (Compute.MachineTypeUndetermined "unread"))+    , testProperty "a permitted resize sets the type once, on a stopped machine, and ends in the declared power state" $+        forAll genPower $ \power ->+            forAll (elements ["RUNNING", "TERMINATED"]) $ \status ->+                forAll genDiffers $ \verdict ->+                    case planTyped mayStop power status verdict of+                        Left t -> counterexample (Text.unpack t) False+                        Right steps ->+                            counterexample (show steps) $+                                length (filter (== Compute.SetMachineTypeStep) steps) === 1+                                    .&&. simulate (status == "RUNNING") steps === Just (power == on)+    , testProperty "a permitted resize of an instance neither running nor stopped is a refusal naming both types" $+        forAll genPower $ \power ->+            forAll genStatus $ \status ->+                forAll genDiffers $ \verdict ->+                    status `notElem` ["RUNNING", "TERMINATED"] ==>+                        case (verdict, planTyped mayStop power status verdict) of+                            (Compute.MachineTypeDiffers live wanted, Left t) -> counterexample (Text.unpack t) (live `Text.isInfixOf` t .&&. wanted `Text.isInfixOf` t)+                            (_, other) -> counterexample (show other) False+    , testProperty "the permission changes no command, ref or help, and only it adds a note" $+        forAll genInstance $ \i ->+            let permitted = i{Compute.instanceMachineTypeChange = Compute.MayStopToResize}+                refused = i{Compute.instanceMachineTypeChange = Compute.RefuseMachineTypeChange}+                args c = processArgs (prepare Compute.computeCommand c)+             in conjoin+                    [ args (Compute.InstancesCreate permitted) === args (Compute.InstancesCreate refused)+                    , args (Compute.InstancesStop permitted) === args (Compute.InstancesStop refused)+                    , args (Compute.InstancesStart permitted) === args (Compute.InstancesStart refused)+                    , args (Compute.InstancesDescribeStatus permitted) === args (Compute.InstancesDescribeStatus refused)+                    , (node permitted).extension.ref === (node refused).extension.ref+                    , (node permitted).extension.help === (node refused).extension.help+                    , (node refused).extension.notes === []+                    , (node permitted).extension.notes === ["machine type: " <> Compute.renderMachineType i.instanceMachineType <> " (may stop to resize)"]+                    ]+    , testProperty "set-machine-type carries the declared type, the instance's coordinates and --quiet" $+        forAll genInstance $ \i ->+            processArgs (prepare Compute.computeCommand (Compute.InstancesSetMachineType i))+                === [ "compute", "instances", "set-machine-type", Text.unpack i.instanceName+                    , "--machine-type", Text.unpack (Compute.renderMachineType i.instanceMachineType)+                    , "--zone", "europe-west1-b"+                    , "--project", "p"+                    , "--quiet"+                    ]+    ]+  where+    on = Compute.PoweredOn+    off = Compute.PoweredOff+    refuse = Compute.RefuseMachineTypeChange+    mayStop = Compute.MayStopToResize+    -- the plan of an instance with no run limit: what it was before there+    -- was one to declare+    planTyped change power status verdict = Compute.planExistingInstance change power status verdict Compute.RunUnlimited+    isLeft :: Either a b -> Bool+    isLeft = either (const True) (const False)+    mapLeft :: (a -> c) -> Either a b -> Either c b+    mapLeft f = either (Left . f) Right+    differing v = case v of+        Compute.MachineTypeDiffers _ _ -> True+        _ -> False+    typeNameChars = ['a' .. 'z'] <> ['0' .. '9'] <> "-" :: String+    genTypeName :: Gen Text.Text+    genTypeName = oneof [elements ["e2-medium", "e2-standard-2", "n2-standard-4", "e2-custom-2-4096"], Text.pack <$> listOf1 (elements typeNameChars)]+    genBlank :: Gen Text.Text+    genBlank = Text.pack <$> listOf (elements " \t\n")+    genMachineType :: Gen Compute.MachineType+    genMachineType = oneof [elements [Compute.E2Medium, Compute.E2Standard2, Compute.N2Standard4], Compute.Custom <$> genTypeName]+    genChange = elements [refuse, mayStop]+    genPower = elements [on, off]+    genStatus :: Gen Text.Text+    genStatus =+        oneof+            [ elements ["RUNNING", "TERMINATED", "SUSPENDED", "PROVISIONING", "STAGING", "STOPPING", "SUSPENDING", "REPAIRING", ""]+            , Text.pack <$> arbitrary+            ]+    genDiffers :: Gen Compute.MachineTypeVerdict+    genDiffers = do+        live <- genTypeName+        wanted <- genTypeName+        pure (Compute.MachineTypeDiffers live (if wanted == live then wanted <> "-x" else wanted))+    genVerdict :: Gen Compute.MachineTypeVerdict+    genVerdict = oneof [pure Compute.MachineTypeAsDeclared, genDiffers, pure (Compute.MachineTypeUndetermined "unread")]+    genInstance :: Gen Compute.Instance+    genInstance = do+        name <- genTypeName+        machine <- genMachineType+        power <- genPower+        change <- genChange+        pure inst{Compute.instanceName = name, Compute.instanceMachineType = machine, Compute.instancePower = power, Compute.instanceMachineTypeChange = change}+    -- what 'up' ran for each plan before the machine type was compared,+    -- restated here from the old @bringUp@ rather than taken from the module+    previousUp :: Compute.InstancePower -> Compute.InstanceUpPlan -> Either () [Compute.InstanceStep]+    previousUp power plan = case plan of+        Compute.CreateInstance -> Right (Compute.CreateStep : [Compute.StopStep | power == off])+        Compute.StartInstance -> Right [Compute.StartStep]+        Compute.ResumeInstance -> Right [Compute.ResumeStep]+        Compute.StopInstance -> Right [Compute.StopStep]+        Compute.AlreadyThere -> Right []+        Compute.CannotActYet _ -> Left ()+    -- a machine that is running or not: a step that GCP would refuse (the+    -- type set on a running machine, a stop of a stopped one) is 'Nothing'+    simulate :: Bool -> [Compute.InstanceStep] -> Maybe Bool+    simulate running [] = Just running+    simulate running (step : rest) = case (step, running) of+        (Compute.StopStep, True) -> simulate False rest+        (Compute.StartStep, False) -> simulate True rest+        (Compute.SetMachineTypeStep, False) -> simulate False rest+        _ -> Nothing+    node i = only (Compute.gceInstance silent ignoreTrack i)+    only o = case Map.elems (Dag.dagNodes (Dag.foldDag Dag.sameRepresentative (evalDeps o))) of+        [act] -> act+        acts -> error ("expected one node, got " <> show (length acts))+    inst =+        Compute.Instance+            { Compute.instanceName = "toy-vm"+            , Compute.instanceProject = Core.Project "p"+            , Compute.instanceZone = Core.Zone "europe-west1-b"+            , Compute.instanceMachineType = Compute.Custom "e2-micro"+            , Compute.instanceBootDisk = Compute.BootDisk 10 Nothing (Just "ubuntu-2404-lts-amd64") (Just "ubuntu-os-cloud")+            , Compute.instanceNetwork = "default"+            , Compute.instanceSubnet = "default"+            , Compute.instanceServiceAccount = Nothing+            , Compute.instanceMetadata = Map.fromList [("enable-oslogin", "FALSE")]+            , Compute.instanceMetadataFiles = Map.fromList [("startup-script", "/tmp/w/startup-script.sh")]+            , Compute.instanceExternalAddress = Compute.ReservedExternal "toy-ip"+            , Compute.instanceInternalAddress = Compute.EphemeralInternal+            , Compute.instanceTags = ["toy-ssh"]+            , Compute.instancePower = Compute.PoweredOn+            , Compute.instanceScopes = Compute.DefaultScopes+            , Compute.instanceMachineTypeChange = Compute.RefuseMachineTypeChange+            , Compute.instanceRunLimit = Compute.NoRunLimit+            }++-------------------------------------------------------------------------------++{- | An instance with a run limit: what is rendered, what is read back, the+verdict at a given time, and what the check and @up@ make of it. Everything+here is the pure half, with the time an argument; no gcloud, real or fake,+is run and no clock is read.+-}+instanceRunLimitTests :: [TestTree]+instanceRunLimitTests =+    [ testCase "no limit renders nothing: no flag, no note" $ do+        assertEqual "flags" [] (Compute.runLimitArgs Compute.NoRunLimit)+        assertEqual "notes" [] (node inst).extension.notes+        assertEqual "nothing wrong" [] (Compute.runLimitProblems Compute.NoRunLimit)+    , testCase "a duration is passed in seconds with the STOP action, after everything else" $+        assertEqual+            ""+            (init (createArgs inst) <> ["--max-run-duration", "3600s", "--instance-termination-action", "STOP", "--quiet"])+            (createArgs (limited (Compute.MaxRunDuration 3600)))+    , testCase "a date is passed as an RFC 3339 UTC timestamp with the STOP action" $+        assertEqual+            ""+            (init (createArgs inst) <> ["--termination-time", "2026-10-12T08:30:00Z", "--instance-termination-action", "STOP", "--quiet"])+            (createArgs (limited (Compute.RunUntil (at 1791793800))))+    , testCase "a limit is the same node, described differently" $ do+        let hourly = node (limited (Compute.MaxRunDuration 3600))+        assertEqual "ref" (node inst).extension.ref hourly.extension.ref+        assertEqual "help" (node inst).extension.help hourly.extension.help+        assertEqual "notes" ["run limit: 3600s after each start, then stopped by GCP"] hourly.extension.notes+        assertEqual "date" ["run limit: until 2026-10-12T08:30:00Z, then stopped by GCP"] (node (limited (Compute.RunUntil (at 1791793800)))).extension.notes+        assertBool "another limit is another description" (hourly.extension.notes /= (node (limited (Compute.MaxRunDuration 7200))).extension.notes)+    , testCase "the limit is asked for in one describe, as JSON" $+        assertEqual+            ""+            [ "compute", "instances", "describe", "toy-vm"+            , "--format=json(lastStartTimestamp,scheduling.maxRunDuration.seconds,scheduling.terminationTime,scheduling.instanceTerminationAction)"+            , "--zone", "europe-west1-b", "--project", "p", "--quiet"+            ]+            (processArgs (prepare Compute.computeCommand (Compute.InstancesDescribeRunLimit inst)))+    , testCase "set-scheduling carries the declared limit, clears the other kind and never prompts" $ do+        assertEqual+            "duration"+            ["compute", "instances", "set-scheduling", "toy-vm", "--max-run-duration", "3600s", "--instance-termination-action", "STOP", "--clear-termination-time", "--zone", "europe-west1-b", "--project", "p", "--quiet"]+            (processArgs (prepare Compute.computeCommand (Compute.InstancesSetScheduling (limited (Compute.MaxRunDuration 3600)))))+        assertEqual+            "date"+            ["compute", "instances", "set-scheduling", "toy-vm", "--termination-time", "2026-10-12T08:30:00Z", "--instance-termination-action", "STOP", "--clear-max-run-duration", "--zone", "europe-west1-b", "--project", "p", "--quiet"]+            (processArgs (prepare Compute.computeCommand (Compute.InstancesSetScheduling (limited (Compute.RunUntil (at 1791793800))))))+    , testCase "a duration outside what GCP accepts is refused, by the check and by up, before any gcloud call" $ do+        assertEqual "30s" [] (Compute.runLimitProblems (Compute.MaxRunDuration 30))+        assertEqual "120 days" [] (Compute.runLimitProblems (Compute.MaxRunDuration (120 * 86400)))+        assertEqual "too short" 1 (length (Compute.runLimitProblems (Compute.MaxRunDuration 29)))+        assertEqual "too long" 1 (length (Compute.runLimitProblems (Compute.MaxRunDuration (120 * 86400 + 1))))+        let act = node (limited (Compute.MaxRunDuration 5))+        verdict <- act.extension.check+        assertBool (show verdict) (isFailure verdict)+        r <- try act.extension.up+        case r of+            Left e -> assertBool (show e) ("run limit cannot be declared" `isInfixOf` show (e :: IOError))+            Right () -> assertBool "up went through" False+    , testCase "a describe is read with the seconds as a string or a number, and dates with an offset or Z" $ do+        assertEqual+            "as the API writes it"+            (Right (Compute.LiveRunLimit (Just 3600) Nothing (Just "STOP") (Just (at 1791720000))))+            (Compute.parseLiveRunLimit "{\"lastStartTimestamp\": \"2026-10-11T05:00:00.000-07:00\", \"scheduling\": {\"instanceTerminationAction\": \"STOP\", \"maxRunDuration\": {\"seconds\": \"3600\"}}}\n")+        assertEqual+            "a number, a date"+            (Right (Compute.LiveRunLimit (Just 60) (Just (at 1791793800)) (Just "DELETE") Nothing))+            (Compute.parseLiveRunLimit "{\"scheduling\": {\"instanceTerminationAction\": \"DELETE\", \"maxRunDuration\": {\"seconds\": 60}, \"terminationTime\": \"2026-10-12T08:30:00Z\"}}")+        assertEqual "an instance with no limit" (Right (Compute.LiveRunLimit Nothing Nothing Nothing Nothing)) (Compute.parseLiveRunLimit "{}")+        assertEqual "nulls are absences" (Right (Compute.LiveRunLimit Nothing Nothing Nothing Nothing)) (Compute.parseLiveRunLimit "{\"lastStartTimestamp\": null, \"scheduling\": {\"maxRunDuration\": null}}")+    , testCase "an output that is not that describe, or holds a field that cannot be read, is not read" $ do+        assertBool "empty" (isLeft (Compute.parseLiveRunLimit ""))+        assertBool "an error message" (isLeft (Compute.parseLiveRunLimit "ERROR: (gcloud.compute.instances.describe) not found"))+        assertBool "a list" (isLeft (Compute.parseLiveRunLimit "[]"))+        assertBool "a duration in words" (isLeft (Compute.parseLiveRunLimit "{\"scheduling\": {\"maxRunDuration\": {\"seconds\": \"1h\"}}}"))+        assertBool "a duration that is not an object" (isLeft (Compute.parseLiveRunLimit "{\"scheduling\": {\"maxRunDuration\": \"3600s\"}}"))+        assertBool "a date in words" (isLeft (Compute.parseLiveRunLimit "{\"scheduling\": {\"terminationTime\": \"tomorrow\"}}"))+        assertBool "a start in words" (isLeft (Compute.parseLiveRunLimit "{\"lastStartTimestamp\": \"yesterday\"}"))+    , testCase "a duration's deadline is the instance's last start plus the duration" $ do+        let hour = Compute.MaxRunDuration 3600+            held = liveJson False hour (Just (at 1000000))+        assertEqual "before" (Compute.RunWithin (at 1003600)) (Compute.judgeRunLimit hour (at 1003599) ExitSuccess held)+        assertEqual "at it" (Compute.RunEnding (at 1003600)) (Compute.judgeRunLimit hour (at 1003600) ExitSuccess held)+        assertEqual "the grace over" (Compute.RunExpired (at 1003600)) (Compute.judgeRunLimit hour (at 1003900) ExitSuccess held)+        assertEqual+            "no start to count from"+            (Compute.RunLimitUndetermined "the instance holds the declared run limit and says nothing of its last start")+            (Compute.judgeRunLimit hour (at 1003900) ExitSuccess (liveJson False hour Nothing))+    , testCase "a date's deadline is the declared date, whatever the instance holds once it has passed" $ do+        let date = Compute.RunUntil (at 2000000)+        assertEqual "before, held" (Compute.RunWithin (at 2000000)) (Compute.judgeRunLimit date (at 1999999) ExitSuccess (liveJson True date Nothing))+        assertEqual "passed, held" (Compute.RunExpired (at 2000000)) (Compute.judgeRunLimit date (at 2000300) ExitSuccess (liveJson True date Nothing))+        assertEqual "passed, not held" (Compute.RunExpired (at 2000000)) (Compute.judgeRunLimit date (at 2000300) ExitSuccess "{}")+        assertEqual "just passed" (Compute.RunEnding (at 2000000)) (Compute.judgeRunLimit date (at 2000001) ExitSuccess "{}")+    , testCase "an instance holding another limit, none, or another action is named with the declared one" $ do+        let hour = Compute.MaxRunDuration 3600+            wanted = "3600s after each start, then stopped by GCP"+        assertEqual "none" (Compute.RunLimitDiffers "none" wanted) (Compute.judgeRunLimit hour (at 5) ExitSuccess "{\"lastStartTimestamp\": \"2026-10-11T05:00:00Z\"}")+        assertEqual+            "another duration"+            (Compute.RunLimitDiffers "7200s after each start, then STOP" wanted)+            (Compute.judgeRunLimit hour (at 5) ExitSuccess (liveJson False (Compute.MaxRunDuration 7200) (Just (at 1))))+        assertEqual+            "a date instead"+            (Compute.RunLimitDiffers "until 1970-01-24T03:33:20Z, then STOP" wanted)+            (Compute.judgeRunLimit hour (at 5) ExitSuccess (liveJson False (Compute.RunUntil (at 2000000)) (Just (at 1))))+        assertEqual+            "DELETE is not the declared action"+            (Compute.RunLimitDiffers "3600s after each start, then DELETE" wanted)+            (Compute.judgeRunLimit hour (at 5) ExitSuccess "{\"lastStartTimestamp\": \"2026-10-11T05:00:00Z\", \"scheduling\": {\"instanceTerminationAction\": \"DELETE\", \"maxRunDuration\": {\"seconds\": \"3600\"}}}")+        assertEqual+            "another date, not yet passed"+            (Compute.RunLimitDiffers "until 1970-01-24T03:33:20Z, then STOP" "until 1970-02-04T17:20:00Z, then stopped by GCP")+            (Compute.judgeRunLimit (Compute.RunUntil (at 3000000)) (at 5) ExitSuccess (liveJson True (Compute.RunUntil (at 2000000)) Nothing))+    , testCase "after the deadline a stopped instance is the declared state, and a running one is not" $ do+        let expired = Compute.RunExpired (at 1791793800)+        assertEqual "stopped" Success (Compute.interpretInstanceRun on expired ExitSuccess "TERMINATED")+        assertEqual "what it was without a limit" (Failure "instance is TERMINATED") (Compute.interpretInstanceRun on Compute.RunUnlimited ExitSuccess "TERMINATED")+        assertEqual "running" (Failure "instance is RUNNING past its run limit (deadline 2026-10-12T08:30:00Z)") (Compute.interpretInstanceRun on expired ExitSuccess "RUNNING")+        assertEqual "running, GCP about to stop it" Unknown (Compute.interpretInstanceRun on (Compute.RunEnding (at 1791793800)) ExitSuccess "RUNNING")+        assertEqual "stopping" Unknown (Compute.interpretInstanceRun on expired ExitSuccess "STOPPING")+        assertBool "suspended" (isFailure (Compute.interpretInstanceRun on expired ExitSuccess "SUSPENDED"))+    , testCase "after the deadline up starts nothing, and says why of anything but a stopped instance" $ do+        let expired = Compute.RunExpired (at 1791793800)+        assertEqual "stopped: nothing to do" (Right []) (plan refuse on "TERMINATED" asDeclared expired)+        assertEqual "what it did without a limit" (Right [Compute.StartStep]) (plan refuse on "TERMINATED" asDeclared Compute.RunUnlimited)+        case plan refuse on "RUNNING" asDeclared expired of+            Left t -> do+                assertBool (Text.unpack t) ("2026-10-12T08:30:00Z" `Text.isInfixOf` t)+                assertBool (Text.unpack t) ("does not stop" `Text.isInfixOf` t)+            Right steps -> assertBool (show steps) False+        assertBool "suspended is not resumed" (isLeft (plan refuse on "SUSPENDED" asDeclared expired))+        assertBool "at the deadline, wait" (isLeft (plan refuse on "RUNNING" asDeclared (Compute.RunEnding (at 1791793800))))+    , testCase "a permitted resize past the deadline sets the type and leaves the machine off" $ do+        let expired = Compute.RunExpired (at 1791793800)+            differs = Compute.MachineTypeDiffers "e2-medium" "e2-standard-2"+        assertEqual "stopped" (Right [Compute.SetMachineTypeStep]) (plan mayStop on "TERMINATED" differs expired)+        assertEqual "before the deadline it restarts" (Right [Compute.SetMachineTypeStep, Compute.StartStep]) (plan mayStop on "TERMINATED" differs (Compute.RunWithin (at 1791793800)))+        assertBool "running: not stopped for it either" (isLeft (plan mayStop on "RUNNING" differs expired))+        assertBool "refused as before" (isLeft (plan refuse on "TERMINATED" differs expired))+    , testCase "a differing limit is set on a stopped instance and refused on a running one, naming both" $ do+        let differs = Compute.RunLimitDiffers "none" "3600s after each start, then stopped by GCP"+        assertEqual "stopped" (Right [Compute.SetSchedulingStep, Compute.StartStep]) (plan refuse on "TERMINATED" asDeclared differs)+        assertEqual "declared stopped, stopped" (Right [Compute.SetSchedulingStep]) (plan refuse off "TERMINATED" asDeclared differs)+        assertEqual "declared stopped, running" (Right [Compute.StopStep, Compute.SetSchedulingStep]) (plan refuse off "RUNNING" asDeclared differs)+        case plan refuse on "RUNNING" asDeclared differs of+            Left t -> do+                assertBool (Text.unpack t) ("holds run limit none" `Text.isInfixOf` t)+                assertBool (Text.unpack t) ("declared 3600s after each start" `Text.isInfixOf` t)+            Right steps -> assertBool (show steps) False+        assertBool "suspended is not resumed without its limit" (isLeft (plan refuse on "SUSPENDED" asDeclared differs))+        case Compute.interpretInstanceRun on differs ExitSuccess "RUNNING" of+            Failure t -> assertBool (Text.unpack t) ("holds run limit none" `Text.isInfixOf` t)+            other -> assertBool (show other) False+        assertBool "stopped is a failure too: up has the limit to set" (isFailure (Compute.interpretInstanceRun on differs ExitSuccess "TERMINATED"))+    , testCase "a resize that stops the machine anyway sets a differing limit while it is stopped" $ do+        let differs = Compute.RunLimitDiffers "none" "3600s after each start, then stopped by GCP"+            retyped = Compute.MachineTypeDiffers "e2-medium" "e2-standard-2"+        assertEqual+            "running"+            (Right [Compute.StopStep, Compute.SetSchedulingStep, Compute.SetMachineTypeStep, Compute.StartStep])+            (plan mayStop on "RUNNING" retyped differs)+        assertEqual+            "stopped"+            (Right [Compute.SetSchedulingStep, Compute.SetMachineTypeStep, Compute.StartStep])+            (plan mayStop on "TERMINATED" retyped differs)+        assertBool "without the permission nothing is set" (isLeft (plan refuse on "TERMINATED" retyped differs))+    , testCase "an absent instance is created, unless its declared date has passed" $ do+        assertEqual "no limit" (Right [Compute.CreateStep]) (Compute.planAbsentInstance Compute.NoRunLimit on (at 9))+        assertEqual "a duration, whenever" (Right [Compute.CreateStep, Compute.StopStep]) (Compute.planAbsentInstance (Compute.MaxRunDuration 60) off (at 9))+        assertEqual "a date ahead" (Right [Compute.CreateStep]) (Compute.planAbsentInstance (Compute.RunUntil (at 10)) on (at 9))+        assertBool "a date passed" (isLeft (Compute.planAbsentInstance (Compute.RunUntil (at 10)) on (at 10)))+    , testProperty "without a limit the verdict is today's for every status, exit code and time, and nothing is read" $+        forAll genPower $ \power ->+            forAll genStatus $ \status ->+                forAll genCode $ \code ->+                    forAll genTime $ \now ->+                        \junk ->+                            Compute.judgeRunLimit Compute.NoRunLimit now code (Text.pack junk) === Compute.RunUnlimited+                                .&&. Compute.interpretInstanceRun power Compute.RunUnlimited code status === Compute.interpretInstanceStatus power code status+    , testProperty "before the deadline, with the limit in place, the verdict is today's for every status" $+        forAll genHeld $ \(limit, started, deadline, describe) ->+            forAll (genBefore deadline) $ \now ->+                forAll genPower $ \power ->+                    forAll genStatus $ \status ->+                        let run = Compute.judgeRunLimit limit now ExitSuccess describe+                         in counterexample (show (started, run)) $+                                run === Compute.RunWithin deadline+                                    .&&. Compute.interpretInstanceRun power run ExitSuccess status === Compute.interpretInstanceStatus power ExitSuccess status+    , testProperty "before the deadline, with the limit in place, up does what it does without a limit" $+        forAll genTime $ \deadline ->+            forAll genChange $ \change ->+                forAll genPower $ \power ->+                    forAll genStatus $ \status ->+                        forAll genTypeVerdict $ \verdict ->+                            plan change power status verdict (Compute.RunWithin deadline) === plan change power status verdict Compute.RunUnlimited+    , testProperty "the verdict moves from within to ending to expired as time passes, at the deadline and at the grace" $+        forAll genHeld $ \(limit, _, deadline, describe) ->+            forAll genTime $ \now ->+                Compute.judgeRunLimit limit now ExitSuccess describe+                    === if now < deadline+                        then Compute.RunWithin deadline+                        else+                            if now < addUTCTime Compute.runLimitGrace deadline+                                then Compute.RunEnding deadline+                                else Compute.RunExpired deadline+    , testProperty "after the deadline a declared-running instance that is stopped passes, and one that runs never does" $+        forAll genPassed $ \run ->+            Compute.interpretInstanceRun on run ExitSuccess "TERMINATED" === Success+                .&&. case (run, Compute.interpretInstanceRun on run ExitSuccess "RUNNING") of+                    (Compute.RunEnding _, Unknown) -> property True+                    (Compute.RunExpired deadline, Failure t) -> counterexample (Text.unpack t) (Compute.renderDeadline deadline `Text.isInfixOf` t)+                    (_, other) -> counterexample (show other) False+    , testProperty "after the deadline only TERMINATED and RUNNING are judged differently from today" $+        forAll genPassed $ \run ->+            forAll genStatus $ \status ->+                status `notElem` ["TERMINATED", "RUNNING"] ==>+                    Compute.interpretInstanceRun on run ExitSuccess status === Compute.interpretInstanceStatus on ExitSuccess status+    , testProperty "after the deadline up never creates, starts, resumes or stops a declared-running instance" $+        forAll genPassed $ \run ->+            forAll genChange $ \change ->+                forAll genStatus $ \status ->+                    forAll genTypeVerdict $ \verdict ->+                        case plan change on status verdict run of+                            Left _ -> property True+                            Right steps ->+                                counterexample (show steps) $+                                    status === "TERMINATED"+                                        .&&. all (`elem` [Compute.SetMachineTypeStep]) steps+                                        .&&. fmap (filter (/= Compute.StartStep)) (plan change on status verdict Compute.RunUnlimited) === Right steps+    , testProperty "after the deadline a stopped instance is never an error for up unless its machine type is" $+        forAll genPassed $ \run ->+            forAll genChange $ \change ->+                forAll genTypeVerdict $ \verdict ->+                    isLeft (plan change on "TERMINATED" verdict run) === isLeft (plan change on "TERMINATED" verdict Compute.RunUnlimited)+    , testProperty "a deadline changes nothing for an instance declared stopped" $+        forAll genPassed $ \run ->+            forAll genChange $ \change ->+                forAll genStatus $ \status ->+                    forAll genTypeVerdict $ \verdict ->+                        plan change off status verdict run === plan change off status verdict Compute.RunUnlimited+                            .&&. Compute.interpretInstanceRun off run ExitSuccess status === Compute.interpretInstanceStatus off ExitSuccess status+    , testProperty "a limit that could not be read never passes the check and plans no call" $+        forAll (oneof [Compute.MaxRunDuration <$> genDuration, Compute.RunUntil <$> genTime]) $ \limit ->+            forAll genTime $ \now ->+                forAll genChange $ \change ->+                    forAll genPower $ \power ->+                        forAll genStatus $ \status ->+                            forAll genTypeVerdict $ \verdict ->+                                \junk ->+                                    let failed = Compute.judgeRunLimit limit now (ExitFailure 1) (Text.pack junk)+                                        unread = Compute.RunLimitUndetermined "unread"+                                     in conjoin+                                            [ counterexample (show failed) (undetermined failed)+                                            , property (isFailure (Compute.interpretInstanceRun power unread ExitSuccess status))+                                            , property (isLeft (plan change power status verdict unread))+                                            ]+    , testProperty "whatever a describe printed, it is read as that describe or as nothing known" $+        forAll genDuration $ \duration ->+            forAll genTime $ \now ->+                \junk ->+                    let out = Text.pack junk+                     in case (Compute.parseLiveRunLimit out, Compute.judgeRunLimit (Compute.MaxRunDuration duration) now ExitSuccess out) of+                            (Left why, run) -> run === Compute.RunLimitUndetermined why+                            -- junk that happens to be a JSON object holds no limit+                            (Right live, run) -> counterexample (show (live, run)) (not (within run))+    , testProperty "a rendered describe is read back, whichever way its dates and seconds are written" $+        forAll genLimit $ \limit ->+            forAll (oneof [pure Nothing, Just <$> genTime]) $ \started ->+                forAll arbitrary $ \offsets ->+                    Compute.parseLiveRunLimit (liveJson offsets limit started)+                        === Right+                            ( Compute.LiveRunLimit+                                (case limit of Compute.MaxRunDuration d -> Just d; _ -> Nothing)+                                (case limit of Compute.RunUntil t -> Just t; _ -> Nothing)+                                (case limit of Compute.NoRunLimit -> Nothing; _ -> Just "STOP")+                                started+                            )+    , testProperty "an instance holding another limit than the declared one is never within it, and both are named" $+        forAll genLimit $ \declared ->+            forAll genLimit $ \held ->+                forAll genTime $ \started ->+                    forAll genTime $ \now ->+                        declared /= held && declared /= Compute.NoRunLimit ==>+                            case Compute.judgeRunLimit declared now ExitSuccess (liveJson False held (Just started)) of+                                Compute.RunLimitDiffers _ wanted -> wanted === Compute.renderRunLimit declared+                                -- a declared date that has passed is a deadline whatever is held+                                Compute.RunEnding deadline -> declared === Compute.RunUntil deadline+                                Compute.RunExpired deadline -> declared === Compute.RunUntil deadline+                                other -> counterexample (show other) False+    , testProperty "a differing limit is set once, on a stopped machine, and the plan ends in the declared power state" $+        forAll genChange $ \change ->+            forAll genPower $ \power ->+                forAll (elements ["RUNNING", "TERMINATED"]) $ \status ->+                    forAll genTypeVerdict $ \verdict ->+                        case plan change power status verdict genuinelyDiffers of+                            Left _ -> property True+                            Right steps ->+                                counterexample (show steps) $+                                    length (filter (== Compute.SetSchedulingStep) steps) === 1+                                        .&&. simulate (status == "RUNNING") steps === Just (power == on)+    , testProperty "a machine is never stopped for its limit: a stop is planned exactly when it was without one" $+        forAll genChange $ \change ->+            forAll genPower $ \power ->+                forAll genStatus $ \status ->+                    forAll genTypeVerdict $ \verdict ->+                        forAll genRun $ \run ->+                            case plan change power status verdict run of+                                Right steps+                                    | Compute.StopStep `elem` steps ->+                                        counterexample (show steps) (fmap (Compute.StopStep `elem`) (plan change power status verdict Compute.RunUnlimited) === Right True)+                                _ -> property True+    , testProperty "a differing limit only adds set-scheduling to what up did, or refuses" $+        forAll genChange $ \change ->+            forAll genPower $ \power ->+                forAll genStatus $ \status ->+                    forAll genTypeVerdict $ \verdict ->+                        case plan change power status verdict genuinelyDiffers of+                            Left _ -> property True+                            Right steps -> Right (filter (/= Compute.SetSchedulingStep) steps) === plan change power status verdict Compute.RunUnlimited+    , testProperty "a running instance declared running is never given a differing limit without a resize stopping it" $+        forAll genChange $ \change ->+            forAll genTypeVerdict $ \verdict ->+                case (plan change on "RUNNING" verdict genuinelyDiffers, verdict, change) of+                    (Right _, Compute.MachineTypeDiffers _ _, Compute.MayStopToResize) -> property True+                    (Right steps, _, _) -> counterexample (show steps) False+                    (Left _, _, _) -> property True+    , testProperty "an absent instance is created as before unless a declared date has passed" $+        forAll genLimit $ \limit ->+            forAll genPower $ \power ->+                forAll genTime $ \now ->+                    case (limit, Compute.planAbsentInstance limit power now) of+                        (Compute.RunUntil date, Left t) -> counterexample (Text.unpack t) (now >= date)+                        (Compute.RunUntil date, Right steps) -> counterexample (show steps) (now < date .&&. Right steps === Compute.instanceUpSteps power Compute.CreateInstance)+                        (_, steps) -> steps === Compute.instanceUpSteps power Compute.CreateInstance+    , testProperty "a limit only appends its flags to create and its note to the notes; no other command, ref or help changes" $+        forAll genLimit $ \limit ->+            forAll genPower $ \power ->+                forAll genChange $ \change ->+                    let bare = inst{Compute.instancePower = power, Compute.instanceMachineTypeChange = change}+                        with = bare{Compute.instanceRunLimit = limit}+                        args c = processArgs (prepare Compute.computeCommand c)+                     in conjoin+                            [ args (Compute.InstancesCreate with) === init (args (Compute.InstancesCreate bare)) <> Compute.runLimitArgs limit <> ["--quiet"]+                            , args (Compute.InstancesStop with) === args (Compute.InstancesStop bare)+                            , args (Compute.InstancesStart with) === args (Compute.InstancesStart bare)+                            , args (Compute.InstancesResume with) === args (Compute.InstancesResume bare)+                            , args (Compute.InstancesDelete with) === args (Compute.InstancesDelete bare)+                            , args (Compute.InstancesDescribeStatus with) === args (Compute.InstancesDescribeStatus bare)+                            , args (Compute.InstancesDescribeMachineType with) === args (Compute.InstancesDescribeMachineType bare)+                            , args (Compute.InstancesSetMachineType with) === args (Compute.InstancesSetMachineType bare)+                            , (node with).extension.ref === (node bare).extension.ref+                            , (node with).extension.help === (node bare).extension.help+                            , (node with).extension.notes+                                === (node bare).extension.notes <> ["run limit: " <> Compute.renderRunLimit limit | limit /= Compute.NoRunLimit]+                            ]+    , testProperty "the flags of a limit are its kind, its value and the STOP action, and none without a limit" $+        forAll genLimit $ \limit ->+            Compute.runLimitArgs limit === case limit of+                Compute.NoRunLimit -> []+                Compute.MaxRunDuration d -> ["--max-run-duration", show d <> "s", "--instance-termination-action", "STOP"]+                Compute.RunUntil t -> ["--termination-time", Text.unpack (Compute.renderDeadline t), "--instance-termination-action", "STOP"]+    , testProperty "two declared limits have the same note only when they are the same limit" $+        forAll genLimit $ \a ->+            forAll genLimit $ \b ->+                (Compute.renderRunLimit a == Compute.renderRunLimit b) === (a == b)+    ]+  where+    on = Compute.PoweredOn+    off = Compute.PoweredOff+    refuse = Compute.RefuseMachineTypeChange+    mayStop = Compute.MayStopToResize+    asDeclared = Compute.MachineTypeAsDeclared+    plan = Compute.planExistingInstance+    genuinelyDiffers = Compute.RunLimitDiffers "none" "3600s after each start, then stopped by GCP"+    isLeft :: Either a b -> Bool+    isLeft = either (const True) (const False)+    undetermined run = case run of+        Compute.RunLimitUndetermined _ -> True+        _ -> False+    within run = case run of+        Compute.RunWithin _ -> True+        _ -> False+    at :: Integer -> UTCTime+    at = posixSecondsToUTCTime . fromInteger+    -- whole seconds: a date is rendered and compared to the second+    genTime :: Gen UTCTime+    genTime = at <$> choose (1600000000, 2000000000)+    genBefore :: UTCTime -> Gen UTCTime+    genBefore deadline = do+        early <- choose (1, 200 * 86400 :: Integer)+        pure (addUTCTime (negate (fromInteger early)) deadline)+    genDuration :: Gen Integer+    genDuration = oneof [elements [30, 60, 3600, 86400, 120 * 86400], choose (30, 120 * 86400)]+    genLimit :: Gen Compute.RunLimit+    genLimit = oneof [pure Compute.NoRunLimit, Compute.MaxRunDuration <$> genDuration, Compute.RunUntil <$> genTime]+    -- a declared limit, the last start of an instance holding it, the+    -- deadline that makes, and what that instance describes itself as+    genHeld :: Gen (Compute.RunLimit, Maybe UTCTime, UTCTime, Text.Text)+    genHeld = do+        offsets <- arbitrary+        started <- genTime+        oneof+            [ do+                duration <- genDuration+                let limit = Compute.MaxRunDuration duration+                pure (limit, Just started, addUTCTime (fromInteger duration) started, liveJson offsets limit (Just started))+            , do+                date <- genTime+                let limit = Compute.RunUntil date+                pure (limit, Just started, date, liveJson offsets limit (Just started))+            ]+    genPassed :: Gen Compute.RunLimitVerdict+    genPassed = oneof [Compute.RunEnding <$> genTime, Compute.RunExpired <$> genTime]+    genRun :: Gen Compute.RunLimitVerdict+    genRun =+        oneof+            [ pure Compute.RunUnlimited+            , Compute.RunWithin <$> genTime+            , genPassed+            , pure genuinelyDiffers+            , pure (Compute.RunLimitUndetermined "unread")+            ]+    genCode = elements [ExitSuccess, ExitFailure 1]+    genChange = elements [refuse, mayStop]+    genPower = elements [on, off]+    genStatus :: Gen Text.Text+    genStatus =+        oneof+            [ elements ["RUNNING", "TERMINATED", "SUSPENDED", "PROVISIONING", "STAGING", "STOPPING", "SUSPENDING", "REPAIRING", ""]+            , Text.pack <$> arbitrary+            ]+    genTypeVerdict :: Gen Compute.MachineTypeVerdict+    genTypeVerdict =+        elements+            [ Compute.MachineTypeAsDeclared+            , Compute.MachineTypeDiffers "e2-medium" "e2-standard-2"+            , Compute.MachineTypeUndetermined "unread"+            ]+    -- what an instance holding this limit describes itself as; the dates+    -- either as GCP writes them (milliseconds, an offset) or in UTC+    liveJson :: Bool -> Compute.RunLimit -> Maybe UTCTime -> Text.Text+    liveJson offsets limit started =+        Text.decodeUtf8 . LByteString.toStrict . encode . object $+            ["lastStartTimestamp" .= stamp t | Just t <- [started]]+                <> case limit of+                    Compute.NoRunLimit -> []+                    Compute.MaxRunDuration d ->+                        ["scheduling" .= object ["instanceTerminationAction" .= ("STOP" :: Text.Text), "maxRunDuration" .= object ["seconds" .= show d]]]+                    Compute.RunUntil t ->+                        ["scheduling" .= object ["instanceTerminationAction" .= ("STOP" :: Text.Text), "terminationTime" .= stamp t]]+      where+        stamp :: UTCTime -> String+        stamp t+            | offsets = formatTime defaultTimeLocale "%Y-%m-%dT%H:%M:%S.000%Ez" (utcToZonedTime (hoursToTimeZone (-7)) t)+            | otherwise = formatTime defaultTimeLocale "%Y-%m-%dT%H:%M:%SZ" t+    -- a machine that is running or not: a step that GCP would refuse (the+    -- type or the scheduling set on a running machine, a stop of a stopped+    -- one) is 'Nothing'+    simulate :: Bool -> [Compute.InstanceStep] -> Maybe Bool+    simulate running [] = Just running+    simulate running (step : rest) = case (step, running) of+        (Compute.StopStep, True) -> simulate False rest+        (Compute.StartStep, False) -> simulate True rest+        (Compute.SetMachineTypeStep, False) -> simulate False rest+        (Compute.SetSchedulingStep, False) -> simulate False rest+        _ -> Nothing+    createArgs i = processArgs (prepare Compute.computeCommand (Compute.InstancesCreate i))+    limited limit = inst{Compute.instanceRunLimit = limit}+    node i = only (Compute.gceInstance silent ignoreTrack i)+    only o = case Map.elems (Dag.dagNodes (Dag.foldDag Dag.sameRepresentative (evalDeps o))) of+        [act] -> act+        acts -> error ("expected one node, got " <> show (length acts))+    inst =+        Compute.Instance+            { Compute.instanceName = "toy-vm"+            , Compute.instanceProject = Core.Project "p"+            , Compute.instanceZone = Core.Zone "europe-west1-b"+            , Compute.instanceMachineType = Compute.Custom "e2-micro"+            , Compute.instanceBootDisk = Compute.BootDisk 10 Nothing (Just "ubuntu-2404-lts-amd64") (Just "ubuntu-os-cloud")+            , Compute.instanceNetwork = "default"+            , Compute.instanceSubnet = "default"+            , Compute.instanceServiceAccount = Nothing+            , Compute.instanceMetadata = Map.fromList [("enable-oslogin", "FALSE")]+            , Compute.instanceMetadataFiles = Map.fromList [("startup-script", "/tmp/w/startup-script.sh")]+            , Compute.instanceExternalAddress = Compute.ReservedExternal "toy-ip"+            , Compute.instanceInternalAddress = Compute.EphemeralInternal+            , Compute.instanceTags = ["toy-ssh"]+            , Compute.instancePower = Compute.PoweredOn+            , Compute.instanceScopes = Compute.DefaultScopes+            , Compute.instanceMachineTypeChange = Compute.RefuseMachineTypeChange+            , Compute.instanceRunLimit = Compute.NoRunLimit+            }++bucketTests :: [TestTree]+bucketTests =+    [ testCase "describe succeeding means the bucket exists" $+        assertEqual "" Success (Storage.interpretBucketDescribe "my-bucket" ExitSuccess)+    , testCase "describe failing means the bucket is absent" $+        assertBool "" (isFailure (Storage.interpretBucketDescribe "my-bucket" (ExitFailure 1)))+    ]++-- | The bucket's settings nodes: access, lifecycle, website. The JSON here is+-- written from the storage API's resource shape, not captured from gcloud.+bucketSettingsTests :: [TestTree]+bucketSettingsTests =+    [ testGroup+        "nodes"+        [ testCase "the bucket node is described as it was" $ do+            let act = only (Storage.bucket silent ignoreTrack bkt)+            assertEqual "" (mkRef "gcp-bucket" ("site-bucket" :: Text.Text)) act.extension.ref+            assertEqual "" "creates GCS bucket site-bucket" act.extension.help+            assertEqual "" [] act.extension.notes+        , testCase "each setting is its own effect site" $ do+            let refsOf = map (\a -> a.extension.ref)+                acts =+                    [ only (Storage.bucket silent ignoreTrack bkt)+                    , only (Storage.bucketIamBinding silent ignoreTrack public)+                    , only (Storage.bucketIamBinding silent ignoreTrack writer)+                    , only (Storage.bucketLifecycle silent ignoreTrack expiry)+                    , only (Storage.bucketWebsite silent ignoreTrack site)+                    ]+            assertEqual "" (length acts) (length (nub (refsOf acts)))+        , testCase "a binding is keyed on bucket, role and member" $+            assertEqual+                ""+                (mkRef "gcp-bucket-iam-binding" ("site-bucket" :: Text.Text, "roles/storage.legacyObjectReader" :: Text.Text, "allUsers" :: Text.Text))+                (only (Storage.bucketIamBinding silent ignoreTrack public)).extension.ref+        , testCase "lifecycle and website are keyed on the bucket alone, what is declared riding in the notes" $ do+            let lc rules = only (Storage.bucketLifecycle silent ignoreTrack expiry{Storage.lifecycleRules = rules})+                ws w = only (Storage.bucketWebsite silent ignoreTrack w)+            assertEqual "" (lc []).extension.ref (lc [Storage.expireAfterDays 7]).extension.ref+            assertBool "" ((lc []).extension.notes /= (lc [Storage.expireAfterDays 7]).extension.notes)+            assertEqual "" (ws site).extension.ref (ws site{Storage.websiteNotFoundPage = Nothing}).extension.ref+            assertBool "" ((ws site).extension.notes /= (ws site{Storage.websiteNotFoundPage = Nothing}).extension.notes)+        , testCase "up refuses a declaration with a problem before anything is run" $ do+            r <- try ((only (Storage.bucketLifecycle silent ignoreTrack expiry{Storage.lifecycleRules = [unconditional]})).extension.up)+            case r of+                Left (e :: IOError) -> assertBool (show e) ("no condition" `isInfixOf` show e)+                Right () -> assertBool "expected a refusal" False+        ]+    , testGroup+        "access"+        [ testCase "members are spelled as IAM spells them" $+            assertEqual+                ""+                ["allUsers", "allAuthenticatedUsers", "serviceAccount:sa@p.iam.gserviceaccount.com", "user:a@example.org", "group:g@example.org", "domain:example.org"]+                ( map+                    Storage.renderMember+                    [ Storage.AllUsers+                    , Storage.AllAuthenticatedUsers+                    , Storage.ServiceAccountMember "sa@p.iam.gserviceaccount.com"+                    , Storage.UserMember "a@example.org"+                    , Storage.GroupMember "g@example.org"+                    , Storage.OtherMember "domain:example.org"+                    ]+                )+        , testCase "the policy is read as JSON" $+            assertEqual+                ""+                ["storage", "buckets", "get-iam-policy", "gs://site-bucket", "--format", "json", "--project", "my-project", "--quiet"]+                (args (Storage.BucketsGetIamPolicy bkt))+        , testCase "add names the bucket, member and role" $+            assertEqual+                ""+                ["storage", "buckets", "add-iam-policy-binding", "gs://site-bucket", "--member", "allUsers", "--role", "roles/storage.legacyObjectReader", "--project", "my-project", "--quiet"]+                (args (Storage.BucketsAddIamBinding public))+        , testCase "remove differs from add by its verb only" $+            assertEqual+                ""+                (map (\w -> if w == "add-iam-policy-binding" then "remove-iam-policy-binding" else w) (args (Storage.BucketsAddIamBinding writer)))+                (args (Storage.BucketsRemoveIamBinding writer))+        , testCase "the member under the role is satisfied" $ do+            assertEqual "" Success (Storage.interpretBucketPolicy public ExitSuccess policy)+            assertEqual "" Success (Storage.interpretBucketPolicy writer ExitSuccess policy)+        , testCase "the member under another role only is a failure naming role and member" $+            case Storage.interpretBucketPolicy public{Storage.bindingRole = "roles/storage.objectViewer"} ExitSuccess policy of+                Failure why -> assertBool (Text.unpack why) ("roles/storage.objectViewer" `Text.isInfixOf` why && "allUsers" `Text.isInfixOf` why)+                other -> assertBool ("expected a Failure, got " <> show other) False+        , testCase "a member that merely contains the declared one is not it" $+            assertBool "" (isFailure (Storage.interpretBucketPolicy writer{Storage.bindingMember = Storage.ServiceAccountMember "p.iam.gserviceaccount.com"} ExitSuccess policy))+        , testCase "a conditional binding is not the declared one" $+            assertBool "" (isFailure (Storage.interpretBucketPolicy conditional ExitSuccess policy))+        , testCase "a policy with no bindings grants nothing" $+            assertBool "" (isFailure (Storage.interpretBucketPolicy public ExitSuccess "{\"etag\": \"CAE=\"}"))+        , testCase "the policy not being readable is a failure, and not being a policy cannot be judged" $ do+            assertBool "" (isFailure (Storage.interpretBucketPolicy public (ExitFailure 1) ""))+            assertEqual "" Unknown (Storage.interpretBucketPolicy public ExitSuccess "bindings:\n- role: x\n")+        , testCase "a binding with no role or no member is refused" $ do+            assertBool "" (not (null (Storage.bindingProblems public{Storage.bindingRole = " "})))+            assertBool "" (not (null (Storage.bindingProblems public{Storage.bindingMember = Storage.ServiceAccountMember ""})))+            assertBool "" (isFailure (Storage.interpretBucketPolicy public{Storage.bindingRole = ""} ExitSuccess policy))+            assertEqual "" [] (Storage.bindingProblems public)+        ]+    , testGroup+        "lifecycle"+        [ testCase "the rules render as the API's lifecycle document" $+            assertEqual+                ""+                "{\"rule\":[{\"action\":{\"type\":\"Delete\"},\"condition\":{\"age\":30}},{\"action\":{\"storageClass\":\"NEARLINE\",\"type\":\"SetStorageClass\"},\"condition\":{\"age\":7,\"matchesPrefix\":[\"daily/\"]}}]}"+                (Storage.renderLifecycle [Storage.expireAfterDays 30, tiering])+        , testCase "no rule renders as an empty list" $+            assertEqual "" "{\"rule\":[]}" (Storage.renderLifecycle [])+        , testCase "set names the bucket and the file, clear the bucket alone" $ do+            assertEqual+                ""+                ["storage", "buckets", "update", "gs://site-bucket", "--lifecycle-file", "/tmp/rules.json", "--project", "my-project", "--quiet"]+                (args (Storage.BucketsSetLifecycle bkt "/tmp/rules.json"))+            assertEqual+                ""+                ["storage", "buckets", "update", "gs://site-bucket", "--clear-lifecycle", "--project", "my-project", "--quiet"]+                (args (Storage.BucketsClearLifecycle bkt))+        , testCase "the bucket is described raw, as JSON" $+            assertEqual+                ""+                ["storage", "buckets", "describe", "gs://site-bucket", "--raw", "--format", "json", "--project", "my-project", "--quiet"]+                (args (Storage.BucketsDescribeJson bkt))+        , testCase "the declared rules in any order are satisfied" $+            assertEqual "" Success (lifecycleVerdict [tiering, Storage.expireAfterDays 30] (describedWith "lifecycle" twoRules))+        , testCase "gcloud's own name for the configuration is read too" $+            assertEqual "" Success (lifecycleVerdict [Storage.expireAfterDays 30, tiering] (describedWith "lifecycle_config" twoRules))+        , testCase "null members and empty lists in a live rule are not a difference" $+            assertEqual+                ""+                Success+                ( lifecycleVerdict+                    [Storage.expireAfterDays 30]+                    (describedWith "lifecycle" "{\"rule\": [{\"action\": {\"type\": \"Delete\", \"storageClass\": null}, \"condition\": {\"age\": 30, \"matchesPrefix\": []}}]}")+                )+        , testCase "another age is a failure" $+            assertBool "" (isFailure (lifecycleVerdict [Storage.expireAfterDays 31, tiering] (describedWith "lifecycle" twoRules)))+        , testCase "a rule too many, or too few, is a failure" $ do+            assertBool "" (isFailure (lifecycleVerdict [Storage.expireAfterDays 30] (describedWith "lifecycle" twoRules)))+            assertBool "" (isFailure (lifecycleVerdict [Storage.expireAfterDays 30] "{\"name\": \"site-bucket\"}"))+        , testCase "a live condition this module has no field for is a difference" $+            assertBool+                ""+                ( isFailure+                    ( lifecycleVerdict+                        [Storage.expireAfterDays 30]+                        (describedWith "lifecycle" "{\"rule\": [{\"action\": {\"type\": \"Delete\"}, \"condition\": {\"age\": 30, \"daysSinceCustomTime\": 2}}]}")+                    )+                )+        , testCase "no rule declared is satisfied by a bucket with none, however it says so" $ do+            assertEqual "" Success (lifecycleVerdict [] "{\"name\": \"site-bucket\"}")+            assertEqual "" Success (lifecycleVerdict [] (describedWith "lifecycle" "{\"rule\": []}"))+            assertEqual "" Success (lifecycleVerdict [] (describedWith "lifecycle" "null"))+            assertBool "" (isFailure (lifecycleVerdict [] (describedWith "lifecycle" twoRules)))+        , testCase "describe failing means the bucket is absent, and output that is not JSON cannot be judged" $ do+            assertBool "" (isFailure (Storage.interpretLifecycleDescribe expiry (ExitFailure 1) ""))+            assertEqual "" Unknown (Storage.interpretLifecycleDescribe expiry ExitSuccess "name: site-bucket\n")+        , testCase "a rule with no condition, a negative count or no storage class is refused" $ do+            let problems rules = Storage.lifecycleProblems expiry{Storage.lifecycleRules = rules}+            assertEqual "" [] (problems [Storage.expireAfterDays 30, tiering])+            assertBool "" (any ("rule 2 has no condition" `Text.isPrefixOf`) (problems [Storage.expireAfterDays 30, unconditional]))+            assertBool "" (not (null (problems [Storage.expireAfterDays (-1)])))+            assertBool "" (not (null (problems [tiering{Storage.ruleAction = Storage.SetStorageClass ""}])))+            assertBool "" (isFailure (lifecycleVerdict [unconditional] (describedWith "lifecycle" twoRules)))+        ]+    , testGroup+        "website"+        [ testCase "both settings are set" $+            assertEqual+                ""+                ["storage", "buckets", "update", "gs://site-bucket", "--web-main-page-suffix", "index.html", "--web-error-page", "404.html", "--project", "my-project", "--quiet"]+                (args (Storage.BucketsSetWebsite site))+        , testCase "a setting not declared is cleared" $ do+            assertEqual+                ""+                ["storage", "buckets", "update", "gs://site-bucket", "--web-main-page-suffix", "index.html", "--clear-web-error-page", "--project", "my-project", "--quiet"]+                (args (Storage.BucketsSetWebsite site{Storage.websiteNotFoundPage = Nothing}))+            assertEqual+                ""+                ["storage", "buckets", "update", "gs://site-bucket", "--clear-web-main-page-suffix", "--clear-web-error-page", "--project", "my-project", "--quiet"]+                (args (Storage.BucketsSetWebsite (Storage.BucketWebsite bkt Nothing (Just " "))))+        , testCase "the declared settings are satisfied, under either name" $ do+            assertEqual "" Success (websiteVerdict site (describedWith "website" bothPages))+            assertEqual "" Success (websiteVerdict site (describedWith "website_config" bothPages))+        , testCase "another page is a failure naming both" $+            case websiteVerdict site (describedWith "website" "{\"mainPageSuffix\": \"index.html\", \"notFoundPage\": \"missing.html\"}") of+                Failure why -> do+                    assertBool (Text.unpack why) ("missing.html" `Text.isInfixOf` why && "404.html" `Text.isInfixOf` why)+                    assertBool (Text.unpack why) (not ("main page" `Text.isInfixOf` why))+                other -> assertBool ("expected a Failure, got " <> show other) False+        , testCase "a bucket with no website settings does not have the declared ones" $+            assertBool "" (isFailure (websiteVerdict site "{\"name\": \"site-bucket\"}"))+        , testCase "a setting left on a bucket declared without it is a failure" $+            assertBool "" (isFailure (websiteVerdict site{Storage.websiteNotFoundPage = Nothing} (describedWith "website" bothPages)))+        , testCase "nothing declared is satisfied by a bucket with nothing set" $+            assertEqual "" Success (websiteVerdict (Storage.BucketWebsite bkt Nothing Nothing) "{\"name\": \"site-bucket\"}")+        , testCase "describe failing means the bucket is absent, and output that is not JSON cannot be judged" $ do+            assertBool "" (isFailure (Storage.interpretWebsiteDescribe site (ExitFailure 1) ""))+            assertEqual "" Unknown (websiteVerdict site "name: site-bucket\n")+        , testCase "a main page suffix with a slash is refused" $ do+            assertEqual "" [] (Storage.websiteProblems site)+            assertBool "" (not (null (Storage.websiteProblems site{Storage.websiteMainPageSuffix = Just "pages/index.html"})))+            assertBool "" (isFailure (websiteVerdict site{Storage.websiteMainPageSuffix = Just "pages/index.html"} (describedWith "website" bothPages)))+        ]+    ]+  where+    args = processArgs . prepare Storage.storageCommand+    only o = case Map.elems (Dag.dagNodes (Dag.foldDag Dag.sameRepresentative (evalDeps o))) of+        [act] -> act+        acts -> error ("expected one node, got " <> show (length acts))+    bkt = Storage.Bucket "site-bucket" (Core.Project "my-project") (Core.Region "europe-west1") True+    public = Storage.BucketIamBinding bkt "roles/storage.legacyObjectReader" Storage.AllUsers+    writer = Storage.BucketIamBinding bkt "roles/storage.objectCreator" (Storage.ServiceAccountMember "backup@my-project.iam.gserviceaccount.com")+    conditional = Storage.BucketIamBinding bkt "roles/storage.objectAdmin" (Storage.UserMember "temp@example.org")+    policy =+        "{\"bindings\": [\+        \{\"members\": [\"projectOwner:my-project\"], \"role\": \"roles/storage.legacyBucketOwner\"},\+        \{\"members\": [\"allUsers\"], \"role\": \"roles/storage.legacyObjectReader\"},\+        \{\"members\": [\"user:someone@example.org\", \"serviceAccount:Backup@my-project.iam.gserviceaccount.com\"], \"role\": \"roles/storage.objectCreator\"},\+        \{\"members\": [\"user:temp@example.org\"], \"role\": \"roles/storage.objectAdmin\", \"condition\": {\"title\": \"until\", \"expression\": \"request.time < timestamp('2030-01-01T00:00:00Z')\"}}\+        \], \"etag\": \"CAI=\"}"+    expiry = Storage.BucketLifecycle bkt [Storage.expireAfterDays 30]+    tiering =+        Storage.LifecycleRule+            (Storage.SetStorageClass "NEARLINE")+            Storage.noCondition{Storage.conditionAge = Just 7, Storage.conditionMatchesPrefix = ["daily/"]}+    unconditional = Storage.LifecycleRule Storage.Delete Storage.noCondition+    twoRules =+        "{\"rule\": [\+        \{\"action\": {\"type\": \"Delete\"}, \"condition\": {\"age\": 30}},\+        \{\"action\": {\"type\": \"SetStorageClass\", \"storageClass\": \"NEARLINE\"}, \"condition\": {\"age\": 7, \"matchesPrefix\": [\"daily/\"]}}\+        \]}"+    lifecycleVerdict rules = Storage.interpretLifecycleDescribe expiry{Storage.lifecycleRules = rules} ExitSuccess+    site = Storage.BucketWebsite bkt (Just "index.html") (Just "404.html")+    bothPages = "{\"mainPageSuffix\": \"index.html\", \"notFoundPage\": \"404.html\"}"+    websiteVerdict w = Storage.interpretWebsiteDescribe w ExitSuccess+    describedWith member value =+        "{\"kind\": \"storage#bucket\", \"name\": \"site-bucket\", \"location\": \"EUROPE-WEST1\", \"" <> member <> "\": " <> value <> "}"++-------------------------------------------------------------------------------++{- | The node publishing a directory. The dry-run output is what Google Cloud+SDK 573.0.0 wrote on standard error for a real bucket (its name replaced by+@BUCKET@, nothing else edited); standard output was empty and the exit code 0+in every case but the last.+-}+bucketContentsTests :: [TestTree]+bucketContentsTests =+    [ testGroup+        "node"+        [ testCase "it is keyed on the destination, what is declared riding in the notes" $ do+            let act c = only (Storage.bucketContents silent ignoreTrack c)+            assertEqual "" (mkRef "gcp-bucket-contents" ("BUCKET" :: Text.Text, "" :: Text.Text)) (act site).extension.ref+            assertEqual "" "publishes directory site to gs://BUCKET" (act site).extension.help+            assertEqual "" (act site).extension.ref (act site{Storage.contentsSource = "other", Storage.contentsCacheRules = []}).extension.ref+            assertBool "" ((act site).extension.notes /= (act site{Storage.contentsCacheRules = []}).extension.notes)+            assertBool "" ((act site).extension.notes /= (act site{Storage.contentsOnDown = Storage.EmptyDestination}).extension.notes)+            assertBool "" ((act site).extension.ref /= (act site{Storage.contentsPrefix = "docs"}).extension.ref)+            assertEqual "" (act site{Storage.contentsPrefix = "docs"}).extension.ref (act site{Storage.contentsPrefix = "/docs/"}).extension.ref+            assertBool "" ((act site).extension.ref /= (only (Storage.bucket silent ignoreTrack siteBucket)).extension.ref)+        , testCase "the notes say which header each share of the directory gets" $+            assertEqual+                ""+                [ "source: site"+                , "extraneous objects: removed"+                , "on down: objects left"+                , "cache-control *.html: no-cache"+                , "cache-control assets/**: public, max-age=31536000, immutable"+                , "cache-control (other objects): (unset)"+                ]+                (only (Storage.bucketContents silent ignoreTrack site)).extension.notes+        , testCase "declaring the node reads nothing: a directory that does not exist is only a failed check" $+            withSystemTempDirectory "salmon-contents" $ \tmp -> do+                let act = only (Storage.bucketContents silent ignoreTrack site{Storage.contentsSource = tmp </> "absent"})+                verdict <- act.extension.check+                case verdict of+                    Failure why -> assertBool (Text.unpack why) ("does not exist" `Text.isInfixOf` why)+                    other -> assertBool ("expected a Failure, got " <> show other) False+        , testCase "up refuses a missing directory, and an empty one, before anything is run" $+            withSystemTempDirectory "salmon-contents" $ \tmp -> do+                let upOf dir = try ((only (Storage.bucketContents silent ignoreTrack site{Storage.contentsSource = dir})).extension.up)+                missing <- upOf (tmp </> "absent")+                case missing of+                    Left (e :: IOError) -> assertBool (show e) ("does not exist" `isInfixOf` show e)+                    Right () -> assertBool "expected a refusal" False+                empty <- upOf tmp+                case empty of+                    Left (e :: IOError) -> assertBool (show e) ("is empty" `isInfixOf` show e)+                    Right () -> assertBool "expected a refusal" False+                verdict <- (only (Storage.bucketContents silent ignoreTrack site{Storage.contentsSource = tmp})).extension.check+                assertBool (show verdict) (isFailure verdict)+        , testCase "up refuses a declaration with a problem" $ do+            r <- try ((only (Storage.bucketContents silent ignoreTrack site{Storage.contentsPrefix = "a/../b"})).extension.up)+            case r of+                Left (e :: IOError) -> assertBool (show e) ("dotted segment" `isInfixOf` show e)+                Right () -> assertBool "expected a refusal" False+        , testCase "down leaves the objects unless told otherwise" $+            -- nothing is run: there is no gcloud to run here+            (only (Storage.bucketContents silent ignoreTrack site{Storage.contentsSource = "/nonexistent"})).extension.down+        ]+    , testGroup+        "passes"+        [ testCase "with no rule the directory is one unrestricted pass" $+            assertEqual+                ""+                [Storage.RsyncPass "(every object)" Nothing (Just "no-store")]+                (Storage.contentsPasses site{Storage.contentsCacheRules = [], Storage.contentsDefaultCacheControl = Just " no-store "})+        , testCase "one pass per rule, each excluding what is not its own, then one for the rest" $+            assertEqual+                ""+                [ Storage.RsyncPass "*.html" (Just "^(?!(?:(?:.*/)?[^/]*\\.html)$).*$") (Just "no-cache")+                , Storage.RsyncPass+                    "assets/**"+                    (Just "^(?!(?=(?:assets/.*)$)(?!(?:(?:(?:.*/)?[^/]*\\.html))$)).*$")+                    (Just "public, max-age=31536000, immutable")+                , Storage.RsyncPass "(other objects)" (Just "^(?:(?:(?:.*/)?[^/]*\\.html)|(?:assets/.*))$") Nothing+                ]+                (Storage.contentsPasses site)+        , testCase "a glob is a regex over the relative path" $ do+            assertEqual "" "(?:(?:.*/)?[^/]*\\.html)" (Storage.globRegex "*.html")+            assertEqual "" "(?:(?:.*/)?[^/]*\\.html)" (Storage.globRegex " *.html ")+            assertEqual "" "(?:index\\.html)" (Storage.globRegex "/index.html")+            assertEqual "" "(?:assets/.*)" (Storage.globRegex "assets/**")+            assertEqual "" "(?:(?:.*/)?img/[^/]\\.png)" (Storage.globRegex "**/img/?.png")+            assertEqual "" "(?:(?:.*/)?a\\+b\\x2c\\(c\\)\\ d_e-f)" (Storage.globRegex "a+b,(c) d_e-f")+        , testCase "the invocation is the one the dry-run was captured from" $ do+            let htmlPass = head (Storage.contentsPasses site)+            assertEqual+                ""+                [ "storage"+                , "rsync"+                , "site"+                , "gs://BUCKET"+                , "--recursive"+                , "--delete-unmatched-destination-objects"+                , "--checksums-only"+                , "--cache-control=no-cache"+                , "--exclude=^(?!(?:(?:.*/)?[^/]*\\.html)$).*$"+                , "--dry-run"+                , "--project"+                , "my-project"+                , "--quiet"+                ]+                (args (Storage.BucketsRsync (Storage.contentsRsync site htmlPass){Storage.rsyncDryRun = True}))+        , testCase "up runs what the check dry-ran" $+            mapM_+                ( \p ->+                    assertEqual+                        ""+                        (filter (/= "--dry-run") (args (Storage.BucketsRsync (Storage.contentsRsync site p){Storage.rsyncDryRun = True})))+                        (args (Storage.BucketsRsync (Storage.contentsRsync site p)))+                )+                (Storage.contentsPasses site)+        , testCase "extraneous objects are left when not asked for, and the header is omitted when unset" $+            assertEqual+                ""+                ["storage", "rsync", "out/site", "gs://BUCKET/docs/v1", "--recursive", "--checksums-only", "--project", "my-project", "--quiet"]+                ( map+                    (args . Storage.BucketsRsync . Storage.contentsRsync plain)+                    (Storage.contentsPasses plain)+                    !! 0+                )+        , testCase "emptying a destination is an empty directory synced over it" $+            assertEqual+                ""+                ["storage", "rsync", "/tmp/empty", "gs://BUCKET/docs/v1", "--recursive", "--delete-unmatched-destination-objects", "--checksums-only", "--project", "my-project", "--quiet"]+                (args (Storage.BucketsRsync (Storage.emptyingRsync plain "/tmp/empty")))+        ]+    , testGroup+        "dry-run"+        [ testCase "nothing changed: the listing lines are not differences" $ do+            assertEqual "" [] (Storage.parseDryRun unchanged)+            assertEqual "" Success (verdict [unchanged, unchanged, unchanged])+        , testCase "a touched file is an mtime update, which is not a difference" $ do+            assertEqual "" [Storage.WouldSetMtime "gs://BUCKET/assets/keep.txt"] (Storage.parseDryRun touched)+            assertEqual "" Success (verdict [unchanged, touched, unchanged])+        , testCase "a changed, a deleted and an added file are read, generations dropped" $+            assertEqual+                ""+                [ Storage.WouldRemove "gs://BUCKET/404.html"+                , Storage.WouldSetMtime "gs://BUCKET/assets/keep.txt"+                , Storage.WouldCopy "file://site/assets/new.txt" "gs://BUCKET/assets/new.txt"+                , Storage.WouldCopy "file://site/index.html" "gs://BUCKET/index.html"+                ]+                (Storage.parseDryRun changed)+        , testCase "differences are a failure counting and naming the objects" $+            assertEqual+                ""+                (Failure "the contents of gs://BUCKET differ from site: 2 to copy (assets/new.txt index.html), 1 to remove (404.html)")+                (verdict [changed])+        , testCase "differences in any pass are a failure" $ do+            assertEqual+                ""+                (Failure "the contents of gs://BUCKET differ from site: 1 to copy (index.html), 1 to remove (404.html)")+                (verdict [htmlOnly, unchanged, touched])+            assertBool "" (isFailure (verdict [unchanged, unchanged, htmlOnly]))+        , testCase "without --checksums-only the touched file would have been a copy" $+            assertEqual+                ""+                (Failure "the contents of gs://BUCKET differ from site: 3 to copy (assets/keep.txt assets/new.txt index.html), 1 to remove (404.html)")+                (verdict [byMtime])+        , testCase "more than three objects are counted, not all named" $+            assertEqual+                ""+                (Failure "the contents of gs://BUCKET differ from site: 6 to copy (assets/keep.txt assets/new.txt index.html ...), 2 to remove (404.html 404.html)")+                (verdict [byMtime, byMtime])+        , testCase "a non-zero exit is a failure carrying gcloud's error" $+            assertEqual+                ""+                (Failure "could not compare the contents of gs://BUCKET (exit 1): (gcloud.storage.rsync) Did not find existing container at: nosuchdir")+                (Storage.interpretContentsDryRun site [(ExitSuccess, unchanged), (ExitFailure 1, noSuchDir), (ExitSuccess, unchanged)])+        , testCase "an error wins over differences read in another pass" $+            assertBool+                ""+                ( case Storage.interpretContentsDryRun site [(ExitSuccess, changed), (ExitFailure 1, noSuchDir)] of+                    Failure why -> "could not compare" `Text.isPrefixOf` why+                    _ -> False+                )+        , testCase "a planned action of an unknown kind cannot be judged, unless something else already differs" $ do+            let novel = unchanged <> "Would patch gs://BUCKET/index.html#1791198677028920\n"+            assertEqual "" [Storage.WouldOther "Would patch gs://BUCKET/index.html#1791198677028920"] (Storage.parseDryRun novel)+            assertEqual "" Unknown (verdict [unchanged, novel])+            assertBool "" (isFailure (verdict [changed, novel]))+        , testCase "an object whose name holds a hash or the word to is read whole" $+            assertEqual+                ""+                [ Storage.WouldCopy "file://site/how to/a#b" "gs://BUCKET/how to/a#b"+                , Storage.WouldRemove "gs://BUCKET/c#d"+                ]+                (Storage.parseDryRun "Would copy file://site/how to/a#b to gs://BUCKET/how to/a#b\r\nWould remove gs://BUCKET/c#d#1791198677019384\n")+        , testCase "objects under a prefix are named relative to it" $+            assertEqual+                ""+                (Failure "the contents of gs://BUCKET/docs/v1 differ from out/site: 1 to remove (old.html)")+                (Storage.interpretContentsDryRun plain [(ExitSuccess, "Would remove gs://BUCKET/docs/v1/old.html#1\n")])+        ]+    , testGroup+        "refusals"+        [ testCase "the declarations used here have no problem" $ do+            assertEqual "" [] (Storage.contentsProblems site)+            assertEqual "" [] (Storage.contentsProblems plain)+        , testCase "no directory, a dotted prefix, an empty or repeated glob and an empty header are refused" $ do+            let problems = Storage.contentsProblems+                rule = Storage.CacheRule+            assertBool "" (not (null (problems site{Storage.contentsSource = ""})))+            assertBool "" (not (null (problems site{Storage.contentsPrefix = "a/../b"})))+            assertBool "" (not (null (problems site{Storage.contentsPrefix = "a//b"})))+            assertBool "" (not (null (problems site{Storage.contentsCacheRules = [rule " " "no-cache"]})))+            assertEqual+                ""+                ["the glob *.html is in two rules"]+                (problems site{Storage.contentsCacheRules = [rule "*.html" "no-cache", rule "*.css" "no-cache", rule " *.html" "no-store"]})+            assertBool "" (not (null (problems site{Storage.contentsCacheRules = [rule "*.html" " "]})))+            assertBool "" (not (null (problems site{Storage.contentsCacheRules = [rule "*.html" "no-cache\nX-Other: 1"]})))+            assertBool "" (not (null (problems site{Storage.contentsDefaultCacheControl = Just ""})))+        , testCase "a declaration with a problem fails its check whatever the dry-run said" $+            assertBool "" (isFailure (Storage.interpretContentsDryRun site{Storage.contentsPrefix = ".."} [(ExitSuccess, unchanged)]))+        ]+    ]+  where+    args = processArgs . prepare Storage.storageCommand+    only o = case Map.elems (Dag.dagNodes (Dag.foldDag Dag.sameRepresentative (evalDeps o))) of+        [act] -> act+        acts -> error ("expected one node, got " <> show (length acts))+    siteBucket = Storage.Bucket "BUCKET" (Core.Project "my-project") (Core.Region "europe-west1") True+    site =+        Storage.BucketContents+            { Storage.contentsBucket = siteBucket+            , Storage.contentsPrefix = ""+            , Storage.contentsSource = "site"+            , Storage.contentsCacheRules =+                [ Storage.CacheRule "*.html" "no-cache"+                , Storage.CacheRule "assets/**" "public, max-age=31536000, immutable"+                ]+            , Storage.contentsDefaultCacheControl = Nothing+            , Storage.contentsDeleteExtraneous = True+            , Storage.contentsOnDown = Storage.LeaveObjects+            }+    plain =+        Storage.BucketContents+            { Storage.contentsBucket = siteBucket+            , Storage.contentsPrefix = "docs/v1/"+            , Storage.contentsSource = "out/site"+            , Storage.contentsCacheRules = []+            , Storage.contentsDefaultCacheControl = Nothing+            , Storage.contentsDeleteExtraneous = False+            , Storage.contentsOnDown = Storage.EmptyDestination+            }+    verdict = Storage.interpretContentsDryRun site . map (\err -> (ExitSuccess, err))+    -- 1. nothing changed+    unchanged =+        "At file://site/**, worker process 743548 thread 137918706894656 listed 4...\n\+        \At gs://BUCKET/**, worker process 743548 thread 137918706894656 listed 4...\n\+        \  \n"+    -- 2. one file touched (same bytes, newer mtime)+    touched =+        "At file://site/**, worker process 743680 thread 126006568441664 listed 4...\n\+        \At gs://BUCKET/**, worker process 743680 thread 126006568441664 listed 4...\n\+        \Would set mtime for gs://BUCKET/assets/keep.txt#1791198677004703\n\+        \  \n"+    -- 3. index.html changed, 404.html deleted locally, assets/new.txt added, keep.txt touched+    changed =+        "At file://site/**, worker process 743808 thread 128960133023552 listed 4...\n\+        \At gs://BUCKET/**, worker process 743808 thread 128960133023552 listed 4...\n\+        \Would remove gs://BUCKET/404.html#1791198677019384\n\+        \Would set mtime for gs://BUCKET/assets/keep.txt#1791198677004703\n\+        \Would copy file://site/assets/new.txt to gs://BUCKET/assets/new.txt\n\+        \Would copy file://site/index.html to gs://BUCKET/index.html#1791198677028920\n\+        \  \n"+    -- 4. same state, without --checksums-only+    byMtime =+        "At file://site/**, worker process 743939 thread 127683618424640 listed 4...\n\+        \At gs://BUCKET/**, worker process 743939 thread 127683618424640 listed 4...\n\+        \Would remove gs://BUCKET/404.html#1791198677019384\n\+        \Would copy file://site/assets/keep.txt to gs://BUCKET/assets/keep.txt#1791198677004703\n\+        \Would copy file://site/assets/new.txt to gs://BUCKET/assets/new.txt\n\+        \Would copy file://site/index.html to gs://BUCKET/index.html#1791198677028920\n\+        \  \n"+    -- 5. same state, restricted to HTML with a cache header (everything else excluded)+    htmlOnly =+        "At file://site/**, worker process 744065 thread 139602817214272 listed 1...\n\+        \At gs://BUCKET/**, worker process 744065 thread 139602817214272 listed 2...\n\+        \Would remove gs://BUCKET/404.html#1791198677019384\n\+        \Would copy file://site/index.html to gs://BUCKET/index.html#1791198677028920\n\+        \  \n"+    -- 6. source directory missing (exit 1)+    noSuchDir = "ERROR: (gcloud.storage.rsync) Did not find existing container at: nosuchdir\n"++-------------------------------------------------------------------------------++repoTests :: [TestTree]+repoTests =+    [ testCase "gcloud artifacts takes --location, not --region" $+        assertEqual+            ""+            ["artifacts", "repositories", "create", "my-repo", "--repository-format", "docker", "--location", "us-west1", "--project", "p", "--quiet"]+            ( processArgs+                ( prepare+                    ArtifactRegistry.artifactRegistryCommand+                    (ArtifactRegistry.ReposCreate (ArtifactRegistry.ArtifactRepo "my-repo" (Core.Project "p") (Core.Region "us-west1") ArtifactRegistry.Docker))+                )+            )+    , testCase "describe succeeding means the repo exists" $+        assertEqual "" Success (ArtifactRegistry.interpretRepoDescribe "my-repo" ExitSuccess)+    , testCase "describe failing means the repo is absent" $+        assertBool "" (isFailure (ArtifactRegistry.interpretRepoDescribe "my-repo" (ExitFailure 1)))+    ]++-------------------------------------------------------------------------------++cloudRunTests :: [TestTree]+cloudRunTests =+    [ testCase "describe succeeding with what was declared is satisfied" $+        assertEqual "" Success (verdict declared (describeJson "us-docker.pkg.dev/p/r/img:1" (Just "sa@p.iam.gserviceaccount.com") [plain "A" "1", secret "S"]))+    , testCase "a stale image is not satisfied, and the reason names both" $+        case verdict declared (describeJson "us-docker.pkg.dev/p/r/img:0" (Just "sa@p.iam.gserviceaccount.com") [plain "A" "1"]) of+            Failure why -> do+                assertBool (Text.unpack why) ("img:0" `Text.isInfixOf` why)+                assertBool (Text.unpack why) ("img:1" `Text.isInfixOf` why)+            other -> assertBool (show other) False+    , -- the substring match this replaces called these the same+      testCase "img:1 is not img:10" $ do+        assertBool "" (isFailure (verdict declared (describeJson "us-docker.pkg.dev/p/r/img:10" (Just "sa@p.iam.gserviceaccount.com") [plain "A" "1"])))+        assertBool "" (isFailure (verdict declared{CloudRun.crsImage = "us-docker.pkg.dev/p/r/img:10"} (describeJson "us-docker.pkg.dev/p/r/img:1" (Just "sa@p.iam.gserviceaccount.com") [plain "A" "1"])))+    , testCase "an image that merely appears elsewhere in the output is not the image" $+        assertBool "" (isFailure (verdict declared (Text.replace "\"containers\"" "\"note\": \"us-docker.pkg.dev/p/r/img:1\", \"containers\"" (describeJson "other:2" (Just "sa@p.iam.gserviceaccount.com") [plain "A" "1"]))))+    , testCase "a changed service account is drift" $+        assertBool "" (isFailure (verdict declared (describeJson "us-docker.pkg.dev/p/r/img:1" (Just "someone-else@p.iam.gserviceaccount.com") [plain "A" "1"])))+    , testCase "a service with no service account at all is drift" $+        assertBool "" (isFailure (verdict declared (describeJson "us-docker.pkg.dev/p/r/img:1" Nothing [plain "A" "1"])))+    , testCase "a changed, missing and extra environment variable are each drift, named and never quoted" $ do+        let why env = case verdict declared (describeJson "us-docker.pkg.dev/p/r/img:1" (Just "sa@p.iam.gserviceaccount.com") env) of+                Failure w -> w+                other -> Text.pack (show other)+        assertBool "" ("A" `Text.isInfixOf` why [plain "A" "changed-value-9"])+        assertBool "the value is not in the reason" (not ("changed-value-9" `Text.isInfixOf` why [plain "A" "changed-value-9"]))+        assertBool "" ("environment variable A is missing" `Text.isInfixOf` why [])+        assertBool "" ("environment variable EXTRA is set but not declared" `Text.isInfixOf` why [plain "A" "1", plain "EXTRA" "x"])+    , testCase "a secret-bound variable is not a plain one: extra secrets are not env drift" $+        assertEqual "" Success (verdict declared (describeJson "us-docker.pkg.dev/p/r/img:1" (Just "sa@p.iam.gserviceaccount.com") [plain "A" "1", secret "PGRST_JWT_SECRET"]))+    , testCase "output that is not the expected JSON is Unknown, not a redeploy" $ do+        assertEqual "" Unknown (verdict declared "image: us-docker.pkg.dev/p/r/img:1\n")+        assertEqual "" Unknown (verdict declared "{\"spec\": {}}")+    , testCase "describe failing means the service is absent" $+        assertBool "" (isFailure (CloudRun.interpretServiceDescribe declared (ExitFailure 1) ""))+    , testCase "the describe asks for JSON" $+        assertBool "" ("--format=json" `elem` processArgs (prepare CloudRun.cloudRunCommand (CloudRun.RunDescribe declared)))+    , testCase "for down, a service on a stale image is still present" $ do+        -- down deletes what exists; the image only matters for up.+        assertEqual "" Success (CloudRun.interpretServicePresence ExitSuccess "image: us-docker.pkg.dev/p/r/img:1\n")+        assertBool "" (isFailure (CloudRun.interpretServicePresence (ExitFailure 1) ""))+    ]++declared :: CloudRun.CloudRunService+declared =+    CloudRun.CloudRunService+        { CloudRun.crsName = "svc"+        , CloudRun.crsProject = Core.Project "p"+        , CloudRun.crsRegion = Core.Region "europe-west1"+        , CloudRun.crsImage = "us-docker.pkg.dev/p/r/img:1"+        , CloudRun.crsEnv = Map.fromList [("A", "1")]+        , CloudRun.crsServiceAccount = "sa@p.iam.gserviceaccount.com"+        , CloudRun.crsIngress = CloudRun.All+        , CloudRun.crsMaxInstances = Nothing+        , CloudRun.crsOptions = CloudRun.defaultCloudRunOptions+        }++verdict :: CloudRun.CloudRunService -> Text.Text -> CheckResult+verdict svc = CloudRun.interpretServiceDescribe svc ExitSuccess++plain :: Text.Text -> Text.Text -> Value+plain k v = object ["name" .= k, "value" .= v]++secret :: Text.Text -> Value+secret k = object ["name" .= k, "valueFrom" .= object ["secretKeyRef" .= object ["name" .= ("s" :: Text.Text), "key" .= ("latest" :: Text.Text)]]]++-- | The parts of @gcloud run services describe --format=json@ the check reads.+describeJson :: Text.Text -> Maybe Text.Text -> [Value] -> Text.Text+describeJson = describeJsonWith []++-- | The same, with annotations on the revision template.+describeJsonWith :: [(Text.Text, Text.Text)] -> Text.Text -> Maybe Text.Text -> [Value] -> Text.Text+describeJsonWith annotations image sa env =+    Text.decodeUtf8 . LByteString.toStrict . encode $+        object+            [ "spec"+                .= object+                    [ "template"+                        .= object+                            [ "metadata" .= object ["annotations" .= Map.fromList annotations]+                            , "spec"+                                .= object+                                    ( [ "containers" .= [object ["image" .= image, "env" .= env]]+                                      ]+                                        <> maybe [] (\a -> ["serviceAccountName" .= a]) sa+                                    )+                            ]+                    ]+            , "status" .= object ["latestReadyRevisionName" .= ("svc-00001" :: Text.Text)]+            ]++-------------------------------------------------------------------------------++iamTests :: [TestTree]+iamTests =+    [ testCase "service account describe succeeding means it exists" $+        assertEqual "" Success (Iam.interpretServiceAccountDescribe "sa-1" ExitSuccess)+    , testCase "service account describe failing means it is absent" $+        assertBool "" (isFailure (Iam.interpretServiceAccountDescribe "sa-1" (ExitFailure 1)))+    , testCase "a binding present in the policy is satisfied" $+        assertEqual+            ""+            Success+            ( Iam.interpretBindingPolicy+                binding+                ExitSuccess+                "bindings:\n- members:\n  - serviceAccount:sa-1@p.iam.gserviceaccount.com\n  role: roles/storage.objectViewer\n"+            )+    , testCase "a binding absent from the policy is not satisfied" $+        assertBool+            ""+            ( isFailure+                ( Iam.interpretBindingPolicy+                    binding+                    ExitSuccess+                    "bindings:\n- members:\n  - user:someone@example.com\n  role: roles/viewer\n"+                )+            )+    , testCase "get-iam-policy failing outright is not satisfied" $+        assertBool "" (isFailure (Iam.interpretBindingPolicy binding (ExitFailure 1) ""))+    , testCase "a secrets/ resource binds against the secrets group" $+        assertEqual+            ""+            ["secrets", "add-iam-policy-binding", "my-secret", "--member", "serviceAccount:sa-1@p.iam.gserviceaccount.com", "--role", "roles/secretmanager.secretAccessor", "--quiet"]+            (processArgs (prepare Iam.iamCommand (Iam.IamPolicyAddBinding secretBinding)))+    , testCase "an artifacts/repositories/ resource carries its location as a trailing flag, after the resource" $+        assertEqual+            ""+            ["artifacts", "repositories", "add-iam-policy-binding", "my-repo", "--location", "us-west1", "--member", "serviceAccount:sa-1@p.iam.gserviceaccount.com", "--role", "roles/uploader", "--quiet"]+            (processArgs (prepare Iam.iamCommand (Iam.IamPolicyAddBinding repoBinding)))+    , testCase "a project-qualified repository passes --project rather than relying on gcloud's configured project" $+        assertEqual+            ""+            ["artifacts", "repositories", "get-iam-policy", "my-repo", "--location", "us-west1", "--project", "p", "--quiet"]+            (processArgs (prepare Iam.iamCommand (Iam.IamPolicyGetBinding (binding {Iam.iamResource = "projects/p/locations/us-west1/repositories/my-repo"}))))+    , testCase "a project-qualified secret passes --project" $+        assertEqual+            ""+            ["secrets", "get-iam-policy", "my-secret", "--project", "p", "--quiet"]+            (processArgs (prepare Iam.iamCommand (Iam.IamPolicyGetBinding (binding {Iam.iamResource = "projects/p/secrets/my-secret"}))))+    , testCase "a bucket resource is rendered as the gs:// URL gcloud storage requires" $+        assertEqual+            ""+            ["storage", "buckets", "get-iam-policy", "gs://my-bucket", "--quiet"]+            (processArgs (prepare Iam.iamCommand (Iam.IamPolicyGetBinding (binding {Iam.iamResource = "buckets/my-bucket"}))))+    , testCase "role describe succeeding means the custom role exists" $+        assertEqual "" Success (Iam.interpretRoleDescribe "registryUploader" ExitSuccess)+    , testCase "role describe failing means the custom role is absent" $+        assertBool "" (isFailure (Iam.interpretRoleDescribe "registryUploader" (ExitFailure 1)))+    , testCase "a custom role is created from its definition file" $+        assertEqual+            ""+            ["iam", "roles", "create", "registryUploader", "--file", "infra/roles/registryUploader.yaml", "--project", "p", "--quiet"]+            (processArgs (prepare Iam.iamCommand (Iam.RolesCreate role)))+    , testCase "a service account key is written to its target path" $+        assertEqual+            ""+            ["iam", "service-accounts", "keys", "create", "secrets/gh-ci/uploader.key.json", "--iam-account", "uploader@p.iam.gserviceaccount.com", "--project", "p", "--quiet"]+            (processArgs (prepare Iam.iamCommand (Iam.ServiceAccountKeysCreate key)))+    ]+  where+    binding =+        Iam.IamBinding+            { Iam.iamPrincipal = Iam.ServiceAccount "sa-1@p.iam.gserviceaccount.com"+            , Iam.iamRole = "roles/storage.objectViewer"+            , Iam.iamResource = "projects/p"+            }+    secretBinding =+        binding+            { Iam.iamRole = "roles/secretmanager.secretAccessor"+            , Iam.iamResource = "secrets/my-secret"+            }+    repoBinding =+        binding+            { Iam.iamRole = "roles/uploader"+            , Iam.iamResource = "artifacts/repositories/us-west1/my-repo"+            }+    role =+        Iam.CustomRole+            { Iam.roleId = "registryUploader"+            , Iam.roleProject = Core.Project "p"+            , Iam.roleDefinitionFile = "infra/roles/registryUploader.yaml"+            }+    key =+        Iam.ServiceAccountKey+            { Iam.sakProject = Core.Project "p"+            , Iam.sakAccountId = "uploader"+            , Iam.sakPath = "secrets/gh-ci/uploader.key.json"+            }++-------------------------------------------------------------------------------++serviceUsageTests :: [TestTree]+serviceUsageTests =+    [ testCase "the API appearing in the enabled listing is satisfied" $+        assertEqual+            ""+            Success+            (ServiceUsage.interpretServiceList (ServiceUsage.Api "run.googleapis.com") ExitSuccess "NAME\nrun.googleapis.com\n")+    , testCase "the API absent from the enabled listing is not satisfied" $+        assertBool+            ""+            (isFailure (ServiceUsage.interpretServiceList (ServiceUsage.Api "run.googleapis.com") ExitSuccess "NAME\n"))+    , testCase "listing failing outright is not satisfied" $+        assertBool "" (isFailure (ServiceUsage.interpretServiceList (ServiceUsage.Api "run.googleapis.com") (ExitFailure 1) ""))+    ]++-------------------------------------------------------------------------------++secretTests :: [TestTree]+secretTests =+    [ testCase "a version is added from a file, never from argv" $ do+        -- argv is world-readable through /proc for the life of the process.+        let args = processArgs (prepare SecretManager.secretManagerCommand (SecretManager.VersionsAdd version))+        assertBool (show args) (["--data-file", "/certs/db.key"] `isSubsequenceOf` args)+        assertBool (show args) (["secrets", "versions", "add", "db-key"] `isSubsequenceOf` args)+    , testCase "reading back compares the exact bytes, trailing newline included" $ do+        -- Trimming would make a node that had just uploaded its own PEM+        -- report a difference on every later pass.+        assertEqual "" Success (SecretManager.interpretSecretContents "s" "abc\n" ExitSuccess "abc\n")+        assertBool "" (isFailure (SecretManager.interpretSecretContents "s" "abc\n" ExitSuccess "abc"))+        assertBool "" (isFailure (SecretManager.interpretSecretContents "s" "abc" (ExitFailure 1) ""))+    , testCase "a secret that does not describe is absent" $ do+        assertEqual "" Success (SecretManager.interpretSecretDescribe "s" ExitSuccess)+        assertBool "" (isFailure (SecretManager.interpretSecretDescribe "s" (ExitFailure 1)))+    ]+  where+    sec = SecretManager.Secret "db-key" (Core.Project "p") "automatic"+    version = SecretManager.SecretVersion sec "/certs/db.key"++cloudRunOptionTests :: [TestTree]+cloudRunOptionTests =+    [ testCase "every secret rides on ONE --set-secrets flag" $ do+        -- gcloud treats a repeated --set-secrets as a replacement, so the+        -- one-flag-per-binding form silently deploys with only the last.+        let args = processArgs (prepare CloudRun.cloudRunCommand (CloudRun.RunDeploy svc))+            flags = length (filter (== "--set-secrets") args)+        assertEqual (show args) 1 flags+        assertBool (show args) ("/opt/vault/cert.pem=svc-cert:latest,PGRST_JWT_SECRET=svc-jwt:latest" `elem` args)+    , testCase "resource knobs are passed only when set" $ do+        let bare = processArgs (prepare CloudRun.cloudRunCommand (CloudRun.RunDeploy svc{CloudRun.crsOptions = CloudRun.defaultCloudRunOptions}))+        assertBool (show bare) (not ("--set-secrets" `elem` bare))+        assertBool (show bare) (not ("--cpu" `elem` bare))+        assertBool (show bare) (not ("--allow-unauthenticated" `elem` bare))+        assertBool (show bare) (not ("--no-invoker-iam-check" `elem` bare))+        assertBool (show bare) (not ("--min-instances" `elem` bare))+        assertBool (show bare) (not ("--no-cpu-throttling" `elem` bare))+        assertBool (show bare) (not ("--cpu-throttling" `elem` bare))+        let full = processArgs (prepare CloudRun.cloudRunCommand (CloudRun.RunDeploy svc))+        assertBool (show full) (["--cpu", "1000m"] `isSubsequenceOf` full)+        assertBool (show full) (["--memory", "256Mi"] `isSubsequenceOf` full)+        assertBool (show full) (["--concurrency", "80"] `isSubsequenceOf` full)+    , testCase "disabling the invoker IAM check is a deploy flag, not an IAM write" $ do+        -- Under iam.allowedPolicyMemberDomains, --allow-unauthenticated+        -- deploys and only warns that allUsers was refused; the flag form is+        -- part of the spec, so it either lands or the deploy fails.+        let opts = CloudRun.defaultCloudRunOptions{CloudRun.croInvokerIamCheckDisabled = True}+            args = processArgs (prepare CloudRun.cloudRunCommand (CloudRun.RunDeploy svc{CloudRun.crsOptions = opts}))+        assertBool (show args) ("--no-invoker-iam-check" `elem` args)+        assertBool (show args) (not ("--allow-unauthenticated" `elem` args))+    , testCase "min instances and always-allocated CPU are deploy flags" $ do+        -- What a service with a background loop needs: an instance that+        -- stays, and CPU for it outside a request.+        let args = processArgs (prepare CloudRun.cloudRunCommand (CloudRun.RunDeploy svc{CloudRun.crsOptions = alwaysOn}))+        assertBool (show args) (["--min-instances", "1"] `isSubsequenceOf` args)+        assertBool (show args) ("--no-cpu-throttling" `elem` args)+        -- Just 0 is said, not dropped: it is how a service is put back to+        -- scaling to zero.+        let zero = processArgs (prepare CloudRun.cloudRunCommand (CloudRun.RunDeploy svc{CloudRun.crsOptions = CloudRun.defaultCloudRunOptions{CloudRun.croMinInstances = Just 0}}))+        assertBool (show zero) (["--min-instances", "0"] `isSubsequenceOf` zero)+    , testCase "declared scaling knobs are compared against the template's annotations" $ do+        let on = declared{CloudRun.crsOptions = alwaysOn}+            out anns = describeJsonWith anns "us-docker.pkg.dev/p/r/img:1" (Just "sa@p.iam.gserviceaccount.com") [plain "A" "1"]+            both = [("autoscaling.knative.dev/minScale", "1"), ("run.googleapis.com/cpu-throttling", "false")]+        assertEqual "" Success (verdict on (out both))+        -- no annotation at all is scale-to-zero and throttled+        assertBool "" (isFailure (verdict on (out [])))+        assertBool "" (isFailure (verdict on (out [("autoscaling.knative.dev/minScale", "2"), ("run.googleapis.com/cpu-throttling", "false")])))+        assertBool "" (isFailure (verdict on (out [("autoscaling.knative.dev/minScale", "1"), ("run.googleapis.com/cpu-throttling", "true")])))+        -- Just 0 is satisfied by the annotation being absent+        let zero = declared{CloudRun.crsOptions = CloudRun.defaultCloudRunOptions{CloudRun.croMinInstances = Just 0}}+        assertEqual "" Success (verdict zero (out []))+        assertBool "" (isFailure (verdict zero (out both)))+    , testCase "undeclared scaling knobs are not compared" $+        -- Whatever the service has is left alone, as the deploy leaves it.+        assertEqual+            ""+            Success+            ( verdict+                declared+                ( describeJsonWith+                    [("autoscaling.knative.dev/minScale", "3"), ("run.googleapis.com/cpu-throttling", "false")]+                    "us-docker.pkg.dev/p/r/img:1"+                    (Just "sa@p.iam.gserviceaccount.com")+                    [plain "A" "1"]+                )+            )+    , testCase "an owner label is a deploy flag, and nothing without one" $ do+        let args = processArgs (prepare CloudRun.cloudRunCommand (CloudRun.RunDeploy owned))+        assertBool (show args) (["--update-labels", "salmon-turnup=acme"] `isSubsequenceOf` args)+        let bare = processArgs (prepare CloudRun.cloudRunCommand (CloudRun.RunDeploy declared))+        assertBool (show bare) (not (any ("label" `isInfixOf`) bare))+    , testCase "ownership is asked through a listing, by name" $+        assertEqual+            ""+            ["run", "services", "list", "--filter", "metadata.name=svc", "--format=json", "--region", "europe-west1", "--project", "p", "--quiet"]+            (processArgs (prepare CloudRun.cloudRunCommand (CloudRun.RunListNamed owned)))+    , testCase "a listing says whose the service is" $ do+        let ask = CloudRun.interpretOwnership owner "svc" ExitSuccess+        assertEqual "nothing of that name" CloudRun.Absent (ask "[]")+        assertEqual "ours" CloudRun.Ours (ask (listing [("svc", [("salmon-turnup", "acme"), ("cloud.googleapis.com/location", "europe-west1")])]))+        assertEqual "no label at all" CloudRun.Foreign (ask (listing [("svc", [])]))+        assertEqual "another turnup's" CloudRun.Foreign (ask (listing [("svc", [("salmon-turnup", "other")])]))+        -- a filter that matched more than the name decides nothing+        assertEqual "a longer name is not this service" CloudRun.Absent (ask (listing [("svc-2", [("salmon-turnup", "acme")])]))+        assertEqual "nor does it vouch for this one" CloudRun.Foreign (ask (listing [("svc-2", [("salmon-turnup", "acme")]), ("svc", [])]))+    , testCase "a listing that failed or is not one decides nothing" $ do+        assertBool "" (undetermined (CloudRun.interpretOwnership owner "svc" (ExitFailure 1) "[]"))+        assertBool "" (undetermined (CloudRun.interpretOwnership owner "svc" ExitSuccess "Listed 0 items."))+        assertBool "" (undetermined (CloudRun.interpretOwnership owner "svc" ExitSuccess "{}"))+    , testCase "a service without the declared label is not satisfied, whatever it runs" $ do+        let out labels = labelled labels (describeJson "us-docker.pkg.dev/p/r/img:1" (Just "sa@p.iam.gserviceaccount.com") [plain "A" "1"])+        assertEqual "" Success (verdict owned (out [("salmon-turnup", "acme")]))+        assertBool "" (isFailure (verdict owned (out [])))+        assertBool "" (isFailure (verdict owned (out [("salmon-turnup", "other")])))+        -- and a node declared without one does not look+        assertEqual "" Success (verdict declared (out []))+    ]+  where+    owner = CloudRun.OwnerLabel "salmon-turnup" "acme"+    owned = declared{CloudRun.crsOptions = CloudRun.defaultCloudRunOptions{CloudRun.croOwner = Just owner}}+    undetermined (CloudRun.Undetermined _) = True+    undetermined _ = False+    -- the parts of @gcloud run services list --format=json@ the reading uses+    listing :: [(Text.Text, [(Text.Text, Text.Text)])] -> Text.Text+    listing entries =+        Text.decodeUtf8 . LByteString.toStrict . encode $+            [object ["metadata" .= object ["name" .= name, "labels" .= Map.fromList labels]] | (name, labels) <- entries]+    -- a describe output, with these labels on the service itself+    labelled :: [(Text.Text, Text.Text)] -> Text.Text -> Text.Text+    labelled labels described = case decodeStrict (Text.encodeUtf8 described) of+        Just (Object o) ->+            Text.decodeUtf8 . LByteString.toStrict . encode $+                Object (KeyMap.insert "metadata" (object ["name" .= ("svc" :: Text.Text), "labels" .= Map.fromList labels]) o)+        _ -> described+    alwaysOn =+        CloudRun.defaultCloudRunOptions+            { CloudRun.croMinInstances = Just 1+            , CloudRun.croCpuAlwaysAllocated = True+            }+    svc =+        CloudRun.CloudRunService+            { CloudRun.crsName = "svc"+            , CloudRun.crsProject = Core.Project "p"+            , CloudRun.crsRegion = Core.Region "europe-west1"+            , CloudRun.crsImage = "img:1"+            , CloudRun.crsEnv = Map.empty+            , CloudRun.crsServiceAccount = "sa@p.iam.gserviceaccount.com"+            , CloudRun.crsIngress = CloudRun.All+            , CloudRun.crsMaxInstances = Just 1+            , CloudRun.crsOptions =+                CloudRun.defaultCloudRunOptions+                    { CloudRun.croSecrets =+                        [ CloudRun.SecretFile "/opt/vault/cert.pem" "svc-cert" "latest"+                        , CloudRun.SecretEnvVar "PGRST_JWT_SECRET" "svc-jwt" "latest"+                        ]+                    , CloudRun.croCpu = Just "1000m"+                    , CloudRun.croMemory = Just "256Mi"+                    , CloudRun.croConcurrency = Just 80+                    }+            }++lbBackendTests :: [TestTree]+lbBackendTests =+    [ testCase "a proxy-only subnet is created ACTIVE, with its purpose" $ do+        let args = processArgs (prepare Compute.computeCommand (Compute.SubnetsCreate proxySubnet))+        assertBool (show args) (["--purpose", "REGIONAL_MANAGED_PROXY"] `isSubsequenceOf` args)+        assertBool (show args) (["--role", "ACTIVE"] `isSubsequenceOf` args)+        assertBool (show args) (["--range", "192.168.100.0/24"] `isSubsequenceOf` args)+    , testCase "an ordinary subnet is not given a role" $ do+        let args = processArgs (prepare Compute.computeCommand (Compute.SubnetsCreate proxySubnet{Compute.subnetPurpose = Compute.PrivateSubnet}))+        assertBool (show args) (not ("--role" `elem` args))+    , testCase "a subnet of the wrong purpose is not the subnet that was asked for" $ do+        assertEqual "" Success (Compute.interpretSubnetDescribe "s" "REGIONAL_MANAGED_PROXY" ExitSuccess "REGIONAL_MANAGED_PROXY")+        assertBool "a plain subnet under that name" (isFailure (Compute.interpretSubnetDescribe "s" "REGIONAL_MANAGED_PROXY" ExitSuccess "PRIVATE"))+        assertBool "absent" (isFailure (Compute.interpretSubnetDescribe "s" "REGIONAL_MANAGED_PROXY" (ExitFailure 1) ""))+    , testCase "gcloud leaving an ordinary subnet's purpose empty still satisfies PRIVATE" $+        assertEqual "" Success (Compute.interpretSubnetDescribe "s" "PRIVATE" ExitSuccess "")+    , testCase "the instance group is unmanaged and zonal" $ do+        let args = processArgs (prepare Compute.computeCommand (Compute.InstanceGroupsCreate group))+        assertBool (show args) (["instance-groups", "unmanaged", "create", "ig"] `isSubsequenceOf` args)+        assertBool (show args) (["--zone", "europe-west1-b"] `isSubsequenceOf` args)+    , testCase "membership is read off the listing's last path segment" $ do+        let listing = "https://www.googleapis.com/compute/v1/projects/p/zones/europe-west1-b/instances/web\n"+        assertEqual "" Success (Compute.interpretGroupMembership "web" ExitSuccess listing)+        -- the whole reason not to use a substring match+        assertBool "a longer name containing this one" (isFailure (Compute.interpretGroupMembership "web" ExitSuccess "projects/p/zones/z/instances/web-canary\n"))+        assertBool "empty listing" (isFailure (Compute.interpretGroupMembership "web" ExitSuccess ""))+        assertBool "listing failed" (isFailure (Compute.interpretGroupMembership "web" (ExitFailure 1) ""))+    ]+  where+    proxySubnet =+        Compute.Subnet+            { Compute.subnetName = "proxy"+            , Compute.subnetProject = Core.Project "p"+            , Compute.subnetRegion = Core.Region "europe-west1"+            , Compute.subnetNetwork = "default"+            , Compute.subnetRange = "192.168.100.0/24"+            , Compute.subnetPurpose = Compute.RegionalManagedProxy+            }+    group = Compute.InstanceGroup "ig" (Core.Project "p") (Core.Zone "europe-west1-b")++-------------------------------------------------------------------------------++lbTests :: [TestTree]+lbTests =+    [ testCase "describe succeeding means the url map exists" $+        assertEqual "" Success (LoadBalancing.interpretLbDescribe ExitSuccess)+    , testCase "describe failing means the load balancer is absent" $+        assertBool "" (isFailure (LoadBalancing.interpretLbDescribe (ExitFailure 1)))+    , testCase "a complete, healthy balancer is Success" $+        assertEqual "" Success (LoadBalancing.interpretLbCheck ExitSuccess "HEALTH backend HEALTHY\nHEALTH backend HEALTHY\n")+    , testCase "no health lines (Cloud Run NEG) and nothing missing is Success" $+        assertEqual "" Success (LoadBalancing.interpretLbCheck ExitSuccess "")+    , testCase "a missing sub-resource is a Failure naming it" $ do+        let v = LoadBalancing.interpretLbCheck ExitSuccess "MISSING url-maps x-url-map\nHEALTH backend HEALTHY\n"+        assertBool "" (isFailure v)+        case v of+            Failure t -> assertBool "names it" ("url-maps x-url-map" `isInfixOf` Text.unpack t)+            _ -> pure ()+    , testCase "unhealthy backends (the post-up window) are Unknown, not Failure" $+        assertEqual "" Unknown (LoadBalancing.interpretLbCheck ExitSuccess "HEALTH backend HEALTHY\nHEALTH backend UNHEALTHY\n")+    , testCase "missing outranks unhealthy" $+        assertBool "" (isFailure (LoadBalancing.interpretLbCheck ExitSuccess "MISSING backend neg x\nHEALTH backend UNHEALTHY\n"))+    , testCase "a broken check script is a Failure" $+        assertBool "" (isFailure (LoadBalancing.interpretLbCheck (ExitFailure 2) ""))+    , testCase "the balancer's address is read off its forwarding rule" $+        assertEqual+            ""+            ["compute", "forwarding-rules", "describe", "x-fw", "--format", "value(IPAddress)", "--region", "europe-west1", "--project", "my-project", "--quiet"]+            (processArgs (prepare LoadBalancing.loadBalancingCommand (LoadBalancing.LbAddressDescribe (alb {LoadBalancing.albName = "x", LoadBalancing.albProject = Core.Project "my-project", LoadBalancing.albRegion = Core.Region "europe-west1"}))))+    , testCase "check script describes every sub-resource and asks for health" $ do+        let sc = Text.unpack (LoadBalancing.renderLbCheckScript alb)+        mapM_ (\w -> assertBool w (w `isInfixOf` sc)) ["url-maps describe", "target-http-proxies describe", "forwarding-rules describe", "get-health", "health-checks describe"]+    , testCase "shellQuote neutralizes a value that would otherwise break out of quoting" $ do+        assertEqual "no special characters" "'tenant-1'" (LoadBalancing.shellQuote "tenant-1")+        assertEqual+            "an embedded single quote and shell metacharacters stay inside the quoting"+            "'tenant'\\''; rm -rf / #'"+            (LoadBalancing.shellQuote "tenant'; rm -rf / #")+    , testCase "create/delete run the script with bash, not as a gcloud subcommand" $ do+        assertBool "create" (isBash (prepare LoadBalancing.loadBalancingCommand (LoadBalancing.LbCreate alb)))+        assertBool "delete" (isBash (prepare LoadBalancing.loadBalancingCommand (LoadBalancing.LbDelete alb)))+    , testCase "--quiet stays among gcloud's own flags, before a --" $+        assertEqual "" ["compute", "ssh", "vm", "--quiet", "--", "rm", "--quiet"] (Core.quietly ["compute", "ssh", "vm", "--", "rm", "--quiet"])+    , testCase "a script of gcloud calls turns prompts off before its first call" $+        -- these calls do not go through gcloudProc, where the others get --quiet+        mapM_+            ( \cmd -> case processArgs (prepare LoadBalancing.loadBalancingCommand cmd) of+                ["-c", sc] -> assertEqual (take 60 sc) (Text.unpack Core.disablePromptsLine) (takeWhile (/= '\n') sc)+                other -> assertBool (show other) False+            )+            ( [LoadBalancing.LbCreate alb, LoadBalancing.LbCheck alb, LoadBalancing.LbHealth alb, LoadBalancing.LbDelete alb]+                <> concat+                    [ [LoadBalancing.LbPartUp alb p, LoadBalancing.LbPartCheck alb p, LoadBalancing.LbPartDown alb p]+                    | p <- LoadBalancing.lbParts alb+                    ]+            )+    , testCase "scripts never swallow failures with || true" $ do+        let scripts = concatMap (processArgs . prepare LoadBalancing.loadBalancingCommand) [LoadBalancing.LbCreate alb, LoadBalancing.LbDelete alb]+        assertBool "" (not (any ("|| true" `isInfixOf`) scripts))+    , testCase "a zonal instance group is addressed by zone, not by the balancer's region" $ do+        -- The bug this pins: every gcloud call naming the group used to get+        -- the balancer's --region, which an unmanaged (zonal) group rejects+        -- outright -- so the one backend kind made of VMs salmon declared+        -- could never be attached at all.+        assertBool script ("--instance-group-zone='europe-west1-b'" `isInfixOf` script)+        assertBool script (not ("--instance-group-region" `isInfixOf` script))+        assertBool script ("set-named-ports 'ig' --project=\"$PROJECT\" --zone='europe-west1-b'" `isInfixOf` script)+    , testCase "exists() is a bare predicate, so each caller says where its resource lives" $ do+        -- It used to append --project/--region to whatever it was handed,+        -- which silently made every describe a regional one.+        assertBool script ("exists() { \"$@\" >/dev/null 2>&1; }" `isInfixOf` script)+        assertBool script ("exists gcloud compute url-maps describe 'web-url-map' --project=\"$PROJECT\" --region=\"$REGION\"" `isInfixOf` script)+    , testCase "a regional instance group keeps the regional flag" $ do+        let regionalScript = createScript alb{LoadBalancing.albBackends = [LoadBalancing.InstanceGroupBackend "ig" (LoadBalancing.InstanceGroupRegion "europe-west1") [8080]]}+        assertBool regionalScript ("--instance-group-region='europe-west1'" `isInfixOf` regionalScript)+    , testCase "rendered scripts parse as bash" $ do+        let scripts = [s' | cmd <- [LoadBalancing.LbCreate alb, LoadBalancing.LbCheck alb, LoadBalancing.LbDelete alb], (_ : s' : _) <- [processArgs (prepare LoadBalancing.loadBalancingCommand cmd)]]+        mapM_+            ( \script -> do+                (code, _, err) <- readProcessWithExitCode "bash" ["-n", "-c", script] ""+                assertEqual err ExitSuccess code+            )+            scripts+    , testCase "a plain balancer renders none of the HTTPS, rule or timeout steps" $ do+        -- of its resources: the leftovers part asks after certificates and+        -- proxies whatever is declared, since it looks for what used to be+        mapM_+            (\w -> assertBool (w <> "\n" <> resourceLines alb) (not (w `isInfixOf` resourceLines alb)))+            ["target-https-proxies", "certificate-manager", "addresses", "--address", "--timeout", "ssl-certificates"]+        -- the one import is behind a look at the map: only over one that has host rules+        assertEqual "" 1 (length [() | l <- lines script, "url-maps import" `isInfixOf` l])+        assertBool script (all ("if [ -n \"$ruled\" ]; then " `isPrefixOf`) [l | l <- lines script, "url-maps import" `isInfixOf` l])+        assertBool script ("gcloud compute url-maps create 'web-url-map' --project=\"$PROJECT\" --region=\"$REGION\" --default-service='web-backend'" `isInfixOf` script)+    , testCase "a managed certificate is an authorization per domain, a certificate over them, an HTTPS proxy and a :443 rule" $ do+        let sc = createScript full+        mapM_+            (\w -> assertBool (w <> "\n" <> sc) (w `isInfixOf` sc))+            [ "gcloud certificate-manager dns-authorizations create 'web-cert-app-example-org' --project=\"$PROJECT\" --location=\"$REGION\" --domain='app.example.org' --type=PER_PROJECT_RECORD"+            , "gcloud certificate-manager dns-authorizations create 'web-cert-api-example-org'"+            , "gcloud certificate-manager certificates create 'web-cert' --project=\"$PROJECT\" --location=\"$REGION\" --domains='app.example.org,api.example.org' --dns-authorizations='web-cert-app-example-org,web-cert-api-example-org'"+            , "gcloud compute target-https-proxies create 'web-https-proxy' --project=\"$PROJECT\" --region=\"$REGION\" --url-map='web-url-map' --url-map-region=\"$REGION\" --certificate-manager-certificates='web-cert'"+            , "--target-https-proxy='web-https-proxy' --target-https-proxy-region=\"$REGION\" --ports=443"+            , "gcloud compute addresses create 'web-ip' --project=\"$PROJECT\" --region=\"$REGION\""+            ]+    , testCase "with HTTPS both forwarding rules sit on the one reserved address" $ do+        let rules = [l | l <- lines (createScript full), "forwarding-rules create" `isInfixOf` l]+        assertEqual "" 2 (length rules)+        mapM_ (\l -> assertBool l ("--address='web-ip' --address-region=\"$REGION\"" `isInfixOf` l)) rules+    , testCase "the certificate exists before the proxy that names it, the address before the rules" $ do+        let sc = createScript full+        assertBool sc (["certificates create", "target-https-proxies create", "forwarding-rules create"] `inOrder` sc)+        assertBool sc (["addresses create", "forwarding-rules create"] `inOrder` sc)+        assertBool sc (["backend-services create 'web-slow-backend'", "url-maps import"] `inOrder` sc)+    , testCase "a certificate somebody else made is named by --ssl-certificates, and not created or deleted" $ do+        let own = full{LoadBalancing.albCertificates = [LoadBalancing.ComputeCertificate "own"]}+        let sc = createScript own+        assertBool sc ("--ssl-certificates='own' --ssl-certificates-region=\"$REGION\"" `isInfixOf` sc)+        assertBool sc (not ("certificate-manager" `isInfixOf` resourceLines own))+        assertBool sc (not ("ssl-certificates" `isInfixOf` leftoverLines own))+        assertBool "" (not ("ssl-certificates delete" `isInfixOf` deleteScript own))+        assertBool "" ("need 'ssl-certificates own'" `isInfixOf` Text.unpack (LoadBalancing.renderLbCheckScript own))+    , testCase "host rules make the URL map an import of the whole map, every run" $ do+        let sc = createScript full+        assertBool sc ("| gcloud compute url-maps import 'web-url-map' --project=\"$PROJECT\" --region=\"$REGION\" --quiet" `isInfixOf` sc)+        assertBool sc (not ("url-maps create" `isInfixOf` sc))+        assertBool sc (not ("exists gcloud compute url-maps" `isInfixOf` sc))+    , testCase "the URL map sends each host to its service and each path rule to its own" $+        assertEqual+            ""+            ( object+                [ "name" .= ("web-url-map" :: Text.Text)+                , "defaultService" .= svcUrl "web-backend"+                , "hostRules"+                    .= [ object ["hosts" .= (["app.example.org", "www.example.org"] :: [Text.Text]), "pathMatcher" .= ("m0" :: Text.Text)]+                       , object ["hosts" .= (["api.example.org"] :: [Text.Text]), "pathMatcher" .= ("m1" :: Text.Text)]+                       ]+                , "pathMatchers"+                    .= [ object+                            [ "name" .= ("m0" :: Text.Text)+                            , "defaultService" .= svcUrl "web-backend"+                            , "pathRules" .= [object ["paths" .= (["/events/*", "/poll"] :: [Text.Text]), "service" .= svcUrl "web-slow-backend"]]+                            ]+                       , object ["name" .= ("m1" :: Text.Text), "defaultService" .= svcUrl "web-api-backend"]+                       ]+                ]+            )+            (case LoadBalancing.renderUrlMap full of Object o -> Object (KeyMap.delete "description" o); other -> other)+    , testCase "a URL map with no rule is its name and default service, and is imported only over a map that has rules" $ do+        assertEqual+            ""+            (object ["name" .= ("web-url-map" :: Text.Text), "defaultService" .= svcUrl "web-backend"])+            (LoadBalancing.renderUrlMap alb)+        assertEqual "" Nothing (LoadBalancing.urlMapStamp alb)+        assertBool script ("if [ -n \"$ruled\" ]; then printf '%s\\n' '{\"defaultService\"" `isInfixOf` script)+    , testCase "a named backend service has its own resource, NEG and backends" $ do+        let sc = createScript full+        mapM_+            (\w -> assertBool (w <> "\n" <> sc) (w `isInfixOf` sc))+            [ "gcloud compute backend-services create 'web-slow-backend'"+            , "gcloud compute backend-services add-backend 'web-slow-backend' --project=\"$PROJECT\" --region=\"$REGION\" --instance-group='ig-slow' --instance-group-zone='europe-west1-c'"+            , "gcloud compute network-endpoint-groups create 'web-api-neg'"+            , "gcloud compute backend-services add-backend 'web-api-backend' --project=\"$PROJECT\" --region=\"$REGION\" --network-endpoint-group='web-api-neg'"+            ]+    , testCase "a health check shared by two services is created once" $ do+        let creates = [l | l <- lines (createScript full), "health-checks create" `isInfixOf` l]+        assertEqual (unlines creates) 1 (length creates)+    , testCase "a timeout is set on every run, not only at creation" $ do+        let sc = createScript full+        assertBool sc ("\ngcloud compute backend-services update 'web-slow-backend' --project=\"$PROJECT\" --region=\"$REGION\" --timeout=3600\n" `isInfixOf` sc)+        assertBool sc ("\ngcloud compute backend-services update 'web-backend' --project=\"$PROJECT\" --region=\"$REGION\" --timeout=60\n" `isInfixOf` sc)+        assertBool sc (not ("update 'web-api-backend'" `isInfixOf` sc))+    , testCase "the check asks after the HTTPS pieces, the timeouts, the hosts and the certificate's state" $ do+        let sc = Text.unpack (LoadBalancing.renderLbCheckScript full)+        mapM_+            (\w -> assertBool (w <> "\n" <> sc) (w `isInfixOf` sc))+            [ "target-https-proxies describe 'web-https-proxy'"+            , "forwarding-rules describe 'web-https-fw'"+            , "addresses describe 'web-ip'"+            , "certificate-manager certificates describe 'web-cert'"+            , "certificate-manager dns-authorizations describe 'web-cert-api-example-org'"+            , "value(managed.state)"+            , "value(timeoutSec)"+            , "MISSING timeout 3600s on web-slow-backend"+            , "MISSING host-rule api.example.org"+            , "get-health 'web-slow-backend'"+            , "backend-services describe 'web-api-backend'"+            ]+    , testCase "teardown takes dependants first: rules, proxies, address, certificate, authorizations, map, backends, services, health checks" $ do+        let sc = deleteScript full+        assertBool+            sc+            ( [ "forwarding-rules delete 'web-https-fw'"+              , "forwarding-rules delete 'web-fw'"+              , "target-https-proxies delete 'web-https-proxy'"+              , "target-http-proxies delete 'web-proxy'"+              , "addresses delete 'web-ip'"+              , "certificate-manager certificates delete 'web-cert'"+              , "certificate-manager dns-authorizations delete 'web-cert-app-example-org'"+              , "url-maps delete 'web-url-map'"+              , -- a NEG still attached cannot be deleted+                "backend-services remove-backend 'web-api-backend'"+              , "network-endpoint-groups delete 'web-api-neg'"+              , "backend-services delete 'web-api-backend'"+              , "backend-services delete 'web-slow-backend'"+              , "backend-services remove-backend 'web-backend'"+              , "backend-services delete 'web-backend'"+              , "health-checks delete 'hc'"+              ]+                `inOrder` sc+            )+    , testCase "with HTTPS the balancer's address is read off the HTTPS rule" $+        assertBool "" ("web-https-fw" `elem` processArgs (prepare LoadBalancing.loadBalancingCommand (LoadBalancing.LbAddressDescribe full)))+    , testCase "an authorization's record is asked for by location, as three fields" $+        assertEqual+            ""+            ["certificate-manager", "dns-authorizations", "describe", "web-cert-app-example-org", "--format", "value(dnsResourceRecord.name,dnsResourceRecord.type,dnsResourceRecord.data)", "--location", "europe-west1", "--project", "p", "--quiet"]+            (processArgs (prepare LoadBalancing.loadBalancingCommand (LoadBalancing.LbDnsAuthorizationDescribe full "web-cert-app-example-org")))+    , testCase "the authorizations are named from the certificate and the domain" $+        assertEqual+            ""+            [("app.example.org", "web-cert-app-example-org"), ("api.example.org", "web-cert-api-example-org")]+            (LoadBalancing.dnsAuthorizations full)+    , testCase "an authorization's record is three tab-separated fields" $+        assertEqual+            ""+            (Just (LoadBalancing.DnsAuthorizationRecord "_acme-challenge_abc.app.example.org." "CNAME" "0123.4.europe-west1.authorize.certificatemanager.goog."))+            (LoadBalancing.parseDnsAuthorizationRecord "_acme-challenge_abc.app.example.org.\tCNAME\t0123.4.europe-west1.authorize.certificatemanager.goog.\n")+    , testCase "an authorization with no record yet reads as nothing" $ do+        assertEqual "" Nothing (LoadBalancing.parseDnsAuthorizationRecord "")+        assertEqual "" Nothing (LoadBalancing.parseDnsAuthorizationRecord "\t\t\n")+        assertEqual "" Nothing (LoadBalancing.parseDnsAuthorizationRecord "a\tCNAME\n")+    , testCase "a certificate still provisioning is Unknown, an active one Success, a failed one a Failure naming it" $ do+        assertEqual "" Unknown (LoadBalancing.interpretLbCheck ExitSuccess "HEALTH backend HEALTHY\nCERT web-cert PROVISIONING\n")+        assertEqual "waiting" Unknown (LoadBalancing.interpretLbCheck ExitSuccess "WAITING removal of superseded certificate web-cert-0123abcd, still served by web-https-proxy\n")+        assertBool "waiting beside missing" (isFailure (LoadBalancing.interpretLbCheck ExitSuccess "WAITING removal of a\nMISSING removal of b\n"))+        assertEqual "" Success (LoadBalancing.interpretLbCheck ExitSuccess "HEALTH backend HEALTHY\nCERT web-cert ACTIVE\n")+        case LoadBalancing.interpretLbCheck ExitSuccess "CERT web-cert FAILED\n" of+            Failure t -> assertBool (Text.unpack t) ("web-cert" `isInfixOf` Text.unpack t)+            other -> assertBool (show other) False+    , testCase "a wrong timeout or an unrouted host is a Failure" $ do+        assertBool "" (isFailure (LoadBalancing.interpretLbCheck ExitSuccess "MISSING timeout 3600s on web-slow-backend\n"))+        assertBool "" (isFailure (LoadBalancing.interpretLbCheck ExitSuccess "MISSING host-rule api.example.org\nCERT web-cert ACTIVE\n"))+    , testCase "an unhealthy named service is Unknown like the default one" $+        assertEqual "" Unknown (LoadBalancing.interpretLbCheck ExitSuccess "HEALTH backend HEALTHY\nHEALTH slow UNHEALTHY\n")+    , testCase "a sound declaration has no problems" $ do+        assertEqual "" [] (LoadBalancing.albProblems alb)+        assertEqual "" [] (LoadBalancing.albProblems full)+    , testCase "every problem of a declaration is named, not the first" $ do+        let bad =+                full+                    { LoadBalancing.albServices = full.albServices <> [LoadBalancing.BackendService "slow" [] Nothing (Just 0)]+                    , LoadBalancing.albHostRules =+                        [ LoadBalancing.HostRule ["app.example.org"] (LoadBalancing.NamedService "nope") [LoadBalancing.PathRule [] LoadBalancing.DefaultService LoadBalancing.KeepPath]+                        , LoadBalancing.HostRule ["app.example.org"] LoadBalancing.DefaultService []+                        , LoadBalancing.HostRule [] LoadBalancing.DefaultService []+                        ]+                    , LoadBalancing.albCertificates = [LoadBalancing.ManagedCertificate "c" [], LoadBalancing.ComputeCertificate "own"]+                    }+        let problems = unlines (map Text.unpack (LoadBalancing.albProblems bad))+        mapM_+            (\w -> assertBool (w <> "\n" <> problems) (w `isInfixOf` problems))+            ["declared twice: slow", "undeclared backend service: nope", "names no host", "names no path", "two rules: app.example.org", "cannot share a proxy", "c names no domain", "must be positive: 0"]+    , testCase "a domain-set certificate is named after its set: order and case do not move it, one more name does" $ do+        let name ds = LoadBalancing.certificateResource (LoadBalancing.DomainSetCertificate "web-cert" ds)+        assertEqual "" (name ["app.example.org", "api.example.org"]) (name ["API.example.org", "app.example.org", "app.example.org"])+        assertBool "" (name ["app.example.org", "api.example.org"] /= name ["app.example.org", "api.example.org", "www.example.org"])+        assertEqual "" ("web-cert-" <> LoadBalancing.domainSetTag ["app.example.org"]) (name ["app.example.org"])+        assertEqual "eight hex digits" 8 (Text.length (LoadBalancing.domainSetTag ["app.example.org"]))+        assertEqual "a managed certificate keeps its declared name" "web-cert" (LoadBalancing.certificateResource (LoadBalancing.ManagedCertificate "web-cert" ["app.example.org"]))+    , testCase "a domain-set certificate's authorizations are named after its base, so a changed set reuses them" $ do+        assertEqual "" (LoadBalancing.dnsAuthorizations full) (LoadBalancing.dnsAuthorizations rotating)+        assertEqual+            ""+            (LoadBalancing.dnsAuthorizations rotating <> [("www.example.org", "web-cert-www-example-org")])+            (LoadBalancing.dnsAuthorizations rotated)+    , testCase "a changed domain set is another certificate node, the same proxy node and one cleanup node per base" $ do+        let ids a = map LoadBalancing.partId (LoadBalancing.lbParts a)+        assertBool "" (LoadBalancing.CertificatePart certV1 `elem` ids rotating)+        assertBool "" (LoadBalancing.CertificatePart certV2 `elem` ids rotated)+        assertBool "" (LoadBalancing.CertificatePart certV1 `notElem` ids rotated)+        assertBool "" (LoadBalancing.SupersededCertificatesPart "web-cert" `elem` ids rotated)+        assertBool "none for a certificate under a fixed name" (LoadBalancing.SupersededCertificatesPart "web-cert" `notElem` ids full)+        assertEqual "" [LoadBalancing.HttpsProxyPart, LoadBalancing.CertificatePart certV2] (depsOf rotated (LoadBalancing.SupersededCertificatesPart "web-cert"))+        assertBool "" (LoadBalancing.CertificatePart certV2 `elem` depsOf rotated LoadBalancing.HttpsProxyPart)+    , testCase "a problem line is a Failure naming it" $+        case LoadBalancing.interpretLbCheck ExitSuccess "CERT web-cert ACTIVE\nPROBLEM certificate web-cert expired 2026-01-01T00:00:00Z\n" of+            Failure t -> assertBool (Text.unpack t) ("certificate web-cert expired" `isInfixOf` Text.unpack t)+            other -> assertBool (show other) False+    , testCase "one name for two certificates is a problem" $ do+        let bad = full{LoadBalancing.albCertificates = [LoadBalancing.ManagedCertificate "web-cert" ["a.example.org"], LoadBalancing.DomainSetCertificate "web-cert" ["b.example.org"]]}+        let problems = unlines (map Text.unpack (LoadBalancing.albProblems bad))+        assertBool problems ("certificate declared twice: web-cert" `isInfixOf` problems)+        assertEqual "" [] (LoadBalancing.albProblems rotated)+    , testCase "the HTTPS proxy's URL map is named at creation only" $ do+        let ups = unlines [Text.unpack l | part <- LoadBalancing.lbParts rotated, part.partId == LoadBalancing.HttpsProxyPart, l <- part.partUp]+        assertEqual ups 1 (length [() | l <- lines ups, "--url-map=" `isInfixOf` l])+        assertBool ups (not (any (\l -> "update" `isInfixOf` l && "--url-map" `isInfixOf` l) (lines ups)))+    , testCase "rendered scripts with a domain-set certificate parse as bash" $ do+        let scripts = [s' | cmd <- [LoadBalancing.LbCreate rotated, LoadBalancing.LbCheck rotated, LoadBalancing.LbDelete rotated], (_ : s' : _) <- [processArgs (prepare LoadBalancing.loadBalancingCommand cmd)]]+        mapM_+            ( \sc -> do+                (code, _, err) <- readProcessWithExitCode "bash" ["-n", "-c", sc] ""+                assertEqual err ExitSuccess code+            )+            scripts+    , testCase "a group's named ports are set once, as the union over the services naming it" $ do+        let sc = createScript shared+        let sets = [l | l <- lines sc, "set-named-ports" `isInfixOf` l]+        assertEqual sc 1 (length sets)+        mapM_ (\w -> assertBool (w <> "\n" <> unlines sets) (any (w `isInfixOf`) sets)) ["web-a-backend:4272", "web-b-backend:4273"]+        -- and each service is created on, and kept on, its own port name+        mapM_+            (\w -> assertBool (w <> "\n" <> sc) (w `isInfixOf` sc))+            [ "backend-services create 'web-a-backend' --project=\"$PROJECT\" --region=\"$REGION\" --protocol=HTTP --port-name='web-a-backend'"+            , "backend-services update 'web-a-backend' --project=\"$PROJECT\" --region=\"$REGION\" --port-name='web-a-backend'"+            , "backend-services update 'web-b-backend' --project=\"$PROJECT\" --region=\"$REGION\" --port-name='web-b-backend'"+            ]+        assertBool sc (["set-named-ports 'ig'", "backend-services update 'web-a-backend'"] `inOrder` sc)+    , testCase "a Cloud Run service is given no port name" $ do+        let sc = createScript full+        assertBool sc (not ("--port-name='web-api-backend'" `isInfixOf` sc))+    , testCase "one port name on two ports of a group is a problem" $ do+        let twice = LoadBalancing.InstanceGroupBackend "ig" (LoadBalancing.InstanceGroupZone "europe-west1-b")+        let bad = alb{LoadBalancing.albBackends = [twice [8080], twice [8081]]}+        let problems = unlines (map Text.unpack (LoadBalancing.albProblems bad))+        assertBool problems ("named port web-backend" `isInfixOf` problems)+        assertEqual "" [] (LoadBalancing.albProblems shared)+    , testCase "httpLoadBalancer is the plain balancer" $+        assertEqual+            ""+            alb{LoadBalancing.albNetwork = Nothing}+            (LoadBalancing.httpLoadBalancer "web" (Core.Project "p") (Core.Region "europe-west1") alb.albBackends alb.albHealthCheck)+    , testCase "rendered scripts with every feature parse as bash" $ do+        let scripts = [s' | cmd <- [LoadBalancing.LbCreate full, LoadBalancing.LbCheck full, LoadBalancing.LbDelete full], (_ : s' : _) <- [processArgs (prepare LoadBalancing.loadBalancingCommand cmd)]]+        mapM_+            ( \sc -> do+                (code, _, err) <- readProcessWithExitCode "bash" ["-n", "-c", sc] ""+                assertEqual err ExitSuccess code+            )+            scripts+    , testCase "a declaration with no bucket, redirect or HTTP listener option renders what it rendered before those existed" $+        -- Checksums of every script (and the URL map) taken before backend+        -- buckets, redirects and the HTTP listener option were added. A+        -- balancer already up was made by these words; in particular its+        -- HTTP proxy is created once and its URL map never set again.+        assertEqual+            ""+            -- All four moved once more, on purpose and in the URL map's part+            -- alone: its check compares hosts as a set, a map with no rule+            -- is imported over one that has rules (and is rendered without+            -- empty rule lists), and a map of services carries a+            -- description. (The last also moved earlier: a pending swap is+            -- no longer a failed up.)+            [ ("plain", 2724328492292246434)+            , ("every feature", 6369177876073630455)+            , ("two services on one group", 15231005713454579927)+            , ("a domain-set certificate", 10048840134320661561)+            ]+            [ (what :: String, fnv1a (pinned a) :: Word64)+            | (what, a) <- [("plain", alb), ("every feature", full), ("two services on one group", shared), ("a domain-set certificate", rotated)]+            ]+    , testCase "a host sent to a backend bucket, a path and a host answered with a redirect" $ do+        let rendered = LoadBalancing.renderUrlMap site+        let (stamp, rest) = case rendered of+                Object o -> (KeyMap.lookup "description" o, Object (KeyMap.delete "description" o))+                other -> (Nothing, other)+        let bucketUrl = "https://www.googleapis.com/compute/v1/projects/p/regions/europe-west1/backendBuckets/web-site-bucket" :: Text.Text+        assertEqual+            ""+            ( object+                [ "name" .= ("web-url-map" :: Text.Text)+                , "defaultService" .= svcUrl "web-backend"+                , "hostRules"+                    .= [ object ["hosts" .= (["app.example.org", "www.example.org"] :: [Text.Text]), "pathMatcher" .= ("m0" :: Text.Text)]+                       , object ["hosts" .= (["api.example.org"] :: [Text.Text]), "pathMatcher" .= ("m1" :: Text.Text)]+                       , object ["hosts" .= (["static.example.org"] :: [Text.Text]), "pathMatcher" .= ("m2" :: Text.Text)]+                       , object ["hosts" .= (["example.org"] :: [Text.Text]), "pathMatcher" .= ("m3" :: Text.Text)]+                       ]+                , "pathMatchers"+                    .= [ object+                            [ "name" .= ("m0" :: Text.Text)+                            , "defaultService" .= svcUrl "web-backend"+                            , "pathRules" .= [object ["paths" .= (["/events/*", "/poll"] :: [Text.Text]), "service" .= svcUrl "web-slow-backend"]]+                            ]+                       , object ["name" .= ("m1" :: Text.Text), "defaultService" .= svcUrl "web-api-backend"]+                       , object+                            [ "name" .= ("m2" :: Text.Text)+                            , "defaultService" .= bucketUrl+                            , "pathRules"+                                .= [ object+                                        [ "paths" .= (["/old/*"] :: [Text.Text])+                                        , "urlRedirect"+                                            .= object+                                                [ "pathRedirect" .= ("/new" :: Text.Text)+                                                , "httpsRedirect" .= False+                                                , "redirectResponseCode" .= ("FOUND" :: Text.Text)+                                                , "stripQuery" .= False+                                                ]+                                        ]+                                   ]+                            ]+                       , object+                            [ "name" .= ("m3" :: Text.Text)+                            , "defaultUrlRedirect"+                                .= object+                                    [ "hostRedirect" .= ("www.example.org" :: Text.Text)+                                    , "httpsRedirect" .= True+                                    , "redirectResponseCode" .= ("MOVED_PERMANENTLY_DEFAULT" :: Text.Text)+                                    , "stripQuery" .= False+                                    ]+                            ]+                       ]+                ]+            )+            rest+        case stamp of+            Just (String t) -> assertBool (Text.unpack t) ("salmon:" `Text.isPrefixOf` t && Text.length t == 23)+            other -> assertBool (show other) False+    , testCase "the map's fingerprint follows the declaration; it is required of a map naming a bucket or a redirect only" $ do+        let stampOf a = case LoadBalancing.renderUrlMap a of+                Object o -> KeyMap.lookup "description" o+                _ -> Nothing+        assertEqual "" (String <$> LoadBalancing.urlMapStamp full) (stampOf full)+        assertBool "" (stampOf full /= Nothing && stampOf full /= stampOf site)+        assertEqual "" (stampOf site) (stampOf site)+        assertBool "" (stampOf site /= stampOf (siteTo "web.example.org"))+        let required a = "--format='value(description)' 2>/dev/null)\" = 'salmon:" `isInfixOf` Text.unpack (LoadBalancing.renderLbCheckScript a)+        let refused a = "--format='value(description)' 2>/dev/null)\" in 'salmon:" `isInfixOf` Text.unpack (LoadBalancing.renderLbCheckScript a)+        assertEqual "a bucket or a redirect: the live map must carry it" (True, False) (required site, refused site)+        assertEqual "services only: only another fingerprint speaks against the map" (False, True) (required full, refused full)+        assertEqual "no rule: no fingerprint at all" (False, False) (required alb, refused alb)+    , testCase "a backend bucket is a regional resource of its own that the URL map waits for; a redirect is no resource" $ do+        let sc = createScript site+        assertBool sc ("exists gcloud compute backend-buckets describe 'web-site-bucket' --project=\"$PROJECT\" --region=\"$REGION\" || gcloud compute backend-buckets create 'web-site-bucket' --project=\"$PROJECT\" --region=\"$REGION\" --gcs-bucket-name='example-site' --load-balancing-scheme=EXTERNAL_MANAGED" `isInfixOf` sc)+        assertBool sc (["backend-buckets create 'web-site-bucket'", "url-maps import"] `inOrder` sc)+        assertEqual+            ""+            (map LoadBalancing.BackendServicePart ["web-backend", "web-slow-backend", "web-api-backend"] <> [LoadBalancing.BackendBucketPart "web-site-bucket"])+            (depsOf site LoadBalancing.UrlMapPart)+        assertEqual+            "one more resource than without"+            [LoadBalancing.BackendBucketPart "web-site-bucket"]+            (filter (`notElem` map LoadBalancing.partId (LoadBalancing.lbParts full)) (map LoadBalancing.partId (LoadBalancing.lbParts site)))+        assertEqual "" [] (LoadBalancing.albProblems (allowing site))+    , testCase "a rule routing to a backend bucket is refused, naming the outage and the way out; the bucket alone is not" $ do+        case LoadBalancing.albProblems site of+            [t] ->+                mapM_+                    (\w -> assertBool (w <> "\n" <> Text.unpack t) (w `isInfixOf` Text.unpack t))+                    [ "hosts static.example.org to web-site-bucket"+                    , "refused on this regional load balancer"+                    , "every host on a backend service answer 503 (failed_to_pick_backend) while its backends were HEALTHY and the bucket's own host served"+                    , "drop the rule from the declaration (its albBuckets entry may stay) and run a pass: the URL map is re-imported without it"+                    , "AllowBucketRoutesKnownToHaveBrokenALiveBalancer"+                    ]+            other -> assertBool (show other) False+        -- a path rule is a route as much as a host rule's default is+        let byPath = full{LoadBalancing.albBuckets = site.albBuckets, LoadBalancing.albHostRules = [LoadBalancing.HostRule ["app.example.org"] LoadBalancing.DefaultService [LoadBalancing.PathRule ["/static/*"] (LoadBalancing.NamedBucket "site") LoadBalancing.KeepPath]]}+        case LoadBalancing.albProblems byPath of+            [t] -> assertBool (Text.unpack t) ("paths /static/* of hosts app.example.org to web-site-bucket" `isInfixOf` Text.unpack t)+            other -> assertBool (show other) False+        -- the two ways out of it: the rule dropped (bucket kept or not), and the redirects are no part of it+        assertEqual "the bucket with no rule on it" [] (LoadBalancing.albProblems unrouted)+        assertEqual "neither" [] (LoadBalancing.albProblems unrouted{LoadBalancing.albBuckets = []})+        assertBool "the redirect rules stay" (length unrouted.albHostRules == length site.albHostRules - 1)+        -- the opt-in is off unless spelled, and is said in the node's notes+        assertEqual "" LoadBalancing.RefuseBucketRoutes (LoadBalancing.httpLoadBalancer "web" (Core.Project "p") (Core.Region "europe-west1") [] Nothing).albBucketRoutes+        let notesOf a = concat [act.extension.notes | act <- Map.elems (Dag.dagNodes (dagOf (LoadBalancing.applicationLoadBalancer silent ignoreTrack a))), act.extension.help == "application load balancer web"]+        assertBool "" (any ("known to have broken a live balancer" `Text.isInfixOf`) (notesOf (allowing site)))+        assertEqual "nothing more is said of a declaration that does not opt in" (notesOf site) (filter (not . ("known to have broken" `Text.isInfixOf`)) (notesOf (allowing site)))+    , testCase "the opt-in changes no script: what is refused is rendered as what is allowed" $+        assertEqual "" (pinned site) (pinned (allowing site))+    , testCase "a path rule can rewrite the prefix it matched: a routeAction beside its service, seen by the fingerprint" $ do+        let ruled rules = full{LoadBalancing.albHostRules = [LoadBalancing.HostRule ["app.example.org"] LoadBalancing.DefaultService rules]}+        let rewriting prefix = ruled [LoadBalancing.PathRule ["/docs/*"] (LoadBalancing.NamedService "api") (LoadBalancing.RewritePrefix prefix)]+        let kept = ruled [LoadBalancing.PathRule ["/docs/*"] (LoadBalancing.NamedService "api") LoadBalancing.KeepPath]+        let pathRulesOf a = case LoadBalancing.renderUrlMap a of+                Object o | Just (Array ms) <- KeyMap.lookup "pathMatchers" o -> [rs | Object m <- toList ms, Just rs <- [KeyMap.lookup "pathRules" m]]+                _ -> []+        assertEqual+            ""+            [toJSONList [object ["paths" .= (["/docs/*"] :: [Text.Text]), "service" .= svcUrl "web-api-backend", "routeAction" .= object ["urlRewrite" .= object ["pathPrefixRewrite" .= ("/" :: Text.Text)]]]]]+            (pathRulesOf (rewriting "/"))+        assertEqual+            "no rewrite, no routeAction: the rule is what it was"+            [toJSONList [object ["paths" .= (["/docs/*"] :: [Text.Text]), "service" .= svcUrl "web-api-backend"]]]+            (pathRulesOf kept)+        -- the rewrite is in what is imported, and in what the node says of itself+        let urlMapOf a = [p | p <- LoadBalancing.lbParts a, p.partId == LoadBalancing.UrlMapPart]+        let ups = unlines [Text.unpack l | p <- urlMapOf (rewriting "/v2/"), l <- p.partUp]+        assertBool ups ("\"routeAction\":{\"urlRewrite\":{\"pathPrefixRewrite\":\"/v2/\"}}" `isInfixOf` ups)+        assertEqual+            ""+            ["hosts app.example.org to web-backend", "paths /docs/* to web-api-backend with the matched prefix rewritten to /v2/"]+            (concatMap LoadBalancing.partNotes (urlMapOf (rewriting "/v2/")))+        assertEqual "" ["hosts app.example.org to web-backend", "paths /docs/* to web-api-backend"] (concatMap LoadBalancing.partNotes (urlMapOf kept))+        -- a rewrite added, changed or dropped is another fingerprint, and one the live map must carry+        let stamps = map LoadBalancing.urlMapStamp [kept, rewriting "/", rewriting "/v2/"]+        assertEqual (show stamps) 3 (length (nub stamps))+        let required a = "--format='value(description)' 2>/dev/null)\" = 'salmon:" `isInfixOf` Text.unpack (LoadBalancing.renderLbCheckScript a)+        assertEqual "" (False, True) (required kept, required (rewriting "/"))+        assertEqual "" [] (LoadBalancing.albProblems (rewriting "/"))+        -- what no URL map takes+        let redirecting = ruled [LoadBalancing.PathRule ["/old/*"] (LoadBalancing.RedirectTo (LoadBalancing.Redirect Nothing (Just "/new") False LoadBalancing.Found)) (LoadBalancing.RewritePrefix "/x")]+        assertEqual "" ["a path rule both redirects and rewrites its path (/old/*): a redirect names its own path"] (LoadBalancing.albProblems redirecting)+        assertEqual "" ["a path rewrite must start with /: index.html"] (LoadBalancing.albProblems (rewriting "index.html"))+    , testCase "what is wrong with a bucket, a redirect or a listener option is refused" $ do+        let redirect h path https = LoadBalancing.RedirectTo (LoadBalancing.Redirect h path https LoadBalancing.MovedPermanently)+        let bad =+                alb+                    { LoadBalancing.albBuckets = [LoadBalancing.BackendBucket "site" "a", LoadBalancing.BackendBucket "site" ""]+                    , LoadBalancing.albHostRules =+                        [ LoadBalancing.HostRule ["a.example.org"] (LoadBalancing.NamedBucket "nope") [LoadBalancing.PathRule ["/x"] (redirect Nothing (Just "x") False) LoadBalancing.KeepPath]+                        , LoadBalancing.HostRule ["b.example.org"] (redirect Nothing Nothing False) []+                        , LoadBalancing.HostRule ["c.example.org"] (redirect (Just "") Nothing True) []+                        ]+                    , LoadBalancing.albHttp = LoadBalancing.RedirectToHttps LoadBalancing.MovedPermanently+                    }+        let problems = unlines (map Text.unpack (LoadBalancing.albProblems bad))+        mapM_+            (\w -> assertBool (w <> "\n" <> problems) (w `isInfixOf` problems))+            [ "backend bucket declared twice: site"+            , "backend bucket site names no Cloud Storage bucket"+            , "rule names an undeclared backend bucket: nope"+            , "a redirect names neither a host, a path nor HTTPS"+            , "a redirect's path must start with /: x"+            , "a redirect names an empty host"+            , "redirecting HTTP to HTTPS needs a certificate"+            ]+        assertBool "" (any ("no HTTP listener needs a certificate" `Text.isInfixOf`) (LoadBalancing.albProblems alb{LoadBalancing.albHttp = LoadBalancing.NoHttp}))+        mapM_ (\how -> assertEqual (show how) [] (LoadBalancing.albProblems (listening how))) [LoadBalancing.HttpCreatedOnce, LoadBalancing.ServeHttp, LoadBalancing.RedirectToHttps LoadBalancing.PermanentRedirect, LoadBalancing.NoHttp]+    , testCase "by default the HTTP proxy is created once and its URL map never set" $ do+        let ups a = unlines [Text.unpack l | part <- LoadBalancing.lbParts a, part.partId == LoadBalancing.HttpProxyPart, l <- part.partUp]+        assertEqual+            ""+            "exists gcloud compute target-http-proxies describe 'web-proxy' --project=\"$PROJECT\" --region=\"$REGION\" || gcloud compute target-http-proxies create 'web-proxy' --project=\"$PROJECT\" --region=\"$REGION\" --url-map='web-url-map' --url-map-region=\"$REGION\"\n"+            (ups full)+        assertEqual "a bucket and a redirect do not make it a declaration about port 80" (ups full) (ups site)+        mapM_ (\a -> assertBool "" (not ("target-http-proxies update" `isInfixOf` createScript a))) [alb, full, site]+        assertBool "" ("target-http-proxies update 'web-proxy'" `isInfixOf` createScript (listening LoadBalancing.ServeHttp))+    , testCase "redirecting HTTP is a second, redirect-only URL map the HTTP proxy is set on" $ do+        let ids = map LoadBalancing.partId (LoadBalancing.lbParts toHttps)+        assertEqual+            "one more resource"+            [LoadBalancing.HttpRedirectUrlMapPart]+            (filter (`notElem` map LoadBalancing.partId (LoadBalancing.lbParts full)) ids)+        assertEqual "" [LoadBalancing.HttpRedirectUrlMapPart] (depsOf toHttps LoadBalancing.HttpProxyPart)+        assertEqual "the HTTPS proxy stays on the balancer's map" [LoadBalancing.UrlMapPart, LoadBalancing.CertificatePart "web-cert"] (depsOf toHttps LoadBalancing.HttpsProxyPart)+        let rendered = LoadBalancing.renderHttpRedirectUrlMap toHttps LoadBalancing.MovedPermanently+        assertEqual+            ""+            ( object+                [ "name" .= ("web-http-redirect-url-map" :: Text.Text)+                , "defaultUrlRedirect"+                    .= object+                        [ "httpsRedirect" .= True+                        , "redirectResponseCode" .= ("MOVED_PERMANENTLY_DEFAULT" :: Text.Text)+                        , "stripQuery" .= False+                        ]+                ]+            )+            (case rendered of Object o -> Object (KeyMap.delete "description" o); other -> other)+        let sc = createScript toHttps+        assertBool sc (["url-maps import 'web-http-redirect-url-map'", "target-http-proxies create 'web-proxy' --project=\"$PROJECT\" --region=\"$REGION\" --url-map='web-http-redirect-url-map'"] `inOrder` sc)+        -- the balancer's own map is the one it was+        assertEqual "" (LoadBalancing.renderUrlMap full) (LoadBalancing.renderUrlMap toHttps)+    , testCase "no HTTP listener is no proxy and no :80 rule, and a node that removes the ones there" $ do+        let none = listening LoadBalancing.NoHttp+        let ids = map LoadBalancing.partId (LoadBalancing.lbParts none)+        assertBool "" (LoadBalancing.HttpProxyPart `notElem` ids && LoadBalancing.ForwardingRulePart `notElem` ids)+        assertBool "" (LoadBalancing.NoHttpPart `elem` ids)+        assertBool "" (LoadBalancing.HttpsForwardingRulePart `elem` ids)+        let sc = createScript none+        assertBool sc (not ("target-http-proxies create" `isInfixOf` sc) && not ("--ports=80" `isInfixOf` sc))+        assertBool sc (["forwarding-rules delete 'web-fw'", "target-http-proxies delete 'web-proxy'"] `inOrder` sc)+        assertBool "the address is read off the HTTPS rule" ("web-https-fw" `elem` processArgs (prepare LoadBalancing.loadBalancingCommand (LoadBalancing.LbAddressDescribe none)))+    , testCase "the new resources come after what they depend on, and their scripts parse as bash" $ do+        let declarations = [site, toHttps, listening LoadBalancing.ServeHttp, listening LoadBalancing.NoHttp]+        mapM_+            ( \a -> do+                let parts = LoadBalancing.lbParts a+                let ids = map LoadBalancing.partId parts+                assertEqual "no resource twice" (nub ids) ids+                mapM_+                    (\(i, part) -> mapM_ (\d -> assertBool (show part.partId <> " after " <> show d) (d `elem` take i ids)) part.partDeps)+                    (zip [0 :: Int ..] parts)+                let dag = dagOf (LoadBalancing.applicationLoadBalancer silent ignoreTrack a)+                assertEqual "one node per resource, and the root" (length parts + 1) (Map.size (Dag.dagNodes dag))+                assertEqual "nothing conflicting" 0 (length (Dag.dagConflicts dag))+            )+            declarations+        mapM_+            ( \sc -> do+                (code, _, err) <- readProcessWithExitCode "bash" ["-n", "-c", Text.unpack sc] ""+                assertEqual err ExitSuccess code+            )+            [ render a part+            | a <- declarations+            , part <- LoadBalancing.lbParts a+            , render <- [LoadBalancing.renderPartUpScript, LoadBalancing.renderPartCheckScript, LoadBalancing.renderPartDownScript]+            ]+    , testGroup "a node per resource" nodeTests+    , testGroup "against a stand-in gcloud" $+        -- Not GCP: a shell script that keeps "resources" as files and answers+        -- describe/create/delete the way these scripts assume gcloud does.+        -- What it shows is the scripts' own logic -- guards, ordering,+        -- quoting, what a second run does -- and nothing about the API.+        [ testCase "up creates everything once, and a second up creates nothing more" $+            withFakeGcloud $ \run mutations -> do+                (code, _, err) <- run (createScript full)+                assertEqual err ExitSuccess code+                first <- mutations+                mapM_+                    (\w -> assertBool (w <> "\n" <> unlines first) (any (w `isInfixOf`) first))+                    ["target-https-proxies create web-https-proxy", "certificates create web-cert", "url-maps import web-url-map", "forwarding-rules create web-https-fw", "backend-services add-backend web-slow-backend"]+                (code2, _, err2) <- run (createScript full)+                assertEqual err2 ExitSuccess code2+                second <- drop (length first) <$> mutations+                -- the "set" verbs run again by design; nothing is created or attached twice+                assertBool (unlines second) (not (any (\l -> " create " `isInfixOf` l || "add-backend" `isInfixOf` l) second))+                assertBool (unlines second) (any ("url-maps import" `isInfixOf`) second)+        , testCase "a host rule taken out of the declaration is seen by the check and imported away" $+            withFakeGcloud $ \run mutations -> do+                _ <- mutationsOf run mutations (createScript full)+                let fewer = full{LoadBalancing.albHostRules = take 1 full.albHostRules}+                stale <- partVerdict run fewer LoadBalancing.UrlMapPart+                case stale of+                    [Failure t] -> assertBool (Text.unpack t) ("removal of undeclared host-rule api.example.org" `isInfixOf` Text.unpack t)+                    other -> assertBool (show other) False+                moved <- upPart run mutations fewer LoadBalancing.UrlMapPart+                assertEqual "" ["url-maps import web-url-map"] moved+                fresh <- partVerdict run fewer LoadBalancing.UrlMapPart+                assertEqual "" [Success] fresh+                (_, hosts, _) <- run "gcloud compute url-maps describe web-url-map --format='value(hostRules[].hosts)'"+                assertEqual "" "app.example.org,www.example.org\n" hosts+        , testCase "the last host rule taken away is one import of the rule-less map, and none after" $+            withFakeGcloud $ \run mutations -> do+                _ <- mutationsOf run mutations (createScript full)+                let none = full{LoadBalancing.albHostRules = []}+                stale <- partVerdict run none LoadBalancing.UrlMapPart+                case stale of+                    [Failure t] ->+                        mapM_+                            (\h -> assertBool (Text.unpack t) (("removal of undeclared host-rule " <> h) `isInfixOf` Text.unpack t))+                            ["app.example.org", "www.example.org", "api.example.org"]+                    other -> assertBool (show other) False+                moved <- upPart run mutations none LoadBalancing.UrlMapPart+                assertEqual "" ["url-maps import web-url-map"] moved+                fresh <- partVerdict run none LoadBalancing.UrlMapPart+                assertEqual "" [Success] fresh+                again <- upPart run mutations none LoadBalancing.UrlMapPart+                assertEqual "nothing is set on a map that has no rule" [] again+        , testCase "a map with no rule that never had one is created once and never imported" $+            withFakeGcloud $ \run mutations -> do+                first <- upPart run mutations alb LoadBalancing.UrlMapPart+                assertEqual "" ["url-maps create web-url-map"] first+                second <- upPart run mutations alb LoadBalancing.UrlMapPart+                assertEqual "" [] second+                v <- partVerdict run alb LoadBalancing.UrlMapPart+                assertEqual "" [Success] v+        , testCase "a bucket route turned off again is seen and imported away, with no command by hand" $+            withFakeGcloud $ \run mutations -> do+                _ <- mutationsOf run mutations (createScript site)+                stale <- partVerdict run full LoadBalancing.UrlMapPart+                case stale of+                    [Failure t] -> do+                        assertBool (Text.unpack t) ("removal of undeclared host-rule static.example.org" `isInfixOf` Text.unpack t)+                        assertBool (Text.unpack t) ("declared rules on web-url-map" `isInfixOf` Text.unpack t)+                    other -> assertBool (show other) False+                moved <- upPart run mutations full LoadBalancing.UrlMapPart+                assertEqual "" ["url-maps import web-url-map"] moved+                fresh <- partVerdict run full LoadBalancing.UrlMapPart+                assertEqual "" [Success] fresh+        , testCase "a host sent to another service is seen on a map salmon stamped, and not on one written before maps of services were" $+            withFakeGcloud $ \run mutations -> do+                _ <- mutationsOf run mutations (createScript full)+                let swapped =+                        full+                            { LoadBalancing.albHostRules =+                                [ LoadBalancing.HostRule ["app.example.org", "www.example.org"] (LoadBalancing.NamedService "api") []+                                , LoadBalancing.HostRule ["api.example.org"] LoadBalancing.DefaultService []+                                ]+                            }+                stale <- partVerdict run swapped LoadBalancing.UrlMapPart+                case stale of+                    [Failure t] -> assertBool (Text.unpack t) ("declared rules on web-url-map" `isInfixOf` Text.unpack t)+                    other -> assertBool (show other) False+                -- the map as a version before this one left it: no description+                (code, _, err) <- run "sed -i 's/\"description\":\"[^\"]*\",//' \"$FAKE_GCLOUD_STATE/url-maps.web-url-map\""+                assertEqual err ExitSuccess code+                (_, desc, _) <- run "gcloud compute url-maps describe web-url-map --format='value(description)'"+                assertEqual "" "" desc+                same <- partVerdict run full LoadBalancing.UrlMapPart+                assertEqual "an unstamped map with the declared hosts is left alone" [Success] same+                blind <- partVerdict run swapped LoadBalancing.UrlMapPart+                assertEqual "and its services are not seen" [Success] blind+        , testCase "the check of what up made is Success, and of nothing at all a Failure" $+            withFakeGcloud $ \run _ -> do+                let checkScript = Text.unpack (LoadBalancing.renderLbCheckScript full)+                (code0, out0, _) <- run checkScript+                let before = LoadBalancing.interpretLbCheck code0 (Text.pack out0)+                assertBool (show before) (isFailure before)+                _ <- run (createScript full)+                (code1, out1, err1) <- run checkScript+                assertEqual (out1 <> err1) Success (LoadBalancing.interpretLbCheck code1 (Text.pack out1))+        , testCase "the check notices a host dropped from the map and a timeout changed behind it" $+            withFakeGcloud $ \run _ -> do+                _ <- run (createScript full{LoadBalancing.albHostRules = take 1 full.albHostRules, LoadBalancing.albTimeoutSec = Just 30})+                (code, out, _) <- run (Text.unpack (LoadBalancing.renderLbCheckScript full))+                case LoadBalancing.interpretLbCheck code (Text.pack out) of+                    Failure t -> do+                        assertBool (Text.unpack t) ("host-rule api.example.org" `isInfixOf` Text.unpack t)+                        assertBool (Text.unpack t) ("timeout 60s on web-backend" `isInfixOf` Text.unpack t)+                        assertBool (Text.unpack t) (not ("host-rule app.example.org" `isInfixOf` Text.unpack t))+                    other -> assertBool (show other) False+        , testCase "down removes everything up made and leaves the caller's instance groups" $+            withFakeGcloud $ \run mutations -> do+                _ <- run (createScript full)+                (code, _, err) <- run (deleteScript full)+                assertEqual err ExitSuccess code+                (_, out, _) <- run "ls \"$FAKE_GCLOUD_STATE\""+                assertEqual "" "" out+                made <- mutations+                assertBool (unlines made) (not (any ("instance-groups delete" `isInfixOf`) made))+        , testCase "two services on one instance group each reach their own port" $+            -- The bug this pins: every backend service used the default port+            -- name, http, and every attach reset the group's http to its own+            -- port, so all of them sent to whichever service came last.+            withFakeGcloud $ \run _ -> do+                (code, _, err) <- run (createScript shared)+                assertEqual err ExitSuccess code+                let portOf svc =+                        run+                            ( "n=$(gcloud compute backend-services describe "+                                <> svc+                                <> " --format='value(portName)'); gcloud compute instance-groups get-named-ports ig | awk -v n=\"$n\" '$1==n{print $2}'"+                            )+                (_, a, _) <- portOf "web-a-backend"+                (_, b, _) <- portOf "web-b-backend"+                assertEqual "a" "4272\n" a+                assertEqual "b" "4273\n" b+                (code1, out1, err1) <- run (Text.unpack (LoadBalancing.renderLbCheckScript shared))+                assertEqual (out1 <> err1) Success (LoadBalancing.interpretLbCheck code1 (Text.pack out1))+        , testCase "a group's named ports somebody else set survive, and a second up changes nothing" $+            withFakeGcloud $ \run _ -> do+                _ <- run "gcloud compute instance-groups set-named-ports ig --named-ports=other:9000,web-a-backend:1"+                _ <- run (createScript shared)+                _ <- run (createScript shared)+                (_, out, _) <- run "gcloud compute instance-groups get-named-ports ig | tr '\\t' ':' | sort"+                assertEqual "" ["other:9000", "web-a-backend:4272", "web-b-backend:4273"] (lines out)+        , testCase "the check names a backend service on the wrong port name and a group missing a named port" $+            withFakeGcloud $ \run _ -> do+                _ <- run (createScript shared)+                _ <- run "gcloud compute backend-services update web-a-backend --port-name=http"+                _ <- run "gcloud compute instance-groups set-named-ports ig --named-ports=web-a-backend:4272,web-b-backend:9999"+                (code, out, _) <- run (Text.unpack (LoadBalancing.renderLbCheckScript shared))+                case LoadBalancing.interpretLbCheck code (Text.pack out) of+                    Failure t -> do+                        assertBool (Text.unpack t) ("port-name web-a-backend on web-a-backend" `isInfixOf` Text.unpack t)+                        assertBool (Text.unpack t) ("named-port web-b-backend:4273 on instance-group ig" `isInfixOf` Text.unpack t)+                        assertBool (Text.unpack t) (not ("named-port web-a-backend:4272" `isInfixOf` Text.unpack t))+                    other -> assertBool (show other) False+        , testCase "resource by resource: each up makes its own, each check answers for its own, each down removes its own" $+            withFakeGcloud $ \run _ -> do+                let parts = LoadBalancing.lbParts full+                let verdict part = do+                        (code, out, _) <- run (Text.unpack (LoadBalancing.renderPartCheckScript full part))+                        pure (LoadBalancing.interpretLbCheck code (Text.pack out))+                before <- mapM verdict parts+                -- but for the leftovers: with nothing there, nothing is left over+                assertBool (show before) (and [isFailure v == (part.partId /= LoadBalancing.LeftoversPart) | (part, v) <- zip parts before])+                mapM_+                    ( \part -> do+                        (code, _, err) <- run (Text.unpack (LoadBalancing.renderPartUpScript full part))+                        assertEqual (show part.partId <> ": " <> err) ExitSuccess code+                        v <- verdict part+                        assertEqual (show part.partId) Success v+                    )+                    parts+                -- one resource removed behind the balancer is one node's Failure+                _ <- run "rm \"$FAKE_GCLOUD_STATE\"/url-maps.web-url-map"+                after <- mapM verdict parts+                assertEqual+                    ""+                    [LoadBalancing.UrlMapPart]+                    [part.partId | (part, v) <- zip parts after, v /= Success]+                mapM_ (run . Text.unpack . LoadBalancing.renderPartUpScript full) [p | p <- parts, p.partId == LoadBalancing.UrlMapPart]+                (codeH, outH, _) <- run (Text.unpack (LoadBalancing.renderLbHealthScript full))+                assertEqual outH Success (LoadBalancing.interpretLbCheck codeH (Text.pack outH))+                assertBool outH ("HEALTH slow HEALTHY" `isInfixOf` outH)+                mapM_+                    ( \part -> do+                        (code, _, err) <- run (Text.unpack (LoadBalancing.renderPartDownScript full part))+                        assertEqual (show part.partId <> ": " <> err) ExitSuccess code+                    )+                    (reverse parts)+                (_, out, _) <- run "ls \"$FAKE_GCLOUD_STATE\""+                assertEqual "" "" out+        , testCase "a certificate that is somebody else's and absent fails its own node's up" $+            withFakeGcloud $ \run _ -> do+                let own = full{LoadBalancing.albCertificates = [LoadBalancing.ComputeCertificate "own"]}+                results <- mapM (run . Text.unpack . LoadBalancing.renderPartUpScript own) [p | p <- LoadBalancing.lbParts own, p.partId == LoadBalancing.CertificatePart "own"]+                assertEqual "" 1 (length results)+                mapM_+                    ( \(code, _, err) -> do+                        assertBool err (code /= ExitSuccess)+                        assertBool err ("certificate own does not exist" `isInfixOf` err)+                    )+                    results+        , testCase "a changed domain set: the proxy keeps the old certificate until the new one is ACTIVE, then moves, then the old one is deleted" $+            withFakeGcloud $ \run mutations -> do+                (code, _, err) <- run (createScript rotating)+                assertEqual err ExitSuccess code+                first <- mutations+                -- the new certificate is created and is not issued yet+                -- which is a swap pending, not a failed pass: said, exit 0, and+                -- the lines after the proxy's (the cleanup, the rules) still run+                (code1, _, err1) <- run ("export FAKE_GCLOUD_NEW_CERT_STATE=PROVISIONING\n" <> createScript rotated)+                assertEqual err1 ExitSuccess code1+                assertBool err1 (("certificate " <> Text.unpack certV2 <> " is not ACTIVE yet: PROVISIONING") `isInfixOf` err1)+                assertBool err1 ("PENDING certificate swap on web-https-proxy" `isInfixOf` err1)+                assertBool err1 (("certificate " <> Text.unpack certV1 <> " is still served by web-https-proxy: not deleted") `isInfixOf` err1)+                waiting <- drop (length first) <$> mutations+                assertBool (unlines waiting) (("certificates create " <> Text.unpack certV2) `elem` waiting)+                -- only the name the old set did not cover gets an authorization+                -- (made, and then marked as this balancer's by the leftovers part)+                assertEqual (unlines waiting) ["dns-authorizations create web-cert-www-example-org", "dns-authorizations update web-cert-www-example-org"] (filter ("dns-authorizations" `isInfixOf`) waiting)+                assertBool (unlines waiting) (not (any (\l -> "target-https-proxies" `isInfixOf` l || " delete " `isInfixOf` l) waiting))+                (_, served, _) <- run "gcloud compute target-https-proxies describe web-https-proxy --format='value(sslCertificates)'"+                assertBool served (Text.unpack certV1 `isInfixOf` served)+                -- which the proxy's check reads as waiting, not as something up would fix+                proxyBefore <- partVerdict run rotated LoadBalancing.HttpsProxyPart+                assertEqual "" [Unknown] proxyBefore+                -- and so does the cleanup's, of the certificate still in service+                sweepBefore <- partVerdict run rotated (LoadBalancing.SupersededCertificatesPart "web-cert")+                assertEqual "" [Unknown] sweepBefore+                -- every part's own up succeeds meanwhile: nothing blocks the root+                mapM_+                    ( \spec -> do+                        (code, _, err) <- run (Text.unpack (LoadBalancing.renderPartUpScript rotated spec))+                        assertEqual (show spec.partId <> ": " <> err) ExitSuccess code+                    )+                    (LoadBalancing.lbParts rotated)+                stillWaiting <- drop (length first) <$> mutations+                assertBool (unlines stillWaiting) (not (any (\l -> "target-https-proxies" `isInfixOf` l || " delete " `isInfixOf` l) stillWaiting))+                -- issued: the check wants the move, and up makes it, then cleans up+                _ <- run ("echo ACTIVE > \"$FAKE_GCLOUD_STATE\"/certificates." <> Text.unpack certV2 <> ".state")+                proxyReady <- partVerdict run rotated LoadBalancing.HttpsProxyPart+                assertBool (show proxyReady) (all isFailure proxyReady)+                before <- length <$> mutations+                (code2, _, err2) <- run (createScript rotated)+                assertEqual err2 ExitSuccess code2+                moved <- drop before <$> mutations+                assertEqual+                    (unlines moved)+                    ["target-https-proxies update web-https-proxy", "certificates delete " <> Text.unpack certV1]+                    (filter (\l -> "target-https-proxies" `isInfixOf` l || "certificates" `isInfixOf` l) moved)+                (code3, out3, err3) <- run (Text.unpack (LoadBalancing.renderLbCheckScript rotated))+                assertEqual (out3 <> err3) Success (LoadBalancing.interpretLbCheck code3 (Text.pack out3))+                -- and nothing more the next time+                before' <- length <$> mutations+                _ <- run (createScript rotated)+                again <- drop before' <$> mutations+                assertBool (unlines again) (not (any (\l -> "target-https-proxies" `isInfixOf` l || "certificates" `isInfixOf` l) again))+                -- down leaves nothing+                (code4, _, err4) <- run (deleteScript rotated)+                assertEqual err4 ExitSuccess code4+                (_, left, _) <- run "ls \"$FAKE_GCLOUD_STATE\""+                assertEqual "" "" left+        , testCase "a new certificate that FAILED fails the proxy's up, and the proxy stays where it is" $+            withFakeGcloud $ \run mutations -> do+                _ <- run (createScript rotating)+                before <- length <$> mutations+                (code, _, err) <- run ("export FAKE_GCLOUD_NEW_CERT_STATE=FAILED\n" <> createScript rotated)+                assertBool err (code /= ExitSuccess)+                assertBool err (("certificate " <> Text.unpack certV2 <> " is not ACTIVE yet: FAILED") `isInfixOf` err)+                assertBool err (not ("PENDING" `isInfixOf` err))+                after <- drop before <$> mutations+                assertBool (unlines after) (not (any (\l -> "target-https-proxies" `isInfixOf` l || " delete " `isInfixOf` l) after))+        , testCase "without a domain-set certificate a certificate not ACTIVE yet fails the proxy's up, as before" $+            withFakeGcloud $ \run mutations -> do+                _ <- run (createScript full)+                let renamed = full{LoadBalancing.albCertificates = [LoadBalancing.ManagedCertificate "web-cert-next" ["app.example.org", "api.example.org"]]}+                before <- length <$> mutations+                (code, _, err) <- run ("export FAKE_GCLOUD_NEW_CERT_STATE=PROVISIONING\n" <> createScript renamed)+                assertBool err (code /= ExitSuccess)+                assertBool err ("certificate web-cert-next is not ACTIVE yet: PROVISIONING" `isInfixOf` err)+                after <- drop before <$> mutations+                assertBool (unlines after) (not (any ("target-https-proxies" `isInfixOf`) after))+        , testCase "without a domain-set certificate the HTTPS proxy's up is the script it was" $+            assertEqual+                ""+                [ "if exists gcloud compute target-https-proxies describe 'web-https-proxy' --project=\"$PROJECT\" --region=\"$REGION\"; then"+                , "  have=$(gcloud compute target-https-proxies describe 'web-https-proxy' --project=\"$PROJECT\" --region=\"$REGION\" --format='value(sslCertificates)' 2>/dev/null | tr \";,[]' \\t\" '\\n' | sed -e 's|.*/||' -e '/^$/d' | tr 'A-Z' 'a-z' | LC_ALL=C sort -u | tr '\\n' ' ')"+                , "  if [ \"$have\" != 'web-cert ' ]; then"+                , "    st=$(gcloud certificate-manager certificates describe 'web-cert' --project=\"$PROJECT\" --location=\"$REGION\" --format='value(managed.state)' 2>/dev/null) || st=''"+                , "    [ \"$st\" = ACTIVE ] || { echo 'certificate web-cert is not ACTIVE yet:'\" ${st:-absent}\"'; web-https-proxy keeps the certificates it serves until it is. Run again then.' >&2; exit 1; }"+                , "    gcloud compute target-https-proxies update 'web-https-proxy' --project=\"$PROJECT\" --region=\"$REGION\" --certificate-manager-certificates='web-cert'"+                , "  fi"+                , "else"+                , "  gcloud compute target-https-proxies create 'web-https-proxy' --project=\"$PROJECT\" --region=\"$REGION\" --url-map='web-url-map' --url-map-region=\"$REGION\" --certificate-manager-certificates='web-cert'"+                , "fi"+                ]+                (concat [map Text.unpack spec.partUp | spec <- LoadBalancing.lbParts full, spec.partId == LoadBalancing.HttpsProxyPart])+        , testCase "a certificate under a fixed name is superseded by the domain-set one of that base" $+            withFakeGcloud $ \run mutations -> do+                _ <- run (createScript full)+                before <- length <$> mutations+                (code, _, err) <- run (createScript rotated)+                assertEqual err ExitSuccess code+                moved <- drop before <$> mutations+                assertEqual+                    (unlines moved)+                    -- the last is the ownership mark on the new certificate+                    ["certificates create " <> Text.unpack certV2, "target-https-proxies update web-https-proxy", "certificates delete web-cert", "certificates update " <> Text.unpack certV2]+                    (filter (\l -> "target-https-proxies" `isInfixOf` l || "certificates" `isInfixOf` l) moved)+        , testCase "a certificate under a fixed name whose domains changed is refused, by its up and by its check" $+            withFakeGcloud $ \run _ -> do+                _ <- run (createScript full)+                let more = full{LoadBalancing.albCertificates = [LoadBalancing.ManagedCertificate "web-cert" ["app.example.org", "api.example.org", "www.example.org"]]}+                results <- mapM (run . Text.unpack . LoadBalancing.renderPartUpScript more) [p | p <- LoadBalancing.lbParts more, p.partId == LoadBalancing.CertificatePart "web-cert"]+                assertEqual "" 1 (length results)+                mapM_+                    ( \(code, _, err) -> do+                        assertBool err (code /= ExitSuccess)+                        assertBool err ("covers other names than the declared ones" `isInfixOf` err)+                        assertBool err ("DomainSetCertificate" `isInfixOf` err)+                    )+                    results+                verdicts <- partVerdict run more (LoadBalancing.CertificatePart "web-cert")+                case verdicts of+                    [Failure t] -> assertBool (Text.unpack t) ("covers other names" `isInfixOf` Text.unpack t)+                    other -> assertBool (show other) False+                -- the same names in another order and case are the same certificate+                let same = full{LoadBalancing.albCertificates = [LoadBalancing.ManagedCertificate "web-cert" ["API.example.org", "app.example.org"]]}+                sameVerdicts <- partVerdict run same (LoadBalancing.CertificatePart "web-cert")+                assertEqual "" [Success] sameVerdicts+        , testCase "a certificate the proxy still serves is not deleted: not by its own down, not as superseded" $+            withFakeGcloud $ \run mutations -> do+                _ <- run (createScript rotating)+                results <- mapM (run . Text.unpack . LoadBalancing.renderPartDownScript rotating) [p | p <- LoadBalancing.lbParts rotating, p.partId == LoadBalancing.CertificatePart certV1]+                mapM_+                    ( \(code, _, err) -> do+                        assertBool err (code /= ExitSuccess)+                        assertBool err ("still served by web-https-proxy" `isInfixOf` err)+                    )+                    results+                -- the cleanup of the next declaration, run before the proxy has moved+                _ <- run "gcloud certificate-manager certificates create web-cert-00000000 --domains=x.example.org"+                swept <- mapM (run . Text.unpack . LoadBalancing.renderPartUpScript rotated) [p | p <- LoadBalancing.lbParts rotated, p.partId == LoadBalancing.SupersededCertificatesPart "web-cert"]+                assertEqual "" 1 (length swept)+                -- left and said, the rest of the base's swept: a swap in progress, not a failure+                mapM_ (\(code, _, err) -> assertBool err (code == ExitSuccess && ("certificate " <> Text.unpack certV1 <> " is still served by") `isInfixOf` err)) swept+                made <- mutations+                assertBool (unlines made) (("certificates delete " <> Text.unpack certV1) `notElem` made)+                assertBool (unlines made) ("certificates delete web-cert-00000000" `elem` made)+        , testCase "the cleanup removes only its base's certificates, and its check names what is left" $+            withFakeGcloud $ \run _ -> do+                _ <- run (createScript rotated)+                mapM_+                    (\n -> run ("gcloud certificate-manager certificates create " <> n <> " --domains=x.example.org"))+                    ["web-cert-0123abcd", "web-cert", "web-cert-other", "web-certificate", "other-cert-0123abcd", "web-cert-0123abcdef"]+                verdicts <- partVerdict run rotated (LoadBalancing.SupersededCertificatesPart "web-cert")+                case verdicts of+                    [Failure t] -> assertBool (Text.unpack t) ("superseded certificate web-cert-0123abcd" `isInfixOf` Text.unpack t)+                    other -> assertBool (show other) False+                swept <- mapM (run . Text.unpack . LoadBalancing.renderPartUpScript rotated) [p | p <- LoadBalancing.lbParts rotated, p.partId == LoadBalancing.SupersededCertificatesPart "web-cert"]+                mapM_ (\(code, _, err) -> assertEqual err ExitSuccess code) swept+                (_, out, _) <- run "gcloud certificate-manager certificates list --format='value(name)' | sed 's|.*/||' | sort"+                assertEqual "" (sort [Text.unpack certV2, "web-cert-other", "web-certificate", "other-cert-0123abcd", "web-cert-0123abcdef"]) (lines out)+                after <- partVerdict run rotated (LoadBalancing.SupersededCertificatesPart "web-cert")+                assertEqual "" [Success] after+        , testCase "an ACTIVE certificate whose renewal failed, or that expired, is a Failure" $+            withFakeGcloud $ \run _ -> do+                _ <- run (createScript rotated)+                healthy <- partVerdict run rotated (LoadBalancing.CertificatePart certV2)+                assertEqual "" [Success] healthy+                let stateFile suffix = "\"$FAKE_GCLOUD_STATE\"/certificates." <> Text.unpack certV2 <> suffix+                _ <- run ("echo 'AUTHORIZED;FAILED' > " <> stateFile ".attempts")+                failed <- partVerdict run rotated (LoadBalancing.CertificatePart certV2)+                case failed of+                    [Failure t] -> assertBool (Text.unpack t) ("authorization attempt FAILED" `isInfixOf` Text.unpack t)+                    other -> assertBool (show other) False+                _ <- run ("rm " <> stateFile ".attempts; echo 2001-01-01T00:00:00Z > " <> stateFile ".expire")+                expired <- partVerdict run rotated (LoadBalancing.CertificatePart certV2)+                case expired of+                    [Failure t] -> assertBool (Text.unpack t) ("expired 2001-01-01T00:00:00Z" `isInfixOf` Text.unpack t)+                    other -> assertBool (show other) False+                _ <- run ("echo 2999-01-01T00:00:00Z > " <> stateFile ".expire")+                later <- partVerdict run rotated (LoadBalancing.CertificatePart certV2)+                assertEqual "" [Success] later+        , testCase "a compute certificate swapped for another is an update of the proxy, with nothing to wait for" $+            withFakeGcloud $ \run mutations -> do+                let own n = full{LoadBalancing.albCertificates = [LoadBalancing.ComputeCertificate n]}+                _ <- run "gcloud compute ssl-certificates create own-1; gcloud compute ssl-certificates create own-2"+                _ <- run (createScript (own "own-1"))+                stale <- partVerdict run (own "own-2") LoadBalancing.HttpsProxyPart+                assertBool (show stale) (all isFailure stale)+                before <- length <$> mutations+                (code, _, err) <- run (createScript (own "own-2"))+                assertEqual err ExitSuccess code+                moved <- drop before <$> mutations+                assertEqual (unlines moved) ["target-https-proxies update web-https-proxy"] (filter (\l -> "target-https-proxies" `isInfixOf` l || "certificates" `isInfixOf` l) moved)+                fresh <- partVerdict run (own "own-2") LoadBalancing.HttpsProxyPart+                assertEqual "" [Success] fresh+        , testCase "a bucket and redirects go up once, check, follow a changed redirect and a changed bucket, and come down" $+            withFakeGcloud $ \run mutations -> do+                first <- mutationsOf run mutations (createScript site)+                assertBool (unlines first) ("backend-buckets create web-site-bucket" `elem` first)+                (said, v) <- wholeVerdict run site+                assertEqual said Success v+                second <- mutationsOf run mutations (createScript site)+                assertBool (unlines second) (not (any (\l -> " create " `isInfixOf` l || "backend-buckets" `isInfixOf` l || "target-http-proxies" `isInfixOf` l) second))+                -- a redirect sent elsewhere is the same hosts, and still a different map+                let moved = siteTo "web.example.org"+                stale <- partVerdict run moved LoadBalancing.UrlMapPart+                case stale of+                    [Failure t] -> assertBool (Text.unpack t) ("declared rules on web-url-map" `isInfixOf` Text.unpack t)+                    other -> assertBool (show other) False+                _ <- mutationsOf run mutations (createScript moved)+                fresh <- partVerdict run moved LoadBalancing.UrlMapPart+                assertEqual "" [Success] fresh+                -- another Cloud Storage bucket behind the same backend bucket+                let other = moved{LoadBalancing.albBuckets = [LoadBalancing.BackendBucket "site" "example-site-2"]}+                wrong <- partVerdict run other (LoadBalancing.BackendBucketPart "web-site-bucket")+                case wrong of+                    [Failure t] -> assertBool (Text.unpack t) ("gcs-bucket example-site-2 on web-site-bucket" `isInfixOf` Text.unpack t)+                    o -> assertBool (show o) False+                third <- mutationsOf run mutations (createScript other)+                assertBool (unlines third) ("backend-buckets update web-site-bucket" `elem` third)+                (said', v') <- wholeVerdict run other+                assertEqual said' Success v'+                _ <- mutationsOf run mutations (deleteScript other)+                (_, left, _) <- run "ls \"$FAKE_GCLOUD_STATE\""+                assertEqual "" "" left+        , testCase "an HTTP proxy somebody repointed is left where they put it, pass after pass" $+            -- The second writer this pins: a deployment that points the HTTP+            -- proxy at a redirect-only map of its own. Nothing declared about+            -- port 80 means the proxy's map is not this balancer's to set.+            withFakeGcloud $ \run mutations -> do+                _ <- mutationsOf run mutations (createScript full)+                _ <- run "gcloud compute target-http-proxies update web-proxy --url-map=theirs"+                mapM_+                    ( \a -> do+                        again <- mutationsOf run mutations (createScript a)+                        assertBool (unlines again) (not (any ("target-http-proxies" `isInfixOf`) again))+                        on <- proxyMap run+                        assertEqual "" "theirs\n" on+                        (said, v) <- wholeVerdict run a+                        assertEqual said Success v+                    )+                    [full, site]+        , testCase "redirecting HTTP to HTTPS takes the proxy over, once; serving HTTP puts it back" $+            withFakeGcloud $ \run mutations -> do+                _ <- mutationsOf run mutations (createScript full)+                _ <- run "gcloud compute target-http-proxies update web-proxy --url-map=theirs"+                before <- partVerdict run toHttps LoadBalancing.HttpProxyPart+                assertBool (show before) (all isFailure before)+                taken <- mutationsOf run mutations (createScript toHttps)+                assertEqual+                    (unlines taken)+                    ["url-maps import web-http-redirect-url-map", "target-http-proxies update web-proxy"]+                    (filter (\l -> "target-http-proxies" `isInfixOf` l || "http-redirect" `isInfixOf` l) taken)+                on <- proxyMap run+                assertEqual "" "web-http-redirect-url-map\n" on+                (said, v) <- wholeVerdict run toHttps+                assertEqual said Success v+                again <- mutationsOf run mutations (createScript toHttps)+                assertBool (unlines again) (not (any ("target-http-proxies" `isInfixOf`) again))+                -- the other declaration about port 80+                let serving = listening LoadBalancing.ServeHttp+                stale <- partVerdict run serving LoadBalancing.HttpProxyPart+                case stale of+                    [Failure t] -> assertBool (Text.unpack t) ("url-map web-url-map on web-proxy" `isInfixOf` Text.unpack t)+                    other -> assertBool (show other) False+                back <- mutationsOf run mutations (createScript serving)+                assertEqual (unlines back) ["target-http-proxies update web-proxy"] (filter ("target-http-proxies" `isInfixOf`) back)+                on' <- proxyMap run+                assertEqual "" "web-url-map\n" on'+        , testCase "a balancer that redirects HTTP from the start goes up, checks and comes down" $+            withFakeGcloud $ \run mutations -> do+                made <- mutationsOf run mutations (createScript toHttps)+                assertBool (unlines made) ("target-http-proxies create web-proxy" `elem` made && "target-http-proxies update web-proxy" `notElem` made)+                on <- proxyMap run+                assertEqual "" "web-http-redirect-url-map\n" on+                (said, v) <- wholeVerdict run toHttps+                assertEqual said Success v+                _ <- mutationsOf run mutations (deleteScript toHttps)+                (_, left, _) <- run "ls \"$FAKE_GCLOUD_STATE\""+                assertEqual "" "" left+        , testCase "no HTTP listener removes the :80 rule and then the proxy, and nothing else" $+            withFakeGcloud $ \run mutations -> do+                _ <- mutationsOf run mutations (createScript full)+                let none = listening LoadBalancing.NoHttp+                before <- partVerdict run none LoadBalancing.NoHttpPart+                case before of+                    [Failure t] -> assertBool (Text.unpack t) ("removal of forwarding-rules web-fw" `isInfixOf` Text.unpack t && "removal of target-http-proxies web-proxy" `isInfixOf` Text.unpack t)+                    other -> assertBool (show other) False+                gone <- mutationsOf run mutations (createScript none)+                assertEqual (unlines gone) ["forwarding-rules delete web-fw", "target-http-proxies delete web-proxy"] (filter (\l -> " delete " `isInfixOf` l || " create " `isInfixOf` l) gone)+                (said, v) <- wholeVerdict run none+                assertEqual said Success v+                _ <- mutationsOf run mutations (deleteScript none)+                (_, left, _) <- run "ls \"$FAKE_GCLOUD_STATE\""+                assertEqual "" "" left+        , testCase "leftovers: the part is the last, after the map, the proxies and the superseded certificates, and writes no proxy" $ do+            let declarations = [alb, full, site, rotated, toHttps, listening LoadBalancing.ServeHttp, listening LoadBalancing.NoHttp]+            mapM_ (\a -> assertEqual "" [leftovers] (take 1 (reverse (map LoadBalancing.partId (LoadBalancing.lbParts a))))) declarations+            assertEqual "" [LoadBalancing.UrlMapPart, LoadBalancing.HttpProxyPart] (depsOf alb leftovers)+            assertEqual+                ""+                [LoadBalancing.UrlMapPart, LoadBalancing.BackendBucketPart "web-site-bucket", LoadBalancing.CertificatePart "web-cert", LoadBalancing.HttpProxyPart, LoadBalancing.HttpsProxyPart]+                (depsOf site leftovers)+            assertBool "" (LoadBalancing.SupersededCertificatesPart "web-cert" `elem` depsOf rotated leftovers)+            assertBool "" (LoadBalancing.NoHttpPart `elem` depsOf (listening LoadBalancing.NoHttp) leftovers)+            assertEqual "nothing of it at teardown" [] (concat [p.partDown | a <- declarations, p <- LoadBalancing.lbParts a, p.partId == leftovers])+            assertEqual "" "salmon:lb:web" (LoadBalancing.ownershipMarker alb)+            -- while HTTP is redirected the redirect-only map is declared, and not looked at+            assertBool "" (not ("plan_redirect_map" `isInfixOf` leftoverLines toHttps))+            assertBool "" ("plan_redirect_map" `isInfixOf` leftoverLines full)+            mapM_+                ( \a ->+                    mapM_+                        (\w -> assertBool (w <> "\n" <> leftoverLines a) (not (w `isInfixOf` leftoverLines a)))+                        ["proxies update", "proxies delete", "proxies create", "url-maps import", "|| true", "ssl-certificates delete"]+                )+                declarations+        , testCase "leftovers, the first pass after an upgrade: what is declared is marked, once, and nothing is deleted" $+            withFakeGcloud $ \run mutations -> do+                let live = site{LoadBalancing.albCertificates = rotating.albCertificates}+                _ <- upBefore run mutations live+                -- what an earlier declaration left behind before anything was marked, and what is somebody else's+                _ <- run "gcloud compute backend-buckets create web-old-bucket --gcs-bucket-name=old; gcloud compute backend-buckets create web-eu-assets-bucket --gcs-bucket-name=eu; gcloud certificate-manager certificates create web-cert-gone --domains=gone.example.org; gcloud certificate-manager dns-authorizations create web-cert-gone-example-org"+                before <- partVerdict run live leftovers+                case before of+                    [Failure t] -> do+                        mapM_+                            (\w -> assertBool (w <> ": " <> Text.unpack t) (w `isInfixOf` Text.unpack t))+                            ["ownership mark on backend-buckets web-site-bucket", "ownership mark on certificates " <> Text.unpack certV1, "ownership mark on dns-authorizations web-cert-app-example-org"]+                        assertBool (Text.unpack t) (not ("removal" `isInfixOf` Text.unpack t))+                    other -> assertBool (show other) False+                first <- upPart run mutations live leftovers+                assertEqual+                    ""+                    (sort ["backend-buckets update web-site-bucket", "certificates update " <> Text.unpack certV1, "dns-authorizations update web-cert-api-example-org", "dns-authorizations update web-cert-app-example-org"])+                    (sort first)+                after <- partVerdict run live leftovers+                assertEqual "" [Success] after+                second <- upPart run mutations live leftovers+                assertEqual "" [] second+                (said, v) <- wholeVerdict run live+                assertEqual said Success v+                buckets <- listOf run "compute backend-buckets"+                assertEqual "" ["web-eu-assets-bucket", "web-old-bucket", "web-site-bucket"] buckets+        , testCase "leftovers: a backend bucket no longer declared goes once the map no longer names it, and only this balancer's" $+            withFakeGcloud $ \run mutations -> do+                _ <- mutationsOf run mutations (createScript site)+                let others =+                        [ ("web-hand-bucket", "") -- this balancer's name scheme, no mark+                        , ("web-eu-assets-bucket", " --description=salmon:lb:web-eu") -- balancer web-eu's+                        , ("web-kept-bucket", " --description=theirs")+                        , ("other-site-bucket", " --description=salmon:lb:web") -- the mark, not the name+                        , ("web-bucket-site", " --description=salmon:lb:web")+                        ]+                mapM_ (\(n, d) -> run ("gcloud compute backend-buckets create " <> n <> " --gcs-bucket-name=x" <> d)) others+                -- the declaration no longer has it, the live map still does+                early <- partVerdict run full leftovers+                assertEqual "" [Unknown] early+                held <- upPart run mutations full leftovers+                assertEqual "" [] held+                gone <- mutationsOf run mutations (createScript full)+                assertEqual+                    (unlines gone)+                    ["url-maps import web-url-map", "backend-buckets delete web-site-bucket"]+                    (filter (\l -> "url-maps import" `isInfixOf` l || " delete " `isInfixOf` l) gone)+                left <- listOf run "compute backend-buckets"+                assertEqual "" (sort (map fst others)) left+                (said, v) <- wholeVerdict run full+                assertEqual said Success v+                again <- mutationsOf run mutations (createScript full)+                assertEqual (unlines again) [] (deletes again)+        , testCase "the outage: a live map with a bucket rule, the rule dropped from the declaration, one pass imports it away" $+            withFakeGcloud $ \run mutations -> do+                -- the balancer as a version without the leftovers part left it, bucket rule in the map+                _ <- upBefore run mutations site+                let liveMap = (\(_, out, _) -> out) <$> run "cat \"$FAKE_GCLOUD_STATE\"/url-maps.web-url-map"+                before <- liveMap+                assertBool before ("/backendBuckets/web-site-bucket" `isInfixOf` before && "static.example.org" `isInfixOf` before)+                -- the declaration with that one rule dropped, its bucket still declared+                stale <- partVerdict run unrouted LoadBalancing.UrlMapPart+                case stale of+                    [Failure t] -> assertBool (Text.unpack t) ("removal of undeclared host-rule static.example.org" `isInfixOf` Text.unpack t)+                    other -> assertBool (show other) False+                (said, whole) <- wholeVerdict run unrouted+                assertBool said (isFailure whole)+                -- the map's own node is enough, and does one thing+                repaired <- upPart run mutations unrouted LoadBalancing.UrlMapPart+                assertEqual "" ["url-maps import web-url-map"] repaired+                after <- liveMap+                assertBool after (not ("backendBuckets" `isInfixOf` after) && not ("static.example.org" `isInfixOf` after))+                -- every service rule and the redirect are in it as declared+                mapM_ (\w -> assertBool (w <> "\n" <> after) (w `isInfixOf` after)) ["app.example.org", "api.example.org", "/backendServices/web-slow-backend", "/backendServices/web-api-backend", "\"hosts\":[\"example.org\"]", "defaultUrlRedirect"]+                fresh <- partVerdict run unrouted LoadBalancing.UrlMapPart+                assertEqual "" [Success] fresh+                -- the rest of the pass: the bucket, still declared, is kept and marked; nothing is deleted+                rest <- mutationsOf run mutations (createScript unrouted)+                assertEqual (unlines rest) [] (deletes rest)+                assertBool (unlines rest) ("backend-buckets update web-site-bucket" `elem` rest)+                (said', v) <- wholeVerdict run unrouted+                assertEqual said' Success v+                -- and a second pass imports (the map's "set" verb) the same map+                again <- mutationsOf run mutations (createScript unrouted)+                assertEqual (unlines again) ["url-maps import web-url-map"] (filter (\l -> "url-maps" `isInfixOf` l || "backend-buckets" `isInfixOf` l) again)+                same <- liveMap+                assertEqual "" after same+                -- the bucket dropped from the declaration afterwards: marked by now, in no map, deleted+                let bare = unrouted{LoadBalancing.albBuckets = []}+                gone <- mutationsOf run mutations (createScript bare)+                assertEqual (unlines gone) ["backend-buckets delete web-site-bucket"] (deletes gone)+                (said'', v') <- wholeVerdict run bare+                assertEqual said'' Success v'+        , testCase "the outage: rule and bucket dropped at once, the map is imported first and the bucket goes only if it was ever marked" $ do+            let bare = unrouted{LoadBalancing.albBuckets = []}+            let relevant = filter (\l -> "url-maps" `isInfixOf` l || "backend-buckets" `isInfixOf` l)+            -- marked (a pass of a version with the leftovers part ran while the bucket was declared)+            withFakeGcloud $ \run mutations -> do+                _ <- mutationsOf run mutations (createScript site)+                -- the cleanup alone, before the map is imported, holds: the live map still names the bucket+                early <- partVerdict run bare leftovers+                assertEqual "" [Unknown] early+                held <- upPart run mutations bare leftovers+                assertEqual "" [] held+                made <- mutationsOf run mutations (createScript bare)+                assertEqual (unlines made) ["url-maps import web-url-map", "backend-buckets delete web-site-bucket"] (relevant made)+                buckets <- listOf run "compute backend-buckets"+                assertEqual "" [] buckets+                (said, v) <- wholeVerdict run bare+                assertEqual said Success v+            -- never marked (dropped in the first pass of a version with the leftovers part)+            withFakeGcloud $ \run mutations -> do+                _ <- upBefore run mutations site+                made <- mutationsOf run mutations (createScript bare)+                assertEqual (unlines made) ["url-maps import web-url-map"] (relevant made)+                (_, live, _) <- run "cat \"$FAKE_GCLOUD_STATE\"/url-maps.web-url-map"+                assertBool live (not ("backendBuckets" `isInfixOf` live))+                -- in no map any more, and left: nothing says it is this balancer's+                buckets <- listOf run "compute backend-buckets"+                assertEqual "" ["web-site-bucket"] buckets+                (said, v) <- wholeVerdict run bare+                assertEqual said Success v+        , testCase "leftovers: a host-set certificate given up for one under a fixed name goes after the proxy has moved, then its authorizations" $+            withFakeGcloud $ \run mutations -> do+                let fixed = full{LoadBalancing.albCertificates = [LoadBalancing.ManagedCertificate "web-fixed" ["app.example.org", "api.example.org"]]}+                _ <- mutationsOf run mutations (createScript rotating)+                -- one this balancer's naming would claim and nothing marks, and another balancer's+                _ <- run "gcloud certificate-manager certificates create web-cert-0123abcd --domains=x.example.org; gcloud certificate-manager certificates create web-other --domains=y.example.org --description=salmon:lb:other; gcloud certificate-manager dns-authorizations create web-cert-x-example-org; gcloud certificate-manager dns-authorizations create other-y --description=salmon:lb:other"+                -- the new certificate is not issued: the proxy's up fails and it keeps serving the old one+                (code, _, _) <- run ("export FAKE_GCLOUD_NEW_CERT_STATE=PROVISIONING\n" <> createScript fixed)+                assertBool "" (code /= ExitSuccess)+                held <- upPart run mutations fixed leftovers+                assertEqual (unlines held) [] (deletes held)+                waiting <- partVerdict run fixed leftovers+                assertEqual "" [Unknown] waiting+                (_, served, _) <- run "gcloud compute target-https-proxies describe web-https-proxy --format='value(sslCertificates)'"+                assertBool served (Text.unpack certV1 `isInfixOf` served)+                _ <- run "echo ACTIVE > \"$FAKE_GCLOUD_STATE\"/certificates.web-fixed.state"+                moved <- mutationsOf run mutations (createScript fixed)+                assertEqual+                    (unlines moved)+                    [ "target-https-proxies update web-https-proxy"+                    , "certificates delete " <> Text.unpack certV1+                    , "dns-authorizations delete web-cert-api-example-org"+                    , "dns-authorizations delete web-cert-app-example-org"+                    ]+                    (filter (\l -> "target-https-proxies" `isInfixOf` l || " delete " `isInfixOf` l) moved)+                certs <- listOf run "certificate-manager certificates"+                assertEqual "" ["web-cert-0123abcd", "web-fixed", "web-other"] certs+                authz <- listOf run "certificate-manager dns-authorizations"+                assertEqual "" ["other-y", "web-cert-x-example-org", "web-fixed-api-example-org", "web-fixed-app-example-org"] authz+                (said, v) <- wholeVerdict run fixed+                assertEqual said Success v+        , testCase "leftovers: the authorization of a name a set no longer covers goes once no certificate uses it" $+            withFakeGcloud $ \run mutations -> do+                _ <- mutationsOf run mutations (createScript rotated)+                -- one name fewer: the new certificate pending, the old one still served and still using the authorization+                pending <- mutationsOf run mutations ("export FAKE_GCLOUD_NEW_CERT_STATE=PROVISIONING\n" <> createScript rotating)+                assertEqual (unlines pending) [] (deletes pending)+                waiting <- partVerdict run rotating leftovers+                assertEqual "" [Unknown] waiting+                _ <- run ("echo ACTIVE > \"$FAKE_GCLOUD_STATE\"/certificates." <> Text.unpack certV1 <> ".state")+                moved <- mutationsOf run mutations (createScript rotating)+                assertEqual+                    (unlines moved)+                    ["target-https-proxies update web-https-proxy", "certificates delete " <> Text.unpack certV2, "dns-authorizations delete web-cert-www-example-org"]+                    (filter (\l -> "target-https-proxies" `isInfixOf` l || " delete " `isInfixOf` l) moved)+                (said, v) <- wholeVerdict run rotating+                assertEqual said Success v+        , testCase "leftovers: the redirect-only map goes once no proxy is on it, and the HTTP proxy is never moved for it" $+            withFakeGcloud $ \run mutations -> do+                let proxiesAndDeletes = filter (\l -> "target-http-proxies" `isInfixOf` l || " delete " `isInfixOf` l)+                _ <- mutationsOf run mutations (createScript toHttps)+                -- a declaration that sets the proxy moves it first+                back <- mutationsOf run mutations (createScript (listening LoadBalancing.ServeHttp))+                assertEqual (unlines back) ["target-http-proxies update web-proxy", "url-maps delete web-http-redirect-url-map"] (proxiesAndDeletes back)+                -- the default never sets the proxy: it stays on the redirect map, and so does the map+                _ <- mutationsOf run mutations (createScript toHttps)+                kept <- mutationsOf run mutations (createScript full)+                assertEqual (unlines kept) [] (proxiesAndDeletes kept)+                on <- proxyMap run+                assertEqual "" "web-http-redirect-url-map\n" on+                (said, v) <- wholeVerdict run full+                assertEqual said Success v+                -- somebody points the proxy at a map of their own: the map this balancer wrote goes, theirs and the proxy stay+                _ <- run "printf '%s' '{\"name\":\"theirs\",\"description\":\"redirect\"}' | gcloud compute url-maps import theirs; gcloud compute target-http-proxies update web-proxy --url-map=theirs"+                stale <- partVerdict run full leftovers+                assertBool (show stale) (all isFailure stale)+                swept <- mutationsOf run mutations (createScript full)+                assertEqual (unlines swept) ["url-maps delete web-http-redirect-url-map"] (proxiesAndDeletes swept)+                on' <- proxyMap run+                assertEqual "" "theirs\n" on'+                maps <- listOf run "compute url-maps"+                assertEqual "" ["theirs", "web-url-map"] maps+        , testCase "leftovers: a redirect map of that name somebody else wrote is not this balancer's, on the proxy or off it" $+            withFakeGcloud $ \run mutations -> do+                let untouched a = do+                        again <- mutationsOf run mutations (createScript a)+                        assertEqual (unlines again) [] (filter (\l -> "target-http-proxies" `isInfixOf` l || " delete " `isInfixOf` l) again)+                        (said, v) <- wholeVerdict run a+                        assertEqual said Success v+                _ <- mutationsOf run mutations (createScript full)+                _ <- run "printf '%s' '{\"name\":\"web-http-redirect-url-map\",\"description\":\"by hand\"}' | gcloud compute url-maps import web-http-redirect-url-map"+                mapM_ untouched [full, listening LoadBalancing.ServeHttp]+                _ <- run "gcloud compute target-http-proxies update web-proxy --url-map=web-http-redirect-url-map"+                untouched full+                on <- proxyMap run+                assertEqual "" "web-http-redirect-url-map\n" on+        , testCase "leftovers: a delete that fails fails the part's up; a listing that fails is said and is nothing to clean" $+            withFakeGcloud $ \run mutations -> do+                _ <- mutationsOf run mutations (createScript site)+                _ <- upPart run mutations full LoadBalancing.UrlMapPart+                let up = concat [Text.unpack (LoadBalancing.renderPartUpScript full spec) | spec <- LoadBalancing.lbParts full, spec.partId == leftovers]+                (code, _, err) <- run ("export FAKE_GCLOUD_FAIL='backend-buckets delete'\n" <> up)+                assertBool err (code /= ExitSuccess)+                assertBool err ("removing backend-buckets web-site-bucket" `isInfixOf` err)+                still <- partVerdict run full leftovers+                assertBool (show still) (all isFailure still)+                (code2, _, err2) <- run ("export FAKE_GCLOUD_FAIL='certificates list'\n" <> up)+                assertEqual err2 ExitSuccess code2+                assertBool err2 ("left: certificates - (could not be listed)" `isInfixOf` err2)+                made <- mutations+                assertBool (unlines made) ("backend-buckets delete web-site-bucket" `elem` made)+        , testCase "a plain balancer goes up, checks and comes down the same way" $+            withFakeGcloud $ \run _ -> do+                (code, _, err) <- run (createScript alb)+                assertEqual err ExitSuccess code+                (code1, out1, _) <- run (Text.unpack (LoadBalancing.renderLbCheckScript alb))+                assertEqual out1 Success (LoadBalancing.interpretLbCheck code1 (Text.pack out1))+                (code2, _, err2) <- run (deleteScript alb)+                assertEqual err2 ExitSuccess code2+                (_, out, _) <- run "ls \"$FAKE_GCLOUD_STATE\""+                assertEqual "" "" out+        ]+    ]+  where+    dagOf :: Op -> Dag.Dag Extension+    dagOf = Dag.foldDag Dag.sameRepresentative . evalDeps+    depsOf a part = concat [p.partDeps | p <- LoadBalancing.lbParts a, p.partId == part]+    nodeTests =+        [ testCase "every resource comes after the ones it depends on, and names only resources that exist" $+            mapM_+                ( \a -> do+                    let parts = LoadBalancing.lbParts a+                    let ids = map LoadBalancing.partId parts+                    assertEqual "no resource twice" (nub ids) ids+                    mapM_+                        ( \(i, part) ->+                            mapM_+                                (\d -> assertBool (show part.partId <> " after " <> show d) (d `elem` take i ids))+                                part.partDeps+                        )+                        (zip [0 :: Int ..] parts)+                )+                [alb, full, shared]+        , testCase "the declared resources of a balancer with every feature" $+            assertEqual+                ""+                [ LoadBalancing.HealthCheckPart "hc"+                , LoadBalancing.NamedPortsPart "ig" (LoadBalancing.InstanceGroupZone "europe-west1-b")+                , LoadBalancing.NamedPortsPart "ig-slow" (LoadBalancing.InstanceGroupZone "europe-west1-c")+                , LoadBalancing.BackendServicePart "web-backend"+                , LoadBalancing.BackendPart "web-backend" "ig"+                , LoadBalancing.BackendServicePart "web-slow-backend"+                , LoadBalancing.BackendPart "web-slow-backend" "ig-slow"+                , LoadBalancing.BackendServicePart "web-api-backend"+                , LoadBalancing.NetworkEndpointGroupPart "web-api-neg"+                , LoadBalancing.BackendPart "web-api-backend" "web-api-neg"+                , LoadBalancing.UrlMapPart+                , LoadBalancing.DnsAuthorizationPart "web-cert-app-example-org"+                , LoadBalancing.DnsAuthorizationPart "web-cert-api-example-org"+                , LoadBalancing.CertificatePart "web-cert"+                , LoadBalancing.AddressPart+                , LoadBalancing.HttpProxyPart+                , LoadBalancing.HttpsProxyPart+                , LoadBalancing.ForwardingRulePart+                , LoadBalancing.HttpsForwardingRulePart+                , LoadBalancing.LeftoversPart+                ]+                (map LoadBalancing.partId (LoadBalancing.lbParts full))+        , testCase "the edges between resources" $ do+            assertEqual "a backend service needs its health check" [LoadBalancing.HealthCheckPart "hc"] (depsOf full (LoadBalancing.BackendServicePart "web-slow-backend"))+            assertEqual "a Cloud Run service has none" [] (depsOf full (LoadBalancing.BackendServicePart "web-api-backend"))+            assertEqual+                "an attachment needs its service and the group's named ports"+                [LoadBalancing.BackendServicePart "web-backend", LoadBalancing.NamedPortsPart "ig" (LoadBalancing.InstanceGroupZone "europe-west1-b")]+                (depsOf full (LoadBalancing.BackendPart "web-backend" "ig"))+            assertEqual+                "a second attachment on one service waits for the first"+                [LoadBalancing.BackendServicePart "web-backend", LoadBalancing.NetworkEndpointGroupPart "web-neg", LoadBalancing.BackendPart "web-backend" "ig"]+                (depsOf alb (LoadBalancing.BackendPart "web-backend" "web-neg"))+            assertEqual+                "the URL map needs every service it names"+                (map LoadBalancing.BackendServicePart ["web-backend", "web-slow-backend", "web-api-backend"])+                (depsOf full LoadBalancing.UrlMapPart)+            assertEqual+                "a certificate needs its authorizations"+                (map LoadBalancing.DnsAuthorizationPart ["web-cert-app-example-org", "web-cert-api-example-org"])+                (depsOf full (LoadBalancing.CertificatePart "web-cert"))+            assertEqual "the HTTPS proxy needs the map and the certificate" [LoadBalancing.UrlMapPart, LoadBalancing.CertificatePart "web-cert"] (depsOf full LoadBalancing.HttpsProxyPart)+            assertEqual "a rule needs its proxy and the address" [LoadBalancing.HttpsProxyPart, LoadBalancing.AddressPart] (depsOf full LoadBalancing.HttpsForwardingRulePart)+            assertEqual "without HTTPS there is no address to need" [LoadBalancing.HttpProxyPart] (depsOf alb LoadBalancing.ForwardingRulePart)+        , testCase "the balancer is a root over one node per resource, with the declared edges" $ do+            let dag = dagOf (LoadBalancing.applicationLoadBalancer silent ignoreTrack full)+            let parts = LoadBalancing.lbParts full+            assertEqual "one node per resource, and the root" (length parts + 1) (Map.size (Dag.dagNodes dag))+            let byHelp = Map.fromList [(act.extension.help, r) | (r, act) <- Map.toList (Dag.dagNodes dag)]+            let refsOf ps = Set.fromList [r | p <- ps, Just r <- [Map.lookup (LoadBalancing.partHelp p) byHelp]]+            let dependencies r = Set.fromList (Map.findWithDefault [] r (Dag.dagDependencies dag))+            assertEqual "helps tell the nodes apart" (length parts + 1) (Map.size byHelp)+            assertEqual "every resource is a node" (length parts) (Set.size (refsOf parts))+            mapM_+                ( \part ->+                    assertEqual+                        (show part.partId)+                        [refsOf [p | p <- parts, p.partId `elem` part.partDeps]]+                        (map dependencies (Set.toList (refsOf [part])))+                )+                parts+            assertEqual+                "the root depends on every resource"+                (Just (refsOf parts))+                (dependencies <$> Map.lookup "application load balancer web" byHelp)+        , testCase "prerequisites go under every resource, not only under the root" $ do+            let beforeRef = mkRef "test-before" ("x" :: Text.Text)+            let before = op "before" nodeps (\actions -> actions{help = "before", ref = beforeRef})+            let dag = dagOf (LoadBalancing.applicationLoadBalancerAfter [before] silent ignoreTrack alb)+            mapM_+                ( \(r, act) ->+                    assertBool+                        (Text.unpack act.extension.help)+                        (r == beforeRef || beforeRef `elem` Map.findWithDefault [] r (Dag.dagDependencies dag))+                )+                (Map.toList (Dag.dagNodes dag))+        , testCase "one resource's node is the balancer's own" $ do+            let part = LoadBalancing.applicationLoadBalancerPart [] silent ignoreTrack full (LoadBalancing.DnsAuthorizationPart "web-cert-app-example-org")+            case part of+                Nothing -> assertBool "no such node" False+                Just o -> do+                    let dag = dagOf (LoadBalancing.applicationLoadBalancer silent ignoreTrack full `inject` o)+                    assertEqual "nothing new" (length (LoadBalancing.lbParts full) + 1) (Map.size (Dag.dagNodes dag))+                    assertEqual "nothing conflicting" 0 (length (Dag.dagConflicts dag))+                    assertEqual "an authorization stands alone" 1 (Map.size (Dag.dagNodes (dagOf o)))+            assertBool+                "a resource the declaration does not have"+                (null (LoadBalancing.applicationLoadBalancerPart [] silent ignoreTrack alb LoadBalancing.AddressPart))+        , testCase "one resource's own prerequisites go under that resource alone: a backend bucket after its storage bucket" $ do+            let bucketed = full{LoadBalancing.albBuckets = [LoadBalancing.BackendBucket "site" "example-site", LoadBalancing.BackendBucket "docs" "example-docs"]}+            let storageRef gcs = mkRef "test-storage" (gcs :: Text.Text)+            let storage gcs = op "storage" nodeps (\actions -> actions{help = "storage " <> gcs, ref = storageRef gcs})+            let needs = LoadBalancing.afterStorageBuckets bucketed (\b -> [storage b.backendBucketGcsBucket])+            assertEqual "" (LoadBalancing.BackendBucketPart "web-site-bucket") (LoadBalancing.backendBucketPart bucketed "site")+            assertEqual "only the buckets are spoken for" [[], []] (map (map (const ()) . needs) [LoadBalancing.UrlMapPart, LoadBalancing.BackendBucketPart "web-nope-bucket"])+            let dag = dagOf (LoadBalancing.applicationLoadBalancerWith needs [] silent ignoreTrack bucketed)+            let dependants gcs = [act.extension.help | (r, act) <- Map.toList (Dag.dagNodes dag), storageRef gcs `elem` Map.findWithDefault [] r (Dag.dagDependencies dag)]+            assertEqual "" ["backend bucket web-site-bucket"] (dependants "example-site")+            assertEqual "" ["backend bucket web-docs-bucket"] (dependants "example-docs")+            assertEqual "the two storage nodes and nothing else are new" (length (LoadBalancing.lbParts bucketed) + 3) (Map.size (Dag.dagNodes dag))+            -- the node handed back for the bucket is that very node, with its prerequisite+            case LoadBalancing.applicationLoadBalancerPartWith needs [] silent ignoreTrack bucketed (LoadBalancing.backendBucketPart bucketed "site") of+                Nothing -> assertBool "no such node" False+                Just o -> do+                    let both = dagOf (LoadBalancing.applicationLoadBalancerWith needs [] silent ignoreTrack bucketed `inject` o)+                    assertEqual "nothing new" (Map.size (Dag.dagNodes dag)) (Map.size (Dag.dagNodes both))+                    assertEqual "nothing conflicting" 0 (length (Dag.dagConflicts both))+                    assertEqual "the bucket and its storage" 2 (Map.size (Dag.dagNodes (dagOf o)))+            -- and without the function the balancer is the one it was+            assertEqual+                ""+                (Dag.dagDependencies (dagOf (LoadBalancing.applicationLoadBalancer silent ignoreTrack bucketed)))+                (Dag.dagDependencies (dagOf (LoadBalancing.applicationLoadBalancerWith (const []) [] silent ignoreTrack bucketed)))+        , testCase "an invalid declaration is every node's Failure and every node's refusal, before any call" $ do+            let bad = alb{LoadBalancing.albHostRules = [LoadBalancing.HostRule ["x.example.org"] (LoadBalancing.NamedService "nope") []]}+            mapM_+                ( \act -> do+                    v <- act.extension.check+                    assertBool (Text.unpack act.extension.help <> ": " <> show v) (isFailure v)+                    r <- try act.extension.up :: IO (Either LoadBalancing.InvalidLoadBalancer ())+                    assertBool (Text.unpack act.extension.help) (either (const True) (const False) r)+                )+                (Map.elems (Dag.dagNodes (dagOf (LoadBalancing.applicationLoadBalancer silent ignoreTrack bad))))+        , testCase "a bucket route is every node's Failure and every node's refusal, before any call; without the rule, no node refuses" $ do+            let nodesOf a = Map.elems (Dag.dagNodes (dagOf (LoadBalancing.applicationLoadBalancer silent ignoreTrack a)))+            -- no gcloud is reachable from these nodes' up (ignoreTrack) nor+            -- needed by their check: a refusal is decided before either+            mapM_+                ( \act -> do+                    v <- act.extension.check+                    case v of+                        Failure t -> assertBool (Text.unpack t) ("invalid load balancer: a rule routes to a backend bucket (hosts static.example.org to web-site-bucket)" `isInfixOf` Text.unpack t)+                        other -> assertBool (Text.unpack act.extension.help <> ": " <> show other) False+                    r <- try act.extension.up :: IO (Either LoadBalancing.InvalidLoadBalancer ())+                    case r of+                        Left (LoadBalancing.InvalidLoadBalancer [t]) -> assertBool (Text.unpack t) ("drop the rule from the declaration" `isInfixOf` Text.unpack t)+                        other -> assertBool (Text.unpack act.extension.help <> ": " <> show other) False+                )+                (nodesOf site)+            assertEqual "the URL map and the bucket are among them" 2 (length [() | act <- nodesOf site, act.extension.help `elem` ["URL map web-url-map", "backend bucket web-site-bucket"]])+            -- the declaration that repairs: the same nodes, none refusing+            assertEqual "" [] (LoadBalancing.albProblems unrouted)+            assertEqual "" (length (nodesOf site)) (length (nodesOf unrouted))+        , testCase "each resource's scripts parse as bash" $+            mapM_+                ( \sc -> do+                    (code, _, err) <- readProcessWithExitCode "bash" ["-n", "-c", Text.unpack sc] ""+                    assertEqual err ExitSuccess code+                )+                [ render full part+                | part <- LoadBalancing.lbParts full+                , render <- [LoadBalancing.renderPartUpScript, LoadBalancing.renderPartCheckScript, LoadBalancing.renderPartDownScript]+                ]+        ]+    -- everything a declaration renders, part by part and as a whole+    -- the line turning gcloud's prompts off, which every script run by bash+    -- now starts with, is not part of what these checksums were taken of+    unprompted :: String -> String+    unprompted sc = case break (== '\n') sc of+        (first, _ : rest) | first == Text.unpack Core.disablePromptsLine -> rest+        _ -> sc+    pinned :: LoadBalancing.ApplicationLoadBalancer -> String+    -- ... but for the leftovers part, which has tests of its own: its lines+    -- are taken out of the whole scripts, so that these checksums are of the+    -- scripts as they were before that part existed.+    pinned a =+        unlines+            ( [ without LoadBalancing.partUp (unprompted (createScript a))+              , without LoadBalancing.partCheck (Text.unpack (LoadBalancing.renderLbCheckScript a))+              , unprompted (deleteScript a)+              , show (encode (LoadBalancing.renderUrlMap a))+              ]+                <> concat+                    [ [show p.partId, show p.partDeps, Text.unpack p.partHelp, show p.partNotes]+                        <> map Text.unpack (p.partUp <> p.partCheck <> p.partDown)+                    | p <- LoadBalancing.lbParts a+                    , p.partId /= LoadBalancing.LeftoversPart+                    ]+            )+      where+        without field sc = unlines (cut (concat [map Text.unpack (field p) | p <- LoadBalancing.lbParts a, p.partId == LoadBalancing.LeftoversPart]) (lines sc))+        cut needle hay+            | null needle = hay+            | needle `isPrefixOf` hay = drop (length needle) hay+            | otherwise = case hay of+                [] -> []+                (l : ls) -> l : cut needle ls+    fnv1a :: String -> Word64+    fnv1a = foldl' (\h c -> (h `xor` fromIntegral (fromEnum c)) * 1099511628211) 14695981039346656037+    svcUrl :: Text.Text -> Text.Text+    svcUrl n = "https://www.googleapis.com/compute/v1/projects/p/regions/europe-west1/backendServices/" <> n+    inOrder :: [String] -> String -> Bool+    inOrder [] _ = True+    inOrder (w : ws) hay = case breakOn w hay of+        Nothing -> False+        Just rest -> inOrder ws rest+    breakOn :: String -> String -> Maybe String+    breakOn w hay = case [drop (length w) t | t <- tails hay, w `isPrefixOf` t] of+        (rest : _) -> Just rest+        [] -> Nothing+    deleteScript a = case processArgs (prepare LoadBalancing.loadBalancingCommand (LoadBalancing.LbDelete a)) of+        (_ : s : _) -> s+        other -> error (show other)+    withFakeGcloud :: ((String -> IO (ExitCode, String, String)) -> IO [String] -> IO a) -> IO a+    withFakeGcloud body =+        withSystemTempDirectory "salmon-fake-gcloud" $ \dir -> do+            let fake = dir </> "gcloud.sh"+            let state = dir </> "state"+            let logFile = dir </> "mutations.log"+            createDirectory state+            writeFile logFile ""+            writeFile fake fakeGcloud+            path <- getEnv "PATH"+            -- A shell function that has bash /read/ the stand-in, rather+            -- than an executable on PATH: this suite runs its groups in+            -- parallel in one process, and exec'ing a file some other+            -- test's fork still holds open for writing is ETXTBSY.+            let run sc =+                    readCreateProcessWithExitCode+                        (proc "bash" ["-c", "gcloud() { bash \"$FAKE_GCLOUD\" \"$@\"; }\n" <> sc])+                            { env = Just [("PATH", path), ("FAKE_GCLOUD", fake), ("FAKE_GCLOUD_STATE", state), ("FAKE_GCLOUD_LOG", logFile)]+                            }+                        ""+            body run (lines <$> (readFile logFile >>= \c -> length c `seq` pure c))+    -- one VM, one instance group, two services on two ports+    shared =+        alb+            { LoadBalancing.albBackends = []+            , LoadBalancing.albServices =+                [ LoadBalancing.BackendService n [LoadBalancing.InstanceGroupBackend "ig" (LoadBalancing.InstanceGroupZone "europe-west1-b") [p]] (Just (LoadBalancing.HealthCheck ("hc-" <> n) p)) Nothing+                | (n, p) <- [("a", 4272), ("b", 4273)]+                ]+            , LoadBalancing.albHostRules =+                [ LoadBalancing.HostRule [n <> ".example.org"] (LoadBalancing.NamedService n) []+                | n <- ["a", "b"]+                ]+            }+    full =+        alb+            { LoadBalancing.albBackends = [LoadBalancing.InstanceGroupBackend "ig" (LoadBalancing.InstanceGroupZone "europe-west1-b") [8080]]+            , LoadBalancing.albTimeoutSec = Just 60+            , LoadBalancing.albServices =+                [ LoadBalancing.BackendService+                    "slow"+                    [LoadBalancing.InstanceGroupBackend "ig-slow" (LoadBalancing.InstanceGroupZone "europe-west1-c") [9090]]+                    (Just (LoadBalancing.HealthCheck "hc" 8080))+                    (Just 3600)+                , LoadBalancing.BackendService "api" [LoadBalancing.CloudRunBackend "api-svc"] Nothing Nothing+                ]+            , LoadBalancing.albHostRules =+                [ LoadBalancing.HostRule+                    ["app.example.org", "www.example.org"]+                    LoadBalancing.DefaultService+                    [LoadBalancing.PathRule ["/events/*", "/poll"] (LoadBalancing.NamedService "slow") LoadBalancing.KeepPath]+                , LoadBalancing.HostRule ["api.example.org"] (LoadBalancing.NamedService "api") []+                ]+            , LoadBalancing.albCertificates = [LoadBalancing.ManagedCertificate "web-cert" ["app.example.org", "api.example.org"]]+            }+    -- the same balancer serving a bucket on one host, redirecting a path of it, and its apex to www+    site = siteTo "www.example.org"+    siteTo www =+        full+            { LoadBalancing.albBuckets = [LoadBalancing.BackendBucket "site" "example-site"]+            , LoadBalancing.albHostRules =+                full.albHostRules+                    <> [ LoadBalancing.HostRule+                            ["static.example.org"]+                            (LoadBalancing.NamedBucket "site")+                            [LoadBalancing.PathRule ["/old/*"] (LoadBalancing.RedirectTo (LoadBalancing.Redirect Nothing (Just "/new") False LoadBalancing.Found)) LoadBalancing.KeepPath]+                       , LoadBalancing.HostRule ["example.org"] (LoadBalancing.RedirectTo (LoadBalancing.redirectToHost www)) []+                       ]+            }+    -- a declaration as somebody who means to try a bucket route would write it+    allowing a = a{LoadBalancing.albBucketRoutes = LoadBalancing.AllowBucketRoutesKnownToHaveBrokenALiveBalancer}+    -- 'site' with the one rule that routes to the bucket dropped: bucket still declared, redirects kept+    unrouted = site{LoadBalancing.albHostRules = filter ((/= ["static.example.org"]) . LoadBalancing.hostRuleHosts) site.albHostRules}+    listening how = full{LoadBalancing.albHttp = how}+    leftovers = LoadBalancing.LeftoversPart+    -- every resource's up but the leftovers part's, and that part's alone+    resourceLines a = unlines [Text.unpack l | p <- LoadBalancing.lbParts a, p.partId /= leftovers, l <- p.partUp]+    leftoverLines a = unlines [Text.unpack l | p <- LoadBalancing.lbParts a, p.partId == leftovers, l <- p.partUp <> p.partCheck]+    -- a balancer as a version before the leftovers part left it: nothing marked+    upBefore run mutations a =+        mutationsOf run mutations (unlines ["set -euo pipefail", "PROJECT='p'", "REGION='europe-west1'", "exists() { \"$@\" >/dev/null 2>&1; }", resourceLines a])+    deletes = filter (" delete " `isInfixOf`)+    listOf run what = do+        (_, out, _) <- run ("gcloud " <> what <> " list --format='value(name)' | sed 's|.*/||' | sort")+        pure (lines out)+    toHttps = listening (LoadBalancing.RedirectToHttps LoadBalancing.MovedPermanently)+    mutationsOf run mutations sc = do+        before <- length <$> mutations+        (code, _, err) <- run sc+        assertEqual err ExitSuccess code+        drop before <$> mutations+    wholeVerdict run a = do+        (code, out, err) <- run (Text.unpack (LoadBalancing.renderLbCheckScript a))+        pure (out <> err, LoadBalancing.interpretLbCheck code (Text.pack out))+    proxyMap run = do+        (_, out, _) <- run "gcloud compute target-http-proxies describe web-proxy --format='value(urlMap)' | sed 's|.*/||'"+        pure out+    -- the same balancer with its certificate named after its domain set, before and after one more host+    rotating = full{LoadBalancing.albCertificates = [LoadBalancing.DomainSetCertificate "web-cert" ["app.example.org", "api.example.org"]]}+    rotated = full{LoadBalancing.albCertificates = [LoadBalancing.DomainSetCertificate "web-cert" ["app.example.org", "api.example.org", "www.example.org"]]}+    certV1 = "web-cert-" <> LoadBalancing.domainSetTag ["app.example.org", "api.example.org"]+    certV2 = "web-cert-" <> LoadBalancing.domainSetTag ["app.example.org", "api.example.org", "www.example.org"]+    -- one part's up, and the mutating calls it made+    upPart run mutations a part =+        concat+            <$> mapM+                (mutationsOf run mutations . Text.unpack . LoadBalancing.renderPartUpScript a)+                [spec | spec <- LoadBalancing.lbParts a, spec.partId == part]+    partVerdict run a part =+        mapM+            ( \spec -> do+                (code, out, _) <- run (Text.unpack (LoadBalancing.renderPartCheckScript a spec))+                pure (LoadBalancing.interpretLbCheck code (Text.pack out))+            )+            [spec | spec <- LoadBalancing.lbParts a, spec.partId == part]+    isBash p = case cmdspec p of+        RawCommand "bash" ("-c" : _) -> True+        _ -> False+    createScript a = case processArgs (prepare LoadBalancing.loadBalancingCommand (LoadBalancing.LbCreate a)) of+        (_ : s : _) -> s+        other -> error (show other)+    script = createScript alb+    alb =+        LoadBalancing.ApplicationLoadBalancer+            { LoadBalancing.albName = "web"+            , LoadBalancing.albProject = Core.Project "p"+            , LoadBalancing.albRegion = Core.Region "europe-west1"+            , LoadBalancing.albNetwork = Just "default"+            , LoadBalancing.albBackends =+                [ LoadBalancing.InstanceGroupBackend "ig" (LoadBalancing.InstanceGroupZone "europe-west1-b") [8080, 8081]+                , LoadBalancing.CloudRunBackend "svc"+                ]+            , LoadBalancing.albHealthCheck = Just (LoadBalancing.HealthCheck "hc" 8080)+            , LoadBalancing.albTimeoutSec = Nothing+            , LoadBalancing.albServices = []+            , LoadBalancing.albHostRules = []+            , LoadBalancing.albCertificates = []+            , LoadBalancing.albBuckets = []+            , LoadBalancing.albBucketRoutes = LoadBalancing.RefuseBucketRoutes+            , LoadBalancing.albHttp = LoadBalancing.HttpCreatedOnce+            }++{- | A stand-in for @gcloud@: resources are files named+@\<collection\>.\<name\>@ under @$FAKE_GCLOUD_STATE@, and every call that+would change something is appended to @$FAKE_GCLOUD_LOG@. It answers only+the calls "Salmon.Builtin.Nodes.Gcp.LoadBalancing"'s scripts make, and its+@--format@ output is this module's guess at gcloud's, not a recording.+-}+fakeGcloud :: String+fakeGcloud =+    unlines+        [ "#!/usr/bin/env bash"+        , "set -euo pipefail"+        , "S=\"$FAKE_GCLOUD_STATE\""+        , "coll=\"$2\"; verb=\"$3\"; name=\"$4\""+        , "if [ \"$verb\" = create ] && [ \"$name\" = tcp ]; then name=\"$5\"; fi"+        , "format=''; group=''; timeout=''; portname=''; namedports=''; domains=''; certs=''; gcs=''; urlmap=''; desc=''; authz=''"+        , "for a in \"$@\"; do case \"$a\" in"+        , "  --format=*) format=\"${a#--format=}\";;"+        , "  --instance-group=*) group=\"/instanceGroups/${a#--instance-group=}\";;"+        , "  --network-endpoint-group=*) group=\"/networkEndpointGroups/${a#--network-endpoint-group=}\";;"+        , "  --timeout=*) timeout=\"${a#--timeout=}\";;"+        , "  --port-name=*) portname=\"${a#--port-name=}\";;"+        , "  --named-ports=*) namedports=\"${a#--named-ports=}\";;"+        , "  --domains=*) domains=\"${a#--domains=}\";;"+        , "  --certificate-manager-certificates=*) certs=\"${a#--certificate-manager-certificates=}\";;"+        , "  --ssl-certificates=*) certs=\"${a#--ssl-certificates=}\";;"+        , "  --gcs-bucket-name=*) gcs=\"${a#--gcs-bucket-name=}\";;"+        , "  --url-map=*) urlmap=\"${a#--url-map=}\";;"+        , "  --description=*) desc=\"${a#--description=}\";;"+        , "  --dns-authorizations=*) authz=\"${a#--dns-authorizations=}\";;"+        , "esac; done"+        , "f=\"$S/$coll.$name\""+        , "mutate() { echo \"$coll $verb $name\" >> \"$FAKE_GCLOUD_LOG\"; }"+        , -- a proxy lists its certificates by path, as (it is assumed) the real one does+          "served() { for p in \"$S\"/target-https-proxies.*.certs; do [ -e \"$p\" ] && tr ',' '\\n' < \"$p\"; done; true; }"+        , -- a test can have one call fail: FAKE_GCLOUD_FAIL='certificates list'+          "if [ \"${FAKE_GCLOUD_FAIL:-}\" = \"$coll $verb\" ]; then echo \"FAILED $coll $verb\" >&2; exit 1; fi"+        , "case \"$verb\" in"+        , "  describe)"+        , "    [ \"$coll\" = instance-groups ] && exit 0"+        , "    [ -e \"$f\" ] || { echo \"NOT_FOUND $coll $name\" >&2; exit 1; }"+        , "    case \"$format\" in"+        , "      'value(backends[].group)') paste -sd';' \"$f.backends\" 2>/dev/null || true;;"+        , "      'value(timeoutSec)') cat \"$f.timeout\" 2>/dev/null || echo 30;;"+        , "      'value(portName)') cat \"$f.portname\" 2>/dev/null || echo http;;"+        , "      'value(managed.state)') cat \"$f.state\" 2>/dev/null || echo ACTIVE;;"+        , "      'value(managed.domains)') tr ',' ';' < \"$f.domains\" 2>/dev/null || true;;"+        , "      'value(managed.authorizationAttemptInfo[].state)') cat \"$f.attempts\" 2>/dev/null || echo AUTHORIZED;;"+        , "      'value(expireTime)') cat \"$f.expire\" 2>/dev/null || true;;"+        , "      'value(sslCertificates)') tr ',' '\\n' < \"$f.certs\" 2>/dev/null | sed 's|^|//certificatemanager.googleapis.com/projects/p/locations/r/certificates/|' | paste -sd';' || true;;"+        , "      'value(hostRules[].hosts)') grep -o '\"hosts\":\\[[^]]*\\]' \"$f\" | sed -e 's/\"hosts\"://' -e 's/[]\\[\"]//g' | paste -sd';' || true;;"+        , "      'value(IPAddress)') echo 203.0.113.7;;"+        , "      'value(bucketName)') cat \"$f.gcs\" 2>/dev/null || true;;"+        , -- a proxy names its map by URL, as (it is assumed) the real one does+          "      'value(urlMap)') echo \"https://www.googleapis.com/compute/v1/projects/p/regions/r/urlMaps/$(cat \"$f.urlmap\" 2>/dev/null)\";;"+        , "      'value(description)') if [ -e \"$f.description\" ]; then cat \"$f.description\"; exit 0; fi; grep -o '\"description\":\"[^\"]*\"' \"$f\" | sed -e 's/\"description\"://' -e 's/\"//g' || true;;"+        , "    esac;;"+        , "  create) [ -e \"$f\" ] && { echo \"ALREADY_EXISTS $coll $name\" >&2; exit 1; }; mutate; : > \"$f\"; if [ -n \"$portname\" ]; then echo \"$portname\" > \"$f.portname\"; fi"+        , "    if [ -n \"$certs\" ]; then echo \"$certs\" > \"$f.certs\"; fi"+        , "    if [ -n \"$gcs\" ]; then echo \"$gcs\" > \"$f.gcs\"; fi"+        , "    if [ -n \"$urlmap\" ]; then echo \"$urlmap\" > \"$f.urlmap\"; fi"+        , "    if [ -n \"$desc\" ]; then echo \"$desc\" > \"$f.description\"; fi"+        , "    if [ -n \"$authz\" ]; then echo \"$authz\" > \"$f.authz\"; fi"+        , -- a new certificate is not issued at once, when the test says so+          "    if [ -n \"$domains\" ]; then echo \"$domains\" > \"$f.domains\"; echo \"${FAKE_GCLOUD_NEW_CERT_STATE:-ACTIVE}\" > \"$f.state\"; fi;;"+        , -- the listings the leftovers part reads; like the rest, a guess at gcloud's output+          "  list) for c in \"$S\"/\"$coll\".*; do n=\"${c##*/}\"; n=\"${n#\"$coll\".}\"; case \"$n\" in *.*|'*') continue;; esac"+        , "    case \"$format\" in"+        , "      'value(name,description)') printf '%s\\t%s\\n' \"projects/p/locations/r/$coll/$n\" \"$(cat \"$c.description\" 2>/dev/null || true)\";;"+        , "      json) cat \"$c\"; echo;;"+        , "      'value(sslCertificates,certificateManagerCertificates)') tr ',' '\\n' < \"$c.certs\" 2>/dev/null | sed 's|^|//certificatemanager.googleapis.com/projects/p/locations/r/certificates/|' | paste -sd';' || true;;"+        , "      'value(urlMap)') echo \"https://www.googleapis.com/compute/v1/projects/p/regions/europe-west1/urlMaps/$(cat \"$c.urlmap\" 2>/dev/null)\";;"+        , "      'value(managed.dnsAuthorizations)') tr ',' '\\n' < \"$c.authz\" 2>/dev/null | sed 's|^|projects/p/locations/r/dnsAuthorizations/|' | paste -sd';' || true;;"+        , "      *) echo \"projects/p/locations/r/$coll/$n\";;"+        , "    esac; done;;"+        , "  import) mutate; cat > \"$f\";;"+        , "  update) [ -e \"$f\" ] || exit 1; mutate; if [ -n \"$timeout\" ]; then echo \"$timeout\" > \"$f.timeout\"; fi; if [ -n \"$portname\" ]; then echo \"$portname\" > \"$f.portname\"; fi; if [ -n \"$certs\" ]; then echo \"$certs\" > \"$f.certs\"; fi; if [ -n \"$gcs\" ]; then echo \"$gcs\" > \"$f.gcs\"; fi; if [ -n \"$urlmap\" ]; then echo \"$urlmap\" > \"$f.urlmap\"; fi; if [ -n \"$desc\" ]; then echo \"$desc\" > \"$f.description\"; fi;;"+        , "  add-backend) [ -e \"$f\" ] || exit 1; grep -qxF \"$group\" \"$f.backends\" 2>/dev/null && { echo 'already a backend' >&2; exit 1; }; mutate; echo \"$group\" >> \"$f.backends\";;"+        , "  remove-backend) [ -e \"$f\" ] || exit 1; grep -qxF \"$group\" \"$f.backends\" || { echo 'not a backend' >&2; exit 1; }; mutate; { grep -vxF \"$group\" \"$f.backends\" || true; } > \"$f.backends.new\"; mv \"$f.backends.new\" \"$f.backends\";;"+        , -- like the real one, it replaces the group's whole set+          "  set-named-ports) mutate; printf '%s\\n' \"$namedports\" | tr ',' '\\n' | tr ':' '\\t' > \"$FAKE_GCLOUD_LOG.ports.$name\";;"+        , "  get-named-ports) cat \"$FAKE_GCLOUD_LOG.ports.$name\" 2>/dev/null || true;;"+        , "  get-health) echo 'HEALTHY;HEALTHY';;"+        , "  delete) [ -e \"$f\" ] || exit 1"+        , "    if [ \"$coll\" = certificates ] && served | grep -qxF \"$name\"; then echo \"IN_USE $coll $name\" >&2; exit 1; fi"+        , -- what GCP refuses too: a resource something else still names+          "    if [ \"$coll\" = backend-buckets ] && grep -qsF \"/backendBuckets/$name\\\"\" \"$S\"/url-maps.*; then echo \"IN_USE $coll $name\" >&2; exit 1; fi"+        , "    if [ \"$coll\" = dns-authorizations ] && cat \"$S\"/certificates.*.authz 2>/dev/null | tr ',' '\\n' | grep -qxF \"$name\"; then echo \"IN_USE $coll $name\" >&2; exit 1; fi"+        , "    if [ \"$coll\" = url-maps ] && cat \"$S\"/target-http-proxies.*.urlmap \"$S\"/target-https-proxies.*.urlmap 2>/dev/null | grep -qxF \"$name\"; then echo \"IN_USE $coll $name\" >&2; exit 1; fi"+        , "    mutate; rm -f \"$f.description\" \"$f.authz\" \"$f\" \"$f.backends\" \"$f.timeout\" \"$f.portname\" \"$f.certs\" \"$f.domains\" \"$f.state\" \"$f.attempts\" \"$f.expire\" \"$f.gcs\" \"$f.urlmap\";;"+        , "  *) echo \"fake gcloud: unhandled $*\" >&2; exit 2;;"+        , "esac"+        ]++-------------------------------------------------------------------------------++billingTests :: [TestTree]+billingTests =+    [ testCase "a project linked and billing-enabled is satisfied" $+        assertEqual+            ""+            Success+            (Billing.interpretBillingDescribe account ExitSuccess "billingAccountName: billingAccounts/XXXXXX-XXXXXX-XXXXXX\nbillingEnabled: true\nname: projects/p\n")+    , testCase "a project linked to a different account is not satisfied" $+        assertBool+            ""+            (isFailure (Billing.interpretBillingDescribe account ExitSuccess "billingAccountName: billingAccounts/OTHER-ACCOUNT\nbillingEnabled: true\n"))+    , testCase "a project with billing disabled is not satisfied" $+        assertBool+            ""+            (isFailure (Billing.interpretBillingDescribe account ExitSuccess "billingAccountName: billingAccounts/XXXXXX-XXXXXX-XXXXXX\nbillingEnabled: false\n"))+    , testCase "describe failing outright is not satisfied" $+        assertBool "" (isFailure (Billing.interpretBillingDescribe account (ExitFailure 1) ""))+    ]+  where+    account = Billing.BillingAccount "XXXXXX-XXXXXX-XXXXXX"++-------------------------------------------------------------------------------++projectTests :: [TestTree]+projectTests =+    [ testCase "an ACTIVE project is satisfied" $+        assertEqual "" Success (ResourceManager.interpretProjectState "p" ExitSuccess "ACTIVE")+    , testCase "a project pending deletion is not satisfied, and says why" $+        case ResourceManager.interpretProjectState "p" ExitSuccess "DELETE_REQUESTED" of+            Failure msg -> assertBool "mentions the id cannot be reused" ("cannot be reused" `isInfixOf` show msg)+            other -> assertBool ("expected Failure, got " <> show other) False+    , testCase "describe failing means the project is absent" $+        assertBool "" (isFailure (ResourceManager.interpretProjectState "p" (ExitFailure 1) ""))+    , testCase "create passes the parent and labels" $ do+        let args =+                processArgs $+                    prepare+                        ResourceManager.resourceManagerCommand+                        ( ResourceManager.ProjectsCreate+                            (ResourceManager.ProjectSpec (Core.Project "p") (ResourceManager.Folder "123") (Map.fromList [("purpose", "salmon-toy")]))+                        )+        assertEqual "" ["projects", "create", "p", "--folder", "123", "--labels", "purpose=salmon-toy", "--quiet"] args+    ]++-------------------------------------------------------------------------------++vmTests :: [TestTree]+vmTests =+    [ testCase "an address is reserved regionally, and read back as a bare IP" $ do+        assertEqual+            "create"+            ["compute", "addresses", "create", "toy-ip", "--region", "europe-west1", "--project", "p", "--quiet"]+            (processArgs (prepare Compute.computeCommand (Compute.AddressesCreate addr)))+        assertEqual+            "describe asks for the address itself, which is what a driver needs"+            ["compute", "addresses", "describe", "toy-ip", "--region", "europe-west1", "--format=value(address)", "--project", "p", "--quiet"]+            (processArgs (prepare Compute.computeCommand (Compute.AddressesDescribe addr)))+    , testCase "a reserved address with no IP yet is not satisfied" $+        assertBool "" (isFailure (Compute.interpretAddressDescribe "toy-ip" ExitSuccess ""))+    , testCase "a reserved address with an IP is satisfied" $+        assertEqual "" Success (Compute.interpretAddressDescribe "toy-ip" ExitSuccess "34.1.2.3")+    , testCase "a firewall rule carries its allow, ranges and target tags" $+        assertEqual+            ""+            [ "compute", "firewall-rules", "create", "toy-ssh"+            , "--network", "default", "--allow", "tcp:22"+            , "--source-ranges", "0.0.0.0/0", "--project", "p"+            , "--target-tags", "toy-ssh"+            , "--quiet"+            ]+            (processArgs (prepare Compute.computeCommand (Compute.FirewallCreate fw)))+    , testCase "an instance boots from an image family, in its publisher's project" $+        assertBool+            "--image-family and --image-project are passed"+            (["--image-family", "ubuntu-2404-lts-amd64"] `isSubsequenceOf` args && ["--image-project", "ubuntu-os-cloud"] `isSubsequenceOf` args)+    , testCase "a multi-line startup script goes through --metadata-from-file" $+        assertBool+            "a newline-bearing value cannot ride in --metadata KEY=VALUE"+            (["--metadata-from-file", "startup-script=/tmp/w/startup-script.sh"] `isSubsequenceOf` args)+    , testCase "the instance claims the reserved address by name" $+        assertBool "" (["--address", "toy-ip"] `isSubsequenceOf` args)+    , testCase "an instance left to GCP names neither address" $ do+        let plain = createArgs inst{Compute.instanceExternalAddress = Compute.EphemeralExternal}+        assertBool (show plain) (not (any (`elem` ["--address", "--no-address", "--private-network-ip"]) plain))+    , testCase "a pinned internal address is passed as --private-network-ip" $+        assertBool+            ""+            (["--private-network-ip", "10.132.0.10"] `isSubsequenceOf` createArgs inst{Compute.instanceInternalAddress = Compute.PinnedInternal "10.132.0.10"})+    , testCase "an instance with no external address says so, and can still be pinned" $ do+        let private =+                createArgs+                    inst+                        { Compute.instanceExternalAddress = Compute.NoExternalAddress+                        , Compute.instanceInternalAddress = Compute.PinnedInternal "10.132.0.11"+                        }+        assertBool (show private) ("--no-address" `elem` private && "--address" `notElem` private)+        assertBool (show private) (["--private-network-ip", "10.132.0.11"] `isSubsequenceOf` private)+    , testCase "an internal address is reserved in its subnet, at the declared literal" $ do+        assertEqual+            "pinned"+            ["compute", "addresses", "create", "toy-int", "--region", "europe-west1", "--project", "p", "--subnet", "default", "--addresses", "10.132.0.10", "--quiet"]+            (processArgs (prepare Compute.computeCommand (Compute.AddressesCreate (internal (Just "10.132.0.10")))))+        assertEqual+            "left to GCP"+            ["compute", "addresses", "create", "toy-int", "--region", "europe-west1", "--project", "p", "--subnet", "default", "--quiet"]+            (processArgs (prepare Compute.computeCommand (Compute.AddressesCreate (internal Nothing))))+    , testCase "an internal address reserved at the declared literal is satisfied" $+        assertEqual "" Success (Compute.interpretAddress (internal (Just "10.132.0.10")) ExitSuccess "10.132.0.10")+    , testCase "an internal address reserved at another literal is not" $+        assertBool "" (isFailure (Compute.interpretAddress (internal (Just "10.132.0.10")) ExitSuccess "10.132.0.99"))+    , testCase "an address with no declared literal is satisfied by whatever was reserved" $ do+        assertEqual "internal" Success (Compute.interpretAddress (internal Nothing) ExitSuccess "10.132.0.99")+        assertEqual "external" Success (Compute.interpretAddress addr ExitSuccess "34.1.2.3")+        assertBool "absent" (isFailure (Compute.interpretAddress (internal (Just "10.132.0.10")) (ExitFailure 1) ""))+    ]+  where+    args = createArgs inst+    createArgs i = processArgs (prepare Compute.computeCommand (Compute.InstancesCreate i))+    addr = Compute.Address "toy-ip" (Core.Project "p") (Core.Region "europe-west1") Compute.ExternalAddress+    internal ip = Compute.Address "toy-int" (Core.Project "p") (Core.Region "europe-west1") (Compute.InternalAddress "default" ip)+    fw =+        Compute.FirewallRule+            { Compute.firewallName = "toy-ssh"+            , Compute.firewallProject = Core.Project "p"+            , Compute.firewallNetwork = "default"+            , Compute.firewallAllow = "tcp:22"+            , Compute.firewallSourceRanges = ["0.0.0.0/0"]+            , Compute.firewallTargetTags = ["toy-ssh"]+            }+    inst =+        Compute.Instance+            { Compute.instanceName = "toy-vm"+            , Compute.instanceProject = Core.Project "p"+            , Compute.instanceZone = Core.Zone "europe-west1-b"+            , Compute.instanceMachineType = Compute.Custom "e2-micro"+            , Compute.instanceBootDisk = Compute.BootDisk 10 Nothing (Just "ubuntu-2404-lts-amd64") (Just "ubuntu-os-cloud")+            , Compute.instanceNetwork = "default"+            , Compute.instanceSubnet = "default"+            , Compute.instanceServiceAccount = Nothing+            , Compute.instanceMetadata = Map.fromList [("enable-oslogin", "FALSE")]+            , Compute.instanceMetadataFiles = Map.fromList [("startup-script", "/tmp/w/startup-script.sh")]+            , Compute.instanceExternalAddress = Compute.ReservedExternal "toy-ip"+            , Compute.instanceInternalAddress = Compute.EphemeralInternal+            , Compute.instanceTags = ["toy-ssh"]+            , Compute.instancePower = Compute.PoweredOn+            , Compute.instanceScopes = Compute.DefaultScopes+            , Compute.instanceMachineTypeChange = Compute.RefuseMachineTypeChange+            , Compute.instanceRunLimit = Compute.NoRunLimit+            }++-------------------------------------------------------------------------------++clientOptsTests :: [TestTree]+clientOptsTests =+    [ testCase "no options means ssh authenticates as it always did" $+        assertEqual "" [] (Ssh.clientArgs Ssh.noClientOpts)+    , testCase "an identity is offered exclusively" $+        assertEqual+            "IdentitiesOnly, or an agent key can be tried first and the cert never reached"+            ["-i", "/w/ssh/toy-client", "-o", "IdentitiesOnly=yes"]+            (Ssh.clientArgs Ssh.noClientOpts{Ssh.optIdentity = Just "/w/ssh/toy-client"})+    , testCase "a known-hosts file comes with accept-new" $+        assertEqual+            ""+            ["-o", "UserKnownHostsFile=/w/ssh/known_hosts", "-o", "StrictHostKeyChecking=accept-new"]+            (Ssh.clientArgs Ssh.noClientOpts{Ssh.optKnownHosts = Just "/w/ssh/known_hosts"})+    , testCase "rsync carries the same options through --rsh" $+        assertEqual+            "rsync has no -i of its own"+            [ "--copy-links"+            , "--rsh"+            , "ssh -i /w/ssh/toy-client -o IdentitiesOnly=yes -o UserKnownHostsFile=/w/ssh/known_hosts -o StrictHostKeyChecking=accept-new"+            , "/local/bin"+            , "salmon@1.2.3.4:/home/salmon/bin"+            ]+            (processArgs (prepare Rsync.rsyncRun (Rsync.SendFile "/local/bin" (Rsync.Remote "salmon" "1.2.3.4") "/home/salmon/bin" opts)))+    , testCase "a directory upload carries them too" $+        assertEqual+            "sendDirWith is sendFileWith's --rsh treatment, recursively"+            [ "--copy-links"+            , "--recursive"+            , "--rsh"+            , "ssh -i /w/ssh/toy-client -o IdentitiesOnly=yes -o UserKnownHostsFile=/w/ssh/known_hosts -o StrictHostKeyChecking=accept-new"+            , "/local/files"+            , "salmon@1.2.3.4:/home/salmon/files"+            ]+            (processArgs (prepare Rsync.rsyncRun (Rsync.SendDir "/local/files" (Rsync.Remote "salmon" "1.2.3.4") "/home/salmon/files" opts)))+    , testCase "a directory upload without options is the same command as before" $+        assertEqual+            ""+            ["--copy-links", "--recursive", "/local/files", "salmon@1.2.3.4:/home/salmon/files"]+            (processArgs (prepare Rsync.rsyncRun (Rsync.SendDir "/local/files" (Rsync.Remote "salmon" "1.2.3.4") "/home/salmon/files" Ssh.noClientOpts)))+    , testCase "a changed host key is recognised as such" $+        assertBool+            "the one ssh failure that never resolves by waiting"+            (Ssh.isHostKeyMismatch "@@@@\nWARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!\n")+    , testCase "an ordinary refusal is not a host key mismatch" $+        assertBool+            "a VM still booting must be waited out, not have its host key forgotten"+            (not (Ssh.isHostKeyMismatch "salmon@1.2.3.4: Permission denied (publickey)."))+    ]+  where+    opts = Ssh.ClientOpts (Just "/w/ssh/toy-client") (Just "/w/ssh/known_hosts")++-------------------------------------------------------------------------------++monitoringTests :: [TestTree]+monitoringTests =+    [ testCase "an email channel is created with its type and address as channel labels" $ do+        let args = processArgs (prepare Monitoring.monitoringCommand (Monitoring.ChannelsCreate channel))+        assertBool (show args) (["beta", "monitoring", "channels", "create"] `isSubsequenceOf` args)+        assertBool (show args) (["--type", "email"] `isSubsequenceOf` args)+        assertBool (show args) (["--channel-labels", "email_address=ops@example.org"] `isSubsequenceOf` args)+        assertBool (show args) (["--display-name", "ops mail"] `isSubsequenceOf` args)+        assertBool (show args) (["--project", "p"] `isSubsequenceOf` args)+    , testCase "channels and policies are looked up by display name, as JSON" $ do+        let cargs = processArgs (prepare Monitoring.monitoringCommand (Monitoring.ChannelsList channel))+            pargs = processArgs (prepare Monitoring.monitoringCommand (Monitoring.PoliciesList policy))+        assertBool (show cargs) (["--filter", "display_name=\"ops mail\"", "--format", "json"] `isSubsequenceOf` cargs)+        assertBool (show pargs) (["--filter", "display_name=\"svc: 5xx ratio\"", "--format", "json"] `isSubsequenceOf` pargs)+    , testCase "channel lookup: failed, absent, present matching, present with another address" $ do+        assertEqual "" (Monitoring.LookupFailed "exit 1: boom") (Monitoring.lookupChannel channel (ExitFailure 1) "" "boom\n")+        assertEqual "" Monitoring.Absent (Monitoring.lookupChannel channel ExitSuccess "[]" "")+        assertEqual+            ""+            (Monitoring.Present (Monitoring.FoundChannel "projects/p/notificationChannels/1" True))+            (Monitoring.lookupChannel channel ExitSuccess (channelJson "ops@example.org") "")+        assertEqual+            ""+            (Monitoring.Present (Monitoring.FoundChannel "projects/p/notificationChannels/1" False))+            (Monitoring.lookupChannel channel ExitSuccess (channelJson "other@example.org") "")+        -- and as a check: only the matching one is satisfied+        assertEqual "" Success (Monitoring.interpretChannelList channel (ExitSuccess, channelJson "ops@example.org", ""))+        assertBool "" (isFailure (Monitoring.interpretChannelList channel (ExitSuccess, channelJson "other@example.org", "")))+        assertBool "" (isFailure (Monitoring.interpretChannelList channel (ExitSuccess, "[]", "")))+        assertBool "" (isFailure (Monitoring.interpretChannelList channel (ExitFailure 1, "", "")))+    , testCase "the 5xx condition is a ratio: numerator on the 5xx class, denominator on every request, same service" $ do+        let v = Monitoring.renderCondition target (Monitoring.ServerErrorRatio 0.05 300)+            threshold = fieldAt ["conditionThreshold"] v+            filt = textAt ["conditionThreshold", "filter"] v+            denom = textAt ["conditionThreshold", "denominatorFilter"] v+        assertBool (show filt) (maybe False ("metric.labels.response_code_class=\"5xx\"" `Text.isInfixOf`) filt)+        assertBool (show filt) (maybe False ("resource.labels.service_name=\"svc\"" `Text.isInfixOf`) filt)+        assertBool (show filt) (maybe False ("resource.labels.location=\"europe-west1\"" `Text.isInfixOf`) filt)+        assertBool (show denom) (maybe False (\d -> "request_count" `Text.isInfixOf` d && not ("5xx" `Text.isInfixOf` d)) denom)+        assertEqual "" (Just "300s") (textAt ["conditionThreshold", "duration"] v)+        assertBool (show threshold) (threshold /= Nothing)+    , testCase "latency and memory read the 99th percentile; instance count sums active instances" $ do+        let lat = Monitoring.renderCondition target (Monitoring.RequestLatencyP99 2000 300)+            mem = Monitoring.renderCondition target (Monitoring.MemoryUtilization 0.9 300)+            cnt = Monitoring.renderCondition target (Monitoring.InstanceCount 3 300)+        assertEqual "" (Just "ALIGN_PERCENTILE_99") (textAt ["conditionThreshold", "aggregations", "0", "perSeriesAligner"] lat)+        assertBool "" (maybe False ("request_latencies" `Text.isInfixOf`) (textAt ["conditionThreshold", "filter"] lat))+        assertEqual "" (Just "ALIGN_PERCENTILE_99") (textAt ["conditionThreshold", "aggregations", "0", "perSeriesAligner"] mem)+        assertBool "" (maybe False ("memory/utilizations" `Text.isInfixOf`) (textAt ["conditionThreshold", "filter"] mem))+        assertEqual "" (Just "REDUCE_SUM") (textAt ["conditionThreshold", "aggregations", "0", "crossSeriesReducer"] cnt)+        assertBool "" (maybe False ("metric.labels.state=\"active\"" `Text.isInfixOf`) (textAt ["conditionThreshold", "filter"] cnt))+    , testCase "the rendered policy names its channels and carries the fingerprint as a user label" $ do+        let names = ["projects/p/notificationChannels/1"]+            v = Monitoring.renderPolicy policy names+        assertEqual "" (Just "svc: 5xx ratio") (textAt ["displayName"] v)+        assertEqual "" (Just "OR") (textAt ["combiner"] v)+        assertEqual "" (Just "projects/p/notificationChannels/1") (textAt ["notificationChannels", "0"] v)+        assertEqual "" (Just (Monitoring.policyFingerprint policy names)) (textAt ["userLabels", Monitoring.fingerprintLabel] v)+        -- and it is what --policy carries, inline+        let args = processArgs (prepare Monitoring.monitoringCommand (Monitoring.PoliciesCreate policy names))+        assertBool (show args) (["alpha", "monitoring", "policies", "create", "--policy"] `isSubsequenceOf` args)+        assertBool (show args) (Text.unpack (Text.decodeUtf8 (LByteString.toStrict (encode v))) `elem` args)+    , testCase "the fingerprint is label-safe, and moves with a threshold or a channel id" $ do+        let names = ["projects/p/notificationChannels/1"]+            fp = Monitoring.policyFingerprint policy names+        assertEqual (show fp) 16 (Text.length fp)+        assertBool (show fp) (Text.all (\c -> isAsciiLower c || isDigit c) fp)+        assertBool "same declaration, same fingerprint" (fp == Monitoring.policyFingerprint policy names)+        assertBool "threshold" (fp /= Monitoring.policyFingerprint policy{Monitoring.apConditions = [Monitoring.ServerErrorRatio 0.1 300]} names)+        assertBool "channel id" (fp /= Monitoring.policyFingerprint policy ["projects/p/notificationChannels/2"])+        assertBool "documentation" (fp /= Monitoring.policyFingerprint policy{Monitoring.apDocumentation = "other"} names)+    , testCase "policy lookup: a matching fingerprint is satisfied, anything else is not" $ do+        let names = ["projects/p/notificationChannels/1"]+            fp = Monitoring.policyFingerprint policy names+        assertEqual "" Success (Monitoring.interpretPolicyList policy names (ExitSuccess, policyJson (Just fp), ""))+        assertBool "edited or older" (isFailure (Monitoring.interpretPolicyList policy names (ExitSuccess, policyJson (Just "0000000000000000"), "")))+        assertBool "no label" (isFailure (Monitoring.interpretPolicyList policy names (ExitSuccess, policyJson Nothing, "")))+        assertBool "absent" (isFailure (Monitoring.interpretPolicyList policy names (ExitSuccess, "[]", "")))+        assertBool "unreachable" (isFailure (Monitoring.interpretPolicyList policy names (ExitFailure 1, "", "")))+        assertEqual+            ""+            (Monitoring.Present (Monitoring.FoundPolicy "projects/p/alertPolicies/9" (Just fp)))+            (Monitoring.lookupPolicy policy ExitSuccess (policyJson (Just fp)) "")+    , testCase "an update names the policy found, a delete the resource, both under the project" $ do+        let up = processArgs (prepare Monitoring.monitoringCommand (Monitoring.PoliciesUpdate "projects/p/alertPolicies/9" policy []))+            del = processArgs (prepare Monitoring.monitoringCommand (Monitoring.PoliciesDelete (Core.Project "p") "projects/p/alertPolicies/9"))+            cdel = processArgs (prepare Monitoring.monitoringCommand (Monitoring.ChannelsDelete (Core.Project "p") "projects/p/notificationChannels/1"))+        assertBool (show up) (["policies", "update", "projects/p/alertPolicies/9", "--policy"] `isSubsequenceOf` up)+        assertBool (show del) (["policies", "delete", "projects/p/alertPolicies/9", "--quiet", "--project", "p"] `isSubsequenceOf` del)+        assertBool (show cdel) (["channels", "delete", "projects/p/notificationChannels/1", "--quiet"] `isSubsequenceOf` cdel)+    ]+  where+    channel = Monitoring.NotificationChannel (Core.Project "p") "ops mail" (Monitoring.Email "ops@example.org")+    target = Monitoring.CloudRunTarget (Core.Project "p") (Core.Region "europe-west1") "svc"+    policy =+        Monitoring.AlertPolicy+            { Monitoring.apProject = Core.Project "p"+            , Monitoring.apDisplayName = "svc: 5xx ratio"+            , Monitoring.apTarget = target+            , Monitoring.apConditions = [Monitoring.ServerErrorRatio 0.05 300]+            , Monitoring.apChannels = [channel]+            , Monitoring.apDocumentation = "doc"+            }+    channelJson address =+        "[{\"name\": \"projects/p/notificationChannels/1\", \"type\": \"email\", \"displayName\": \"ops mail\", \"labels\": {\"email_address\": \"" <> Text.encodeUtf8 address <> "\"}, \"enabled\": true}]"+    policyJson mfp =+        "[{\"name\": \"projects/p/alertPolicies/9\", \"displayName\": \"svc: 5xx ratio\", \"combiner\": \"OR\""+            <> maybe "" (\fp -> ", \"userLabels\": {\"salmon-fingerprint\": \"" <> Text.encodeUtf8 fp <> "\"}") mfp+            <> "}]"++-- | A field of a JSON value by path; an array index is spelled as a number.+fieldAt :: [Text.Text] -> Value -> Maybe Value+fieldAt [] v = Just v+fieldAt (k : ks) (Object o) = KeyMap.lookup (Key.fromText k) o >>= fieldAt ks+fieldAt (k : ks) (Array xs) = case reads (Text.unpack k) of+    [(i, "")] | i >= 0, i < length xs -> fieldAt ks (toList xs !! i)+    _ -> Nothing+fieldAt _ _ = Nothing++textAt :: [Text.Text] -> Value -> Maybe Text.Text+textAt ks v = case fieldAt ks v of+    Just (String t) -> Just t+    _ -> Nothing++cloudRunAlertsTests :: [TestTree]+cloudRunAlertsTests =+    [ testCase "three policies without a maximum, four with; one channel shared by all" $ do+        let without = CloudRunAlerts.standardPolicies cfg{CloudRunAlerts.cra_maxInstances = Nothing}+            with = CloudRunAlerts.standardPolicies cfg+        assertEqual "" 3 (length without)+        assertEqual "" 4 (length with)+        assertEqual "" 1 (length (nub (concatMap (.apChannels) with)))+        assertBool "" (any (\p -> p.apConditions == [Monitoring.InstanceCount 2 300]) with)+    , testCase "display names are distinct per service, so two services' alerts are distinct resources" $ do+        let a = map (.apDisplayName) (CloudRunAlerts.standardPolicies cfg)+            b = map (.apDisplayName) (CloudRunAlerts.standardPolicies cfg{CloudRunAlerts.cra_service = "other"})+        assertEqual "" 4 (length (nub a))+        assertBool (show (a, b)) (null (filter (`elem` b) a))+        assertBool "" (all ("svc: " `Text.isPrefixOf`) a)+    , testCase "the defaults are the documented ones" $ do+        let t = CloudRunAlerts.defaultAlertThresholds+        assertEqual "" 0.05 t.at_errorRatio+        assertEqual "" 2000 t.at_latencyP99Ms+        assertEqual "" 0.9 t.at_memoryUtilization+        assertEqual "" 300 t.at_duration+    ]+  where+    cfg =+        CloudRunAlerts.CloudRunAlertsConfig+            { CloudRunAlerts.cra_project = Core.Project "p"+            , CloudRunAlerts.cra_region = Core.Region "europe-west1"+            , CloudRunAlerts.cra_service = "svc"+            , CloudRunAlerts.cra_email = "ops@example.org"+            , CloudRunAlerts.cra_channelName = "ops mail"+            , CloudRunAlerts.cra_maxInstances = Just 2+            , CloudRunAlerts.cra_thresholds = CloudRunAlerts.defaultAlertThresholds+            }++-------------------------------------------------------------------------------++caTrustScriptTests :: [TestTree]+caTrustScriptTests =+    [ testCase "the fetch is inside a retry loop, never a bare command under set -e" $ do+        assertEqual "errexit is on, which is what makes a bare fetch fatal" (Just "set -eux") (lookup 2 numbered)+        assertBool "the curl is the condition of an `if`, so a 404 does not abort the script" $+            any (\l -> "if curl -fsS" `Text.isInfixOf` l && "Metadata-Flavor: Google" `Text.isInfixOf` l) loopLines+        assertBool "the loop retries with a pause" $+            any ("sleep 2" `Text.isInfixOf`) loopLines+        assertBool "it reads the attribute installMetadataCaKey publishes" $+            any ("/computeMetadata/v1/project/attributes/ssh-ca" `Text.isInfixOf`) loopLines+    , testCase "running out of attempts still fails, on an empty key file" $+        assertEqual+            "an empty TrustedUserCAKeys file locks everybody out, so sshd is not touched without a key"+            ["done", "test -s /etc/ssh/salmon_ca.pub"]+            (take 2 (dropWhile (/= "done") scriptLines))+    , testCase "sshd is told about the key only after it is there, and restarted last" $ do+        let at needle = [n | (n, l) <- numbered, needle `Text.isInfixOf` l]+        assertBool "" (at "test -s" < at "TrustedUserCAKeys /etc/ssh/salmon_ca.pub' >>")+        assertEqual "" (Just "systemctl restart ssh || systemctl restart sshd") (lookup (length scriptLines) numbered)+    , testCase "the sshd_config line is appended only if missing (a startup script runs every boot)" $+        assertBool "" (any ("grep -qxF 'TrustedUserCAKeys /etc/ssh/salmon_ca.pub' /etc/ssh/sshd_config" `Text.isPrefixOf`) scriptLines)+    , testCase "the login user is created, given passwordless sudo, and named nowhere else" $ do+        assertBool "" ("id -u deployer >/dev/null 2>&1 || useradd -m -s /bin/bash deployer" `elem` scriptLines)+        assertBool "" ("printf '%s ALL=(ALL) NOPASSWD:ALL\\n' deployer > /etc/sudoers.d/deployer" `elem` scriptLines)+        assertBool "" ("chmod 440 /etc/sudoers.d/deployer" `elem` scriptLines)+        assertEqual+            "a different user changes those three lines only"+            3+            (length (filter id (zipWith (/=) scriptLines (Text.lines (VmProvision.caTrustStartupScript "other")))))+    ]+  where+    scriptLines = Text.lines (VmProvision.caTrustStartupScript "deployer")+    numbered = zip [1 :: Int ..] scriptLines+    loopLines =+        takeWhile (/= "done") (dropWhile (not . ("for attempt in" `Text.isPrefixOf`)) scriptLines)++-------------------------------------------------------------------------------+-- Cloud DNS++{- | The shape of @gcloud dns managed-zones describe --format json@, written+from the Cloud DNS API's @ManagedZone@ resource rather than captured from a+live project.+-}+zoneDescribeJson :: Text.Text+zoneDescribeJson =+    Text.unlines+        [ "{"+        , "  \"cloudLoggingConfig\": {\"kind\": \"dns#managedZoneCloudLoggingConfig\"},"+        , "  \"creationTime\": \"2026-10-02T15:04:05.678Z\","+        , "  \"description\": \"a zone\","+        , "  \"dnsName\": \"example.org.\","+        , "  \"id\": \"1234567890123456789\","+        , "  \"kind\": \"dns#managedZone\","+        , "  \"name\": \"example-zone\","+        , "  \"nameServers\": ["+        , "    \"ns-cloud-c1.googledomains.com.\","+        , "    \"ns-cloud-c2.googledomains.com.\","+        , "    \"ns-cloud-c3.googledomains.com.\","+        , "    \"ns-cloud-c4.googledomains.com.\""+        , "  ],"+        , "  \"visibility\": \"public\""+        , "}"+        ]++cloudDnsTests :: [TestTree]+cloudDnsTests =+    [ testCase "create names the zone, its DNS name with the trailing dot, a description and public visibility" $+        assertEqual+            ""+            ["dns", "managed-zones", "create", "example-zone", "--dns-name", "example.org.", "--description", "a zone", "--visibility", "public", "--project", "my-project", "--quiet"]+            (processArgs (prepare CloudDns.cloudDnsCommand (CloudDns.ZonesCreate zone)))+    , testCase "describe asks for JSON" $+        assertEqual+            ""+            ["dns", "managed-zones", "describe", "example-zone", "--format", "json", "--project", "my-project", "--quiet"]+            (processArgs (prepare CloudDns.cloudDnsCommand (CloudDns.ZonesDescribe zone)))+    , testCase "delete is quiet" $+        assertEqual+            ""+            ["dns", "managed-zones", "delete", "example-zone", "--quiet", "--project", "my-project"]+            (processArgs (prepare CloudDns.cloudDnsCommand (CloudDns.ZonesDelete zone)))+    , testCase "fqdn lower-cases and ends in exactly one dot" $+        assertEqual "" ["example.org.", "example.org.", "example.org."] (map CloudDns.fqdn ["example.org", "Example.ORG.", " example.org..\n"])+    , testCase "the assigned name servers are read in order" $+        assertEqual+            ""+            (Right ["ns-cloud-c1.googledomains.com.", "ns-cloud-c2.googledomains.com.", "ns-cloud-c3.googledomains.com.", "ns-cloud-c4.googledomains.com."])+            (CloudDns.describedNameServers <$> CloudDns.parseZoneDescribe described)+    , testCase "a description with no nameServers parses to none" $+        assertEqual+            ""+            (Right (CloudDns.ZoneDescription "example.org." []))+            (CloudDns.parseZoneDescribe "{\"dnsName\": \"example.org.\"}")+    , testCase "a zone described for the declared DNS name is satisfied" $+        assertEqual "" Success (CloudDns.interpretZoneDescribe zone ExitSuccess described)+    , testCase "the declared name's dot and case do not matter" $+        assertEqual "" Success (CloudDns.interpretZoneDescribe (zone {CloudDns.zoneDnsName = "Example.org."}) ExitSuccess described)+    , testCase "describe failing means the zone is absent" $+        assertBool "" (isFailure (CloudDns.interpretZoneDescribe zone (ExitFailure 1) ""))+    , testCase "a zone of that name serving another domain is a failure naming both" $+        case CloudDns.interpretZoneDescribe (zone {CloudDns.zoneDnsName = "example.net"}) ExitSuccess described of+            Failure why -> do+                assertBool (Text.unpack why) ("example.org." `Text.isInfixOf` why)+                assertBool (Text.unpack why) ("example.net." `Text.isInfixOf` why)+            other -> assertBool ("expected a Failure, got " <> show other) False+    , testCase "output that is not a zone description cannot be judged" $+        assertEqual "" Unknown (CloudDns.interpretZoneDescribe zone ExitSuccess "not json")+    ]+  where+    zone = CloudDns.ManagedZone "example-zone" (Core.Project "my-project") "example.org" "a zone"+    described = Text.encodeUtf8 zoneDescribeJson++cloudDnsRecordTests :: [TestTree]+cloudDnsRecordTests =+    [ testCase "create names the record, its zone, type, TTL and data" $+        assertEqual+            ""+            ["dns", "record-sets", "create", "www.example.org.", "--zone", "example-zone", "--type", "A", "--project", "my-project", "--ttl", "300", "--rrdatas=192.0.2.1,192.0.2.2", "--quiet"]+            (args (CloudDns.RecordSetsCreate a))+    , testCase "update differs from create by its verb only" $+        assertEqual+            ""+            (map (\w -> if w == "create" then "update" else w) (args (CloudDns.RecordSetsCreate a)))+            (args (CloudDns.RecordSetsUpdate a))+    , testCase "describe asks for JSON, by name and type" $+        assertEqual+            ""+            ["dns", "record-sets", "describe", "www.example.org.", "--zone", "example-zone", "--type", "A", "--project", "my-project", "--format", "json", "--quiet"]+            (args (CloudDns.RecordSetsDescribe a))+    , testCase "delete names the record and its type" $+        assertEqual+            ""+            ["dns", "record-sets", "delete", "www.example.org.", "--zone", "example-zone", "--type", "A", "--project", "my-project", "--quiet"]+            (args (CloudDns.RecordSetsDelete a))+    , testCase "a CNAME's target gets its trailing dot" $+        assertEqual "" ["--rrdatas=target.example.net."] (rrdataFlag (args (CloudDns.RecordSetsCreate cname)))+    , testCase "a TXT is quoted, and a comma in it moves the list separator" $+        assertEqual "" ["--rrdatas=^;^\"v=spf1 ip4:192.0.2.0/24,-all\";\"second\""] (rrdataFlag (args (CloudDns.RecordSetsCreate txt)))+    , testCase "the separator is one no datum contains" $ do+        assertEqual "" "a,b" (CloudDns.renderRrdatas ["a", "b"])+        assertEqual "" "^|^a,;|b" (CloudDns.renderRrdatas ["a,;", "b"])+        assertEqual "" "^|||^,;|#~%@!|||x||" (CloudDns.renderRrdatas [",;|#~%@!", "x||"])+    , testCase "TXT data escapes quotes and backslashes" $+        assertEqual "" "\"say \\\"hi\\\" \\\\ bye\"" (CloudDns.txtRdata "say \"hi\" \\ bye")+    , testCase "a long TXT is split into strings of 255 and reads back whole" $ do+        let long = Text.replicate 60 "0123456789"+            rdata = CloudDns.txtRdata long+        assertEqual "" [257, 257, 92] (map Text.length (Text.splitOn " " rdata))+        assertEqual "" long (CloudDns.txtContent rdata)+    , testCase "txtContent undoes txtRdata, spaces and escapes included" $+        mapM_+            (\t -> assertEqual (Text.unpack t) t (CloudDns.txtContent (CloudDns.txtRdata t)))+            ["v=spf1 -all", "say \"hi\" \\ bye", "", "a  b", "trailing \\"]+    , testCase "txtContent takes unquoted data as it is" $+        assertEqual "" "plain" (CloudDns.txtContent "plain")+    , testCase "the description's TTL and data are read" $+        assertEqual "" (Right (CloudDns.RecordDescription 300 ["192.0.2.2", "192.0.2.1"])) (CloudDns.parseRecordDescribe (described 300 ["192.0.2.2", "192.0.2.1"]))+    , testCase "the declared data in any order is satisfied" $+        assertEqual "" Success (CloudDns.interpretRecordDescribe a ExitSuccess (described 300 ["192.0.2.2", "192.0.2.1"]))+    , testCase "describe failing means the record is absent" $+        assertBool "" (isFailure (CloudDns.interpretRecordDescribe a (ExitFailure 1) ""))+    , testCase "other data is a failure naming both" $+        case CloudDns.interpretRecordDescribe a ExitSuccess (described 300 ["192.0.2.9"]) of+            Failure why -> do+                assertBool (Text.unpack why) ("192.0.2.9" `Text.isInfixOf` why)+                assertBool (Text.unpack why) ("192.0.2.1, 192.0.2.2" `Text.isInfixOf` why)+            other -> assertBool ("expected a Failure, got " <> show other) False+    , testCase "a subset of the declared data is a failure" $+        assertBool "" (isFailure (CloudDns.interpretRecordDescribe a ExitSuccess (described 300 ["192.0.2.1"])))+    , testCase "another TTL is a failure naming both" $+        case CloudDns.interpretRecordDescribe a ExitSuccess (described 60 ["192.0.2.1", "192.0.2.2"]) of+            Failure why -> assertBool (Text.unpack why) ("60" `Text.isInfixOf` why && "300" `Text.isInfixOf` why)+            other -> assertBool ("expected a Failure, got " <> show other) False+    , testCase "output that is not a record description cannot be judged" $+        assertEqual "" Unknown (CloudDns.interpretRecordDescribe a ExitSuccess "not json")+    , testCase "a CNAME compares whatever the dot and case" $+        assertEqual "" Success (CloudDns.interpretRecordDescribe (cname {CloudDns.recordData = ["Target.Example.NET"]}) ExitSuccess (described 300 ["target.example.net."]))+    , testCase "a TXT compares by content, quoted as Cloud DNS reports it" $+        assertEqual "" Success (CloudDns.interpretRecordDescribe txt ExitSuccess (described 300 ["\"second\"", "\"v=spf1 ip4:192.0.2.0/24,\" \"-all\""]))+    , testCase "an AAAA compares whatever the case" $+        assertEqual "" Success (CloudDns.interpretRecordDescribe (a {CloudDns.recordType = CloudDns.AAAA, CloudDns.recordData = ["2001:DB8::1"]}) ExitSuccess (described 300 ["2001:db8::1"]))+    , testCase "up creates what describe did not find and updates what it did" $ do+        assertEqual "" ["create"] (verb (CloudDns.recordUpCommand a (ExitFailure 1)))+        assertEqual "" ["update"] (verb (CloudDns.recordUpCommand a ExitSuccess))+    , testCase "a writable record set has no problems, at the apex included" $ do+        assertEqual "" [] (CloudDns.recordSetProblems a)+        assertEqual "" [] (CloudDns.recordSetProblems (a {CloudDns.recordName = "Example.org"}))+        assertEqual "" [] (CloudDns.recordSetProblems cname)+    , testCase "no data, a name outside the zone, a CNAME at the apex or with two targets are refused" $ do+        assertEqual "" 1 (length (CloudDns.recordSetProblems (a {CloudDns.recordData = []})))+        assertEqual "" 1 (length (CloudDns.recordSetProblems (a {CloudDns.recordName = "www.example.net"})))+        assertEqual "" 1 (length (CloudDns.recordSetProblems (a {CloudDns.recordName = "wwwexample.org"})))+        assertEqual "" 1 (length (CloudDns.recordSetProblems (cname {CloudDns.recordName = "example.org"})))+        assertEqual "" 1 (length (CloudDns.recordSetProblems (cname {CloudDns.recordData = ["a.example.net", "b.example.net"]})))+    , testCase "a record set that cannot be written fails its check whatever is live" $+        assertBool "" (isFailure (CloudDns.interpretRecordDescribe (a {CloudDns.recordName = "www.example.net"}) ExitSuccess (described 300 ["192.0.2.1", "192.0.2.2"])))+    , testGroup "MX" mxTests+    , testCase "A, AAAA, CNAME and TXT nodes are described as before MX existed" $ do+        let node rs = only (CloudDns.recordSet silent ignoreTrack rs)+            key :: CloudDns.RecordSet -> Text.Text -> (Text.Text, Text.Text, Text.Text, Text.Text)+            key rs t = ("my-project", "example-zone", CloudDns.fqdn rs.recordName, t)+            aaaa = a{CloudDns.recordType = CloudDns.AAAA, CloudDns.recordData = ["2001:DB8::1"]}+        mapM_+            ( \(rs, t, wantHelp, wantNotes) -> do+                assertEqual "ref" (mkRef "gcp-dns-record" (key rs t)) (node rs).extension.ref+                assertEqual "help" wantHelp (node rs).extension.help+                assertEqual "notes" wantNotes (node rs).extension.notes+            )+            [ (a, "A", "sets DNS record A www.example.org. in zone example-zone", ["ttl 300", "192.0.2.1 192.0.2.2"])+            , (aaaa, "AAAA", "sets DNS record AAAA www.example.org. in zone example-zone", ["ttl 300", "2001:db8::1"])+            , (cname, "CNAME", "sets DNS record CNAME alias.example.org. in zone example-zone", ["ttl 300", "target.example.net."])+            , (txt, "TXT", "sets DNS record TXT example.org. in zone example-zone", ["ttl 300", "second v=spf1 ip4:192.0.2.0/24,-all"])+            ]+        assertEqual "" ["--rrdatas=2001:db8::1"] (rrdataFlag (args (CloudDns.RecordSetsCreate aaaa)))+    ]+  where+    only o = case Map.elems (Dag.dagNodes (Dag.foldDag Dag.sameRepresentative (evalDeps o))) of+        [act] -> act+        acts -> error ("expected one node, got " <> show (length acts))+    mx = CloudDns.mxRecordSet zone "example.org" 3600 [CloudDns.MailExchanger 10 "mx1.mail.example.net", CloudDns.MailExchanger 20 "MX2.mail.example.net."]+    mxTests =+        [ testCase "a record set of exchangers is one datum per exchanger, the name dotted" $+            assertEqual "" (CloudDns.RecordSet zone "example.org" CloudDns.MX 3600 ["10 mx1.mail.example.net.", "20 mx2.mail.example.net."]) mx+        , testCase "create hands gcloud preference and server, at the apex" $+            assertEqual+                ""+                ["dns", "record-sets", "create", "example.org.", "--zone", "example-zone", "--type", "MX", "--project", "my-project", "--ttl", "3600", "--rrdatas=10 mx1.mail.example.net.,20 mx2.mail.example.net.", "--quiet"]+                (args (CloudDns.RecordSetsCreate mx))+        , testCase "a datum written by hand is sent in the same form" $+            assertEqual+                ""+                (args (CloudDns.RecordSetsCreate mx))+                (args (CloudDns.RecordSetsCreate mx{CloudDns.recordData = [" 10   MX1.mail.example.net", "20\tmx2.mail.example.net."]}))+        , testCase "describe and delete name the type" $ do+            assertEqual "" ["--type", "MX"] (take 2 (drop 6 (args (CloudDns.RecordSetsDescribe mx))))+            assertEqual "" ["--type", "MX"] (take 2 (drop 6 (args (CloudDns.RecordSetsDelete mx))))+        , testCase "a subdomain takes one too" $ do+            let sub = CloudDns.mxRecordSet zone "bounce.example.org" 300 [CloudDns.MailExchanger 10 "feedback.mail.example.net"]+            assertEqual "" [] (CloudDns.recordSetProblems sub)+            assertEqual "" "bounce.example.org." (args (CloudDns.RecordSetsCreate sub) !! 3)+        , testCase "read-back compares whatever the order, the dot and the case" $ do+            assertEqual "" Success (CloudDns.interpretRecordDescribe mx ExitSuccess (described 3600 ["20 mx2.mail.example.net.", "10 mx1.mail.example.net."]))+            assertEqual "" Success (CloudDns.interpretRecordDescribe mx ExitSuccess (described 3600 ["20 MX2.mail.example.net", "10  mx1.mail.example.net"]))+        , testCase "another preference, a missing exchanger or an extra one is a failure naming both sets" $ do+            case CloudDns.interpretRecordDescribe mx ExitSuccess (described 3600 ["10 mx1.mail.example.net.", "30 mx2.mail.example.net."]) of+                Failure why -> do+                    assertBool (Text.unpack why) ("30 mx2.mail.example.net." `Text.isInfixOf` why)+                    assertBool (Text.unpack why) ("20 mx2.mail.example.net." `Text.isInfixOf` why)+                other -> assertBool (show other) False+            assertBool "" (isFailure (CloudDns.interpretRecordDescribe mx ExitSuccess (described 3600 ["10 mx1.mail.example.net."])))+            assertBool "" (isFailure (CloudDns.interpretRecordDescribe mx ExitSuccess (described 3600 ["10 mx1.mail.example.net.", "20 mx2.mail.example.net.", "30 mx3.mail.example.net."])))+            assertBool "" (isFailure (CloudDns.interpretRecordDescribe mx ExitSuccess (described 300 ["10 mx1.mail.example.net.", "20 mx2.mail.example.net."])))+        , testCase "parseMxDatum reads a preference and a name, and nothing else" $ do+            assertEqual "" (Just (CloudDns.MailExchanger 10 "mail.example.org.")) (CloudDns.parseMxDatum "10 Mail.example.org")+            assertEqual "" (Just (CloudDns.MailExchanger 0 ".")) (CloudDns.parseMxDatum "0 .")+            mapM_+                (\d -> assertEqual (Text.unpack d) Nothing (CloudDns.parseMxDatum d))+                ["mail.example.org", "10", "", "ten mail.example.org", "-1 mail.example.org", "10 mail.example.org extra", "1234567 mail.example.org"]+        , testCase "a datum that is not an exchanger, a preference too large, an address, a server twice are refused" $ do+            assertEqual "" [] (CloudDns.recordSetProblems mx)+            assertEqual "" [] (CloudDns.recordSetProblems (CloudDns.mxRecordSet zone "example.org" 300 [CloudDns.MailExchanger 0 "."]))+            let problems ds = length (CloudDns.recordSetProblems mx{CloudDns.recordData = ds})+            assertEqual "" 1 (problems [])+            assertEqual "" 1 (problems ["mail.example.net"])+            assertEqual "" 1 (problems ["70000 mail.example.net"])+            assertEqual "" 1 (problems ["10 192.0.2.1"])+            assertEqual "" 1 (problems ["10 2001:db8::1"])+            assertEqual "" 1 (problems ["10 mail.example.net", "20 MAIL.example.net."])+            assertEqual "" 1 (problems ["0 .", "10 mail.example.net"])+            assertEqual "" 1 (problems ["10 ."])+            assertBool "" (isFailure (CloudDns.interpretRecordDescribe mx{CloudDns.recordData = ["mail.example.net"]} ExitSuccess (described 3600 ["mail.example.net"])))+        , testCase "the node is keyed on name and type, exchangers in the notes" $ do+            let node rs = only (CloudDns.recordSet silent ignoreTrack rs)+            assertEqual "" (mkRef "gcp-dns-record" ("my-project" :: Text.Text, "example-zone" :: Text.Text, "example.org." :: Text.Text, "MX" :: Text.Text)) (node mx).extension.ref+            assertEqual "" "sets DNS record MX example.org. in zone example-zone" (node mx).extension.help+            assertEqual "" ["ttl 3600", "10 mx1.mail.example.net. 20 mx2.mail.example.net."] (node mx).extension.notes+            assertEqual "" (node mx).extension.notes (node mx{CloudDns.recordData = reverse mx.recordData}).extension.notes+            assertBool "" ((node mx).extension.ref /= (node txt).extension.ref)+        ]+    zone = CloudDns.ManagedZone "example-zone" (Core.Project "my-project") "example.org" "a zone"+    a = CloudDns.RecordSet zone "www.example.org" CloudDns.A 300 ["192.0.2.1", "192.0.2.2"]+    cname = CloudDns.RecordSet zone "alias.example.org." CloudDns.CNAME 300 ["target.example.net"]+    txt = CloudDns.RecordSet zone "example.org" CloudDns.TXT 300 ["v=spf1 ip4:192.0.2.0/24,-all", "second"]+    args = processArgs . prepare CloudDns.cloudDnsCommand+    verb = take 1 . drop 2 . args+    rrdataFlag = filter ("--rrdatas=" `isPrefixOf`)+    described :: Int -> [Text.Text] -> ByteString.ByteString+    described ttl rrdatas =+        LByteString.toStrict $+            encode $+                object+                    [ "kind" .= ("dns#resourceRecordSet" :: Text.Text)+                    , "name" .= ("www.example.org." :: Text.Text)+                    , "type" .= ("A" :: Text.Text)+                    , "ttl" .= ttl+                    , "rrdatas" .= rrdatas+                    ]
+ test/Test/GraphFixture.hs view
@@ -0,0 +1,485 @@+{-# LANGUAGE OverloadedStrings #-}++{- | Synthetic 'Op' graphs of a chosen shape and size, for testing scale on+purpose instead of discovering it in production.++A fixture is a __recursive unfold__: 'successors' maps a node number to the+numbers of the nodes it depends on, and 'generateWith' turns that into an 'Op'+whose @predecessors@ are built on demand. Nothing is memoised and nothing is+retained, so describing a million-node graph costs one closure; the cost is+paid by whoever expands it, and only for the part they expand.++Every node is an in-process no-op (no 'IO' beyond a @pure@), carries its own+'Ref' ('nodeRef'), and is numbered so that a node's dependencies always have+larger numbers than it does: node 0 is the root, and the numbering is a+topological order.++= Per node versus per path++The shapes are chosen to separate the two costs a consumer can have:++* 'Chain', 'Fan' and 'Tree' have no sharing: the expanded tree+  ('Salmon.Builtin.Extension.evalDeps') has exactly one occurrence per node.+* 'Diamonds', 'Layered' and 'RandomLayered' share nodes, so the expanded tree+  is exponentially larger than the graph. 'countOccurrences' says by how+  much. Anything that walks the whole expansion pays per occurrence+  ('Salmon.Op.Dag.foldDag' did, and no longer does), so pick the size with+  'counts' in hand: a million-node chain of diamonds has more occurrences+  than can ever be visited.++= Sizes++The size is a __requested node count__. A shape realises the largest graph it+can that does not exceed it (and never fewer than the root alone), and+'counts' states what was actually built. 'Chain', 'Fan' and 'Tree' hit every+size exactly.++Layer 0 only: nothing here measures anything.+-}+module Test.GraphFixture (+    -- * Shapes+    Shape (..),+    Seed,++    -- * Generating+    generate,+    generateWith,+    Options (..),+    EdgeForm (..),+    defaultOptions,+    holdUntilCancelled,++    -- * What was generated, in closed form+    Counts (..),+    counts,+    colliderCount,++    -- * The folded graph, without the fold+    dagOf,++    -- * The unfold itself+    successors,+    isCollider,+    nodeRef,+    nodeName,+) where++import Control.Concurrent (threadDelay)+import Control.Monad (forever)+import Control.Monad.Identity (Identity (..))+import Data.Bits (shiftR, xor)+import Data.Dynamic (Dynamic)+import Data.Foldable (toList)+import Data.List (foldl')+import qualified Data.Map.Strict as Map+import qualified Data.Set as Set+import Data.Text (Text)+import qualified Data.Text as Text+import Data.Word (Word64)+import System.Exit (ExitCode)++import Salmon.Actions.UpDown (CheckResult (..))+import Salmon.Builtin.Extension (Extension (..), Op, Output, nodeps, op, opAct)+import Salmon.Op.Actions (Act (..))+import Salmon.Op.Dag (Dag (..))+import Salmon.Op.Graph (Graph (..))+import Salmon.Op.OpGraph (OpGraph (..))+import Salmon.Op.Ref (Ref, mkRef)++-------------------------------------------------------------------------------++{- | A family of graphs. The fields are what stays fixed as the size grows;+out-of-range values are clamped (a width or branching factor below 1 is 1, a+sharing factor is kept within @1..width@).+-}+data Shape+    = -- | Depth: each node depends on the next one.+      Chain+    | -- | Width: one root over @size - 1@ leaves.+      Fan+    | -- | A complete tree with the given branching factor: no sharing.+      Tree Int+    | -- | A chain of diamonds of the given width: a join node over @width@+      -- middle nodes that all depend on the next join. The smallest shape+      -- whose paths double (for width 2) at every step.+      Diamonds Int+    | -- | @Layered width sharing@: a root over a first layer of @width@+      -- nodes, each layer depending on the next. The node at position @p@+      -- depends on positions @p .. p + sharing - 1@ (wrapping) of the layer+      -- below, so @sharing = 1@ is @width@ independent chains and+      -- @sharing = width@ is a complete bipartite step.+      Layered Int Int+    | -- | As 'Layered', but which @sharing@ nodes of the layer below a node+      -- depends on is drawn from the seed. Position @p@ always depends on+      -- position @p@ below it (so every node stays reachable) and on+      -- @sharing - 1@ other distinct positions. Every node still has exactly+      -- @sharing@ dependencies, which is why the totals in 'counts' are the+      -- same as 'Layered' and do not depend on the seed; how the paths are+      -- spread over the nodes of a layer does.+      RandomLayered Int Int+    deriving (Show, Eq)++{- | Everything random about a fixture is a pure function of this: the same+shape, size, options and seed give the same graph. Only 'RandomLayered' and+the choice of colliding nodes ('optCollisions') read it.+-}+type Seed = Word64++-- | A shape with its size resolved to the parameters that were realised.+data Layout+    = -- | node count+      LChain !Int+    | -- | node count+      LFan !Int+    | -- | branching, node count+      LTree !Int !Int+    | -- | diamonds, width+      LDiamonds !Int !Int+    | -- | drawn from the seed?, layers, width, sharing+      LLayered !Bool !Int !Int !Int++layout :: Shape -> Int -> Layout+layout shape size =+    case shape of+        Chain -> LChain n+        Fan -> LFan n+        Tree b -> LTree (max 1 b) n+        Diamonds w ->+            let w' = max 1 w+             in LDiamonds ((n - 1) `div` (w' + 1)) w'+        Layered w k -> layered False w k+        RandomLayered w k -> layered True w k+  where+    n = max 1 size+    layered random w k =+        let w' = max 1 w+         in LLayered random ((n - 1) `div` w') w' (max 1 (min w' k))++-------------------------------------------------------------------------------++{- | What a fixture contains, as formulas rather than by walking it.++'Integer' because the last two outgrow a machine word quickly: that is the+point of the shapes that share.+-}+data Counts = Counts+    { countNodes :: !Integer+    -- ^ Distinct nodes, i.e. distinct 'Ref's: the size of the folded 'Salmon.Op.Dag.Dag'.+    , countEdges :: !Integer+    -- ^ Distinct dependency edges: the size of 'Salmon.Op.Dag.dagEdges'.+    , countOccurrences :: !Integer+    -- ^ Paths from the root to any node, the root's own empty path+    -- included: the number of nodes of the expanded tree+    -- ('Salmon.Builtin.Extension.evalDeps'). Equal to 'countNodes' exactly+    -- when nothing is shared.+    , countLeafPaths :: !Integer+    -- ^ Paths from the root to a node with no dependencies: the leaves of+    -- the expanded tree.+    }+    deriving (Show, Eq)++{- | The counts of @'generate' shape size seed@, for any seed.++With 'optCollisions' above zero the graph keeps these nodes and edges, and+each colliding node adds one leaf to the expansion per occurrence of it; see+'optCollisions'.+-}+counts :: Shape -> Int -> Counts+counts shape size =+    case layout shape size of+        LChain n -> Counts (int n) (int n - 1) (int n) 1+        LFan n -> Counts (int n) (int n - 1) (int n) (max 1 (int n - 1))+        LTree b n ->+            -- node i has children exactly when b * i + 1 < n+            let internal = (int n - 1 + int b - 1) `div` int b+             in Counts (int n) (int n - 1) (int n) (int n - internal)+        LDiamonds k w ->+            Counts+                { countNodes = 1 + int k * (int w + 1)+                , countEdges = 2 * int k * int w+                , -- join i is reached by w^i paths, and so is each of the w+                  -- middle nodes that depend on it+                  countOccurrences = 2 * geometric (int w) (int k + 1) - 1+                , countLeafPaths = int w ^ k+                }+        LLayered _ 0 _ _ -> Counts 1 0 1 1+        LLayered _ l w k ->+            Counts+                { countNodes = 1 + int l * int w+                , countEdges = int w + (int l - 1) * int w * int k+                , -- every node has k dependencies, so the paths reaching a+                  -- layer are k times those reaching the one above+                  countOccurrences = 1 + int w * geometric (int k) (int l)+                , countLeafPaths = int w * int k ^ (l - 1)+                }+  where+    int :: Int -> Integer+    int = fromIntegral++-- | @geometric r n = 1 + r + ... + r^(n-1)@.+geometric :: Integer -> Integer -> Integer+geometric r n+    | r == 1 = n+    | otherwise = (r ^ n - 1) `div` (r - 1)++-------------------------------------------------------------------------------++{- | The unfold: the nodes a node depends on, in declaration order. Total: a+number outside the graph depends on nothing.++Partially apply it to the shape, size and seed and keep the result: the+layout is resolved once.+-}+successors :: Shape -> Int -> Seed -> Int -> [Int]+successors shape size seed =+    case layout shape size of+        LChain n -> \i -> [i + 1 | i >= 0, i + 1 < n]+        LFan n -> \i -> if i == 0 then [1 .. n - 1] else []+        LTree b n -> \i -> if i < 0 then [] else takeWhile (< n) [b * i + 1 .. b * i + b]+        LDiamonds k w -> \i ->+            let (q, r) = i `divMod` (w + 1)+             in if i < 0 || q > k || (q == k && r /= 0)+                    then []+                    else+                        if r == 0+                            then (if q < k then [i + 1 .. i + w] else [])+                            else [(q + 1) * (w + 1)]+        LLayered random l w k -> \i ->+            if i == 0+                then (if l > 0 then [1 .. w] else [])+                else+                    let (layer, p) = (i - 1) `divMod` w+                        below q = 1 + (layer + 1) * w + q+                     in if i < 0 || layer + 1 >= l+                            then []+                            else+                                fmap below $+                                    if random+                                        then drawn seed w k layer p+                                        else [(p + j) `mod` w | j <- [0 .. k - 1]]++{- | Position @p@ itself, then @k - 1@ other distinct positions of a layer+@w@ wide, drawn (Floyd's sampling) from a stream keyed on the seed and the+node, so one node's draw never depends on having computed another's.+-}+drawn :: Seed -> Int -> Int -> Int -> Int -> [Int]+drawn seed w k layer p = p : [(p + o) `mod` w | o <- Set.toAscList offsets]+  where+    n = w - 1+    key = mix (mix (seed `xor` 0x243f6a8885a308d3) + fromIntegral layer) + fromIntegral p+    offsets = foldl pick Set.empty [n - (k - 1) + 1 .. n]+    pick acc j =+        let t = 1 + fromIntegral (mix (key + fromIntegral j * 0x9e3779b97f4a7c15) `mod` fromIntegral j)+         in if Set.member t acc then Set.insert j acc else Set.insert t acc++-- | The splitmix64 finaliser: a cheap bijective scrambler of a 'Word64'.+mix :: Word64 -> Word64+mix z0 =+    let z1 = (z0 `xor` (z0 `shiftR` 30)) * 0xbf58476d1ce4e5b9+        z2 = (z1 `xor` (z1 `shiftR` 27)) * 0x94d049bb133111eb+     in z2 `xor` (z2 `shiftR` 31)++-------------------------------------------------------------------------------++-- | How a node's dependencies are written down.+data EdgeForm+    = -- | One @Vertices@ list: what 'Salmon.Builtin.Extension.deps' builds.+      AsVertices+    | -- | A @Connect@ per dependency: what repeated+      -- 'Salmon.Op.OpGraph.inject' builds.+      AsConnect+    | -- | An @Overlay@ per dependency: what repeated+      -- 'Salmon.Op.OpGraph.overlaid' builds.+      AsOverlay+    deriving (Show, Eq)++-- | What the profiles need to vary about the nodes. See 'defaultOptions'.+data Options = Options+    { optCheck :: CheckResult+    -- ^ The verdict every node's @check@ answers, without looking at anything.+    , optManaged :: Bool+    -- ^ Give every node a @managed@ action ('holdUntilCancelled').+    , optDynamics :: Int -> [Dynamic]+    -- ^ The @dynamics@ payload of a node, by number.+    , optEdges :: EdgeForm+    , optCollisions :: Rational+    -- ^ The fraction (clamped to @0..1@) of the nodes other than the root+    -- that are declared twice under one 'Ref' with differing @help@, which+    -- is what 'Salmon.Op.Dag.foldDag' reports as a conflict. Exactly+    -- 'colliderCount' nodes are; which ones is drawn from the seed.+    --+    -- The second declaration is a leaf written right after the real one,+    -- wherever the real one is depended upon. So the folded graph keeps its+    -- nodes and edges ('counts' still holds for those two), the second+    -- declaration is the surviving representative (last writer wins), and+    -- the expansion gains one leaf per occurrence of a colliding node.+    }++{- | What 'generate' uses: @check@ answers 'Immaterial' (what a node with no+@check@ says), nothing is @managed@, no @dynamics@, 'AsVertices', no+collisions.+-}+defaultOptions :: Options+defaultOptions =+    Options+        { optCheck = Immaterial+        , optManaged = False+        , optDynamics = const []+        , optEdges = AsVertices+        , optCollisions = 0+        }++{- | A @managed@ action with no effect: it holds its thread until that thread+is cancelled, which is how a driver stands a managed node down. It never+returns of its own accord, so it never looks like a process that exited.+-}+holdUntilCancelled :: Output -> IO ExitCode+holdUntilCancelled _ = forever (threadDelay 1000000000)++-- | The 'Ref' of a node, by number. Distinct numbers give distinct 'Ref's.+nodeRef :: Int -> Ref+nodeRef = mkRef "graph-fixture"++-- | The shorthand of a node, by number.+nodeName :: Int -> Text+nodeName i = "n" <> Text.pack (show i)++{- | How many nodes 'optCollisions' declares twice: the fraction of the+non-root nodes, rounded down.+-}+colliderCount :: Options -> Shape -> Int -> Integer+colliderCount opts shape size =+    floor (fraction opts * fromIntegral (countNodes (counts shape size) - 1))++fraction :: Options -> Rational+fraction opts = max 0 (min 1 opts.optCollisions)++{- | Whether a node is one of the 'colliderCount' declared twice. Partially+apply it and keep the result, as for 'successors'.++The non-root nodes are put through a permutation drawn from the seed (an+affine map modulo their number) and the first 'colliderCount' of the result+collide, so the count is exact and the choice needs no table.+-}+isCollider :: Options -> Shape -> Int -> Seed -> Int -> Bool+isCollider opts shape size seed+    | wanted <= 0 = const False+    | otherwise = \i -> i >= 1 && i <= others && ((a * (i - 1) + b) `mod` others) < wanted+  where+    others :: Int+    others = fromIntegral (countNodes (counts shape size)) - 1+    wanted :: Int+    wanted = fromIntegral (colliderCount opts shape size)+    draw :: Word64 -> Int+    draw salt = fromIntegral (mix (seed `xor` salt) `mod` fromIntegral others)+    b = draw 0x13198a2e03707344+    -- an affine map is a permutation exactly when its multiplier is coprime+    -- with the modulus; the search ends at the latest on a prime.+    a = head [c | c <- [max 1 (draw 0xa4093822299f31d0) ..], gcd c others == 1]++-- | 'generateWith' 'defaultOptions'.+generate :: Shape -> Int -> Seed -> Op+generate = generateWith defaultOptions++{- | The root of the fixture. Its predecessors are built when asked for and+not kept, so two expansions build the graph twice and neither holds it for+the other.+-}+generateWith :: Options -> Shape -> Int -> Seed -> Op+generateWith opts shape size seed = declared 0+  where+    next = successors shape size seed+    collides = isCollider opts shape size seed++    declared :: Int -> Op+    declared i = op (nodeName i) (Identity (written (concatMap declarations (next i)))) (dress i)++    declarations :: Int -> [Op]+    declarations j+        | collides j = [declared j, impostor j]+        | otherwise = [declared j]++    -- same 'Ref' and shorthand, differing @help@: a differing representative.+    impostor :: Int -> Op+    impostor j = op (nodeName j) nodeps (\x -> (dress j x){help = "graph fixture node (colliding declaration)"})++    dress :: Int -> Extension -> Extension+    dress i x =+        x+            { ref = nodeRef i+            , help = "graph fixture node"+            , check = pure opts.optCheck+            , managed = if opts.optManaged then Just holdUntilCancelled else Nothing+            , dynamics = opts.optDynamics i+            }++    written :: [Op] -> Graph Op+    written xs =+        case opts.optEdges of+            AsVertices -> Vertices xs+            AsConnect -> foldr (Connect . Vertices . pure) (Vertices []) xs+            AsOverlay -> foldr (Overlay . Vertices . pure) (Vertices []) xs++-------------------------------------------------------------------------------++{- | The 'Dag' that folding a graph gives, built by visiting each node once+instead of each occurrence: the same nodes, the same edges and the same+'Salmon.Op.Dag.dagOrder' (first met, depth first) as+@'Salmon.Op.Dag.foldDag' same . 'Salmon.Builtin.Extension.evalDeps'@.++It was written when 'Salmon.Op.Dag.foldDag' walked every occurrence, so+that a shape that shares could not be folded at any size worth measuring.+The fold is per node now; this stays because 'Salmon.Op.Dag.record' (which+the fold and 'Salmon.Op.Dag.fromMagma' both go through) is itself one of the+things a profile wants to measure rather than pay for on the way in, and+because it shares no code with the fold it is compared against. Linear in nodes and edges, with a 'Set.Set' of+the 'Ref's met.++Two differences from the fold, neither visible to a walk: a node met again+is not looked at a second time, so the first declaration of a colliding+'Ref' is the representative (the fold keeps the last) and no+'Salmon.Op.Dag.dagConflicts' are recorded; and a node's dependants are+listed in the order their own first occurrences were met.+-}+dagOf :: Op -> Dag Extension+dagOf root =+    Dag+        { dagNodes = Map.fromList [(r, act) | (r, act, _) <- entriesRev]+        , dagDependencies = Map.fromList [(r, ds) | (r, _, ds) <- entriesRev]+        , dagDependants =+            -- entries arrive last met first and each one is consed on, so a+            -- node's dependants end up first met first.+            Map.union+                (Map.fromListWith (<>) [(d, [r]) | (r, _, ds) <- entriesRev, d <- ds])+                (Map.fromList [(r, []) | (r, _, _) <- entriesRev])+        , dagOrderRev = [r | (r, _, _) <- entriesRev]+        , dagConflicts = []+        }+  where+    entriesRev :: [(Ref, Act Extension, [Ref])]+    entriesRev = snd (visit (Set.empty, []) root)++    visit :: (Set.Set Ref, [(Ref, Act Extension, [Ref])]) -> Op -> (Set.Set Ref, [(Ref, Act Extension, [Ref])])+    visit acc@(seen, done) o =+        case opAct o of+            Nothing -> foldl' visit acc (effective o)+            Just act+                | Set.member act.extension.ref seen -> acc+                | otherwise ->+                    let kids = effective o+                        ds = distinct [k.extension.ref | Just k <- fmap opAct kids]+                     in foldl' visit (Set.insert act.extension.ref seen, (act.extension.ref, act, ds) : done) kids++    -- the nearest nodes carrying a 'Ref' below a node, through any glue.+    effective :: Op -> [Op]+    effective o = concatMap pick (toList (runIdentity o.predecessors))+      where+        pick k = maybe (effective k) (const [k]) (opAct k)++    distinct :: [Ref] -> [Ref]+    distinct = go Set.empty+      where+        go _ [] = []+        go s (y : ys)+            | Set.member y s = go s ys+            | otherwise = y : go (Set.insert y s) ys
+ test/Test/GraphFixtureSpec.hs view
@@ -0,0 +1,344 @@+{-# LANGUAGE OverloadedStrings #-}++{- | Layer 0 coverage for "Test.GraphFixture" itself: the generator is only+useful to the suites that will lean on it if its closed forms are right, so+they are checked here three ways at small sizes: against a plain walk of the+unfold, against the expansion of the generated 'Op', and against its fold.++Nothing here measures anything. The one large case is opt-in (see+'largeEnv'); the default run stops at a few tens of thousands of nodes.+-}+module Test.GraphFixtureSpec (tests) where++import Control.Comonad.Cofree (Cofree (..))+import Control.Monad (forM_, unless)+import Control.Monad.Identity (runIdentity)+import Data.Dynamic (toDyn)+import Data.Foldable (toList)+import qualified Data.Map.Strict as Map+import Data.Maybe (isJust, isNothing)+import qualified Data.Set as Set+import System.Environment (lookupEnv)+import System.IO (hPutStrLn, stderr)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, assertEqual, assertFailure, testCase)+import Text.Read (readMaybe)++import Salmon.Actions.UpDown (CheckResult (..))+import Salmon.Builtin.Extension (Extension, Op, check, dynamics, evalDeps, getDynamics, help, managed, notes, opAct, ref)+import Salmon.Op.Actions (Act (..))+import qualified Salmon.Op.Dag as Dag+import Salmon.Op.Graph (Graph (..))+import Salmon.Op.OpGraph (OpGraph (..))+import Salmon.Op.Ref (Ref)++import Test.GraphFixture++tests :: TestTree+tests =+    testGroup+        "Test.GraphFixture"+        [ testCase "the closed forms match a walk of the unfold" closedFormsMatchUnfold+        , testCase "the generated Op expands and folds to the closed forms" opMatchesClosedForms+        , testCase "a size is never exceeded, and chain, fan and tree hit it exactly" sizesAreRespected+        , testCase "the same seed gives the same graph, and only the random shape reads it" deterministic+        , testCase "a random layer keeps its own position and draws distinct others" randomLayerDraws+        , testCase "check, managed and dynamics are what the options say" nodeOptions+        , testCase "the edge form changes how edges are written, not the graph" edgeForms+        , testCase "the collision fraction yields exactly that many conflicting refs" collisions+        , testCase "describing a million nodes costs nothing until expanded" lazyDescription+        , testCase "every shape is walkable per node at tens of thousands of nodes" perNodeWalk+        , testCase ("a per-node walk at the size in " <> largeEnv <> " (opt-in)") largeWalk+        ]++-------------------------------------------------------------------------------++shapes :: [Shape]+shapes =+    [ Chain+    , Fan+    , Tree 1+    , Tree 2+    , Tree 3+    , Diamonds 1+    , Diamonds 2+    , Diamonds 3+    , Layered 1 1+    , Layered 3 1+    , Layered 4 2+    , Layered 3 3+    , RandomLayered 4 2+    , RandomLayered 5 3+    , RandomLayered 3 3+    ]++-- | Small enough that the shapes that share stay cheap to expand.+sizes :: [Int]+sizes = [0, 1, 2, 3, 4, 5, 7, 12, 20, 25]++seeds :: [Seed]+seeds = [0, 1, 42]++label :: Shape -> Int -> Seed -> String+label shape size seed = show shape <> " at " <> show size <> ", seed " <> show seed++{- | The fixture's counts obtained the slow way, from 'successors' alone,+together with the number of paths reaching each node.+-}+walkUnfold :: (Int -> [Int]) -> (Counts, Map.Map Int Integer, Set.Set (Int, Int))+walkUnfold next = (Counts nodeCount edgeCount occurrences leafPaths, paths, edges)+  where+    reachable = go Set.empty [0]+    go seen [] = seen+    go seen (i : rest)+        | Set.member i seen = go seen rest+        | otherwise = go (Set.insert i seen) (next i <> rest)++    edges = Set.fromList [(i, j) | i <- Set.toList reachable, j <- next i]++    -- a node's dependencies have larger numbers than it does, so ascending+    -- order has every path into a node counted before it is passed on.+    paths = foldl pass (Map.singleton 0 1) (Set.toAscList reachable)+    pass acc i =+        let here = Map.findWithDefault 0 i acc+         in foldl (\m j -> Map.insertWith (+) j here m) acc (next i)++    nodeCount = fromIntegral (Set.size reachable)+    edgeCount = fromIntegral (Set.size edges)+    occurrences = sum (Map.elems paths)+    leafPaths = sum [p | (i, p) <- Map.toList paths, null (next i)]++foldOf :: Op -> Dag.Dag Extension+foldOf = Dag.foldDag Dag.sameRepresentative . evalDeps++expansionLeaves :: Cofree Graph a -> Integer+expansionLeaves (_ :< g) =+    case toList g of+        [] -> 1+        below -> sum (fmap expansionLeaves below)++{- | The distinct 'Ref's of a graph, visiting each node once: what a consumer+that does not walk paths pays. An explicit stack, so depth costs heap rather+than stack.+-}+distinctRefs :: Op -> Int+distinctRefs root = go Set.empty [root]+  where+    go :: Set.Set Ref -> [Op] -> Int+    go seen [] = Set.size seen+    go seen (o : rest) =+        case opAct o of+            Just act+                | Set.member act.extension.ref seen -> go seen rest+                | otherwise -> go (Set.insert act.extension.ref seen) (below o <> rest)+            Nothing -> go seen (below o <> rest)++-- | What a node declares it depends on, one step down.+below :: Op -> [Op]+below o = toList (runIdentity o.predecessors)++-------------------------------------------------------------------------------++closedFormsMatchUnfold :: IO ()+closedFormsMatchUnfold =+    forM_ [(shape, size, seed) | shape <- shapes, size <- sizes, seed <- seeds] $ \(shape, size, seed) -> do+        let (walked, _, edges) = walkUnfold (successors shape size seed)+        assertEqual (label shape size seed) (counts shape size) walked+        assertBool+            (label shape size seed <> ": a dependency has a larger number than its dependant")+            (all (\(i, j) -> j > i) (Set.toList edges))++opMatchesClosedForms :: IO ()+opMatchesClosedForms =+    forM_ [(shape, size, seed) | shape <- shapes, size <- sizes, seed <- seeds] $ \(shape, size, seed) -> do+        let expected = counts shape size+            (_, _, edges) = walkUnfold (successors shape size seed)+            root = generate shape size seed+            expansion = evalDeps root+            dag = foldOf root+            at what = label shape size seed <> ": " <> what+        assertEqual (at "occurrences") expected.countOccurrences (fromIntegral (length expansion))+        assertEqual (at "leaf paths") expected.countLeafPaths (expansionLeaves expansion)+        assertEqual (at "nodes") expected.countNodes (fromIntegral (Map.size (Dag.dagNodes dag)))+        assertEqual (at "per-node walk") expected.countNodes (fromIntegral (distinctRefs root))+        -- the fold's edges are the unfold's, node for node: this is also+        -- what says the refs are distinct.+        assertEqual+            (at "edges")+            (Set.map (\(i, j) -> (nodeRef j, nodeRef i)) edges)+            (Dag.dagEdges dag)+        assertEqual (at "edge count") expected.countEdges (fromIntegral (Set.size (Dag.dagEdges dag)))+        assertEqual (at "the root is the only root") [nodeRef 0] (Dag.roots dag)+        assertEqual (at "no conflicts") 0 (length (Dag.dagConflicts dag))++sizesAreRespected :: IO ()+sizesAreRespected =+    forM_ [(shape, size) | shape <- shapes, size <- [1 .. 60]] $ \(shape, size) -> do+        let built = (counts shape size).countNodes+        assertBool (show shape <> " at " <> show size <> " built " <> show built) (built >= 1 && built <= fromIntegral size)+        case shape of+            Chain -> assertEqual "chain" (fromIntegral size) built+            Fan -> assertEqual "fan" (fromIntegral size) built+            Tree _ -> assertEqual "tree" (fromIntegral size) built+            _ -> pure ()++deterministic :: IO ()+deterministic = do+    let edgesOf shape size seed = Dag.dagEdges (foldOf (generate shape size seed))+    forM_ shapes $ \shape ->+        assertEqual (show shape <> " twice") (edgesOf shape 20 7) (edgesOf shape 20 7)+    forM_ [Chain, Fan, Tree 2, Diamonds 2, Layered 4 2] $ \shape ->+        assertEqual (show shape <> " ignores the seed") (edgesOf shape 20 1) (edgesOf shape 20 2)+    assertBool+        "the random shape differs between seeds"+        (edgesOf (RandomLayered 8 3) 41 1 /= edgesOf (RandomLayered 8 3) 41 2)+    assertBool+        "and differs from the regular one"+        (edgesOf (RandomLayered 8 3) 41 1 /= edgesOf (Layered 8 3) 41 1)++randomLayerDraws :: IO ()+randomLayerDraws = do+    let width = 8+        sharing = 3+        size = 1 + 6 * width+        next = successors (RandomLayered width sharing) size 99+    forM_ [1 .. size - 1 - width] $ \i -> do+        let picked = next i+        assertEqual ("node " <> show i <> " has its dependencies") sharing (length picked)+        assertEqual ("node " <> show i <> " draws distinct ones") sharing (Set.size (Set.fromList picked))+        assertEqual ("node " <> show i <> " keeps its own position") [i + width] (take 1 picked)+    forM_ [size - width .. size - 1] $ \i ->+        assertEqual ("node " <> show i <> " is in the last layer") [] (next i)++nodeOptions :: IO ()+nodeOptions = do+    let exts root = [act.extension | o <- toList (evalDeps root), Just act <- [opAct o]]+        plain = exts (generate (Tree 2) 7 0)+    assertEqual "seven nodes" 7 (length plain)+    verdicts <- mapM check plain+    assertEqual "no check by default" (replicate 7 Immaterial) verdicts+    assertBool "nothing managed by default" (all (isNothing . managed) plain)+    assertBool "no dynamics by default" (all (null . dynamics) plain)+    assertBool "no notes" (all (null . notes) plain)++    forM_ [Success, Completed, Failure "absent", Unknown] $ \verdict -> do+        answered <- mapM check (exts (generateWith defaultOptions{optCheck = verdict} (Tree 2) 7 0))+        assertEqual ("check answers " <> show verdict) (replicate 7 verdict) answered++    assertBool+        "every node managed"+        (all (isJust . managed) (exts (generateWith defaultOptions{optManaged = True} (Tree 2) 7 0)))++    let tagged = generateWith defaultOptions{optDynamics = \i -> [toDyn i]} (Tree 2) 7 0+    assertEqual+        "each node carries its own number"+        [0 .. 6 :: Int]+        (Set.toAscList (Set.fromList (concatMap getDynamics (toList (evalDeps tagged)))))++edgeForms :: IO ()+edgeForms = do+    let build form = generateWith defaultOptions{optEdges = form} (Layered 3 2) 10 0+        written form = runIdentity (build form).predecessors+    case written AsVertices of+        Vertices [_, _, _] -> pure ()+        other -> assertFailure ("AsVertices wrote " <> show (fmap (const ()) other))+    case written AsConnect of+        Connect (Vertices [_]) (Connect (Vertices [_]) (Connect (Vertices [_]) (Vertices []))) -> pure ()+        other -> assertFailure ("AsConnect wrote " <> show (fmap (const ()) other))+    case written AsOverlay of+        Overlay (Vertices [_]) (Overlay (Vertices [_]) (Overlay (Vertices [_]) (Vertices []))) -> pure ()+        other -> assertFailure ("AsOverlay wrote " <> show (fmap (const ()) other))+    forM_ [AsConnect, AsOverlay] $ \form -> do+        assertEqual (show form <> ": same edges") (Dag.dagEdges (foldOf (build AsVertices))) (Dag.dagEdges (foldOf (build form)))+        assertEqual (show form <> ": same order") (Dag.dagOrder (foldOf (build AsVertices))) (Dag.dagOrder (foldOf (build form)))++collisions :: IO ()+collisions =+    forM_ [(shape, fraction, seed) | shape <- shapes, fraction <- [0, 1 / 10, 1 / 3, 1], seed <- seeds] $ \(shape, fraction, seed) -> do+        let size = 20+            opts = defaultOptions{optCollisions = fraction}+            at what = label shape size seed <> ", colliding " <> show fraction <> ": " <> what+            expected = counts shape size+            wanted = colliderCount opts shape size+            (_, paths, _) = walkUnfold (successors shape size seed)+            chosen = filter (isCollider opts shape size seed) [-1 .. size + 1]+            root = generateWith opts shape size seed+            dag = foldOf root+            conflicting = Set.fromList (fmap (\c -> c.conflictRef) (Dag.dagConflicts dag))+        assertEqual (at "collider count") (floor (fraction * fromIntegral (expected.countNodes - 1))) wanted+        assertEqual (at "chosen nodes") wanted (fromIntegral (length chosen))+        assertEqual (at "conflicting refs") (Set.fromList (fmap nodeRef chosen)) conflicting+        assertEqual (at "nodes are unchanged") expected.countNodes (fromIntegral (Map.size (Dag.dagNodes dag)))+        assertEqual (at "edges are unchanged") expected.countEdges (fromIntegral (Set.size (Dag.dagEdges dag)))+        assertEqual+            (at "one more leaf per occurrence of a collider")+            (expected.countOccurrences + sum [paths Map.! c | c <- chosen])+            (fromIntegral (length (evalDeps root)))+        forM_ chosen $ \c ->+            assertEqual+                (at "the later declaration survives")+                (Just "graph fixture node (colliding declaration)")+                (fmap (\act -> act.extension.help) (Dag.representativeOf dag (nodeRef c)))++{- | The description is one closure: taking the root of a million-node graph,+and a few of its dependencies, must not build the rest. This is not a timing+assertion; a strict generator would show here as the suite stalling.+-}+lazyDescription :: IO ()+lazyDescription = do+    let million = 1000000+        refOf o = fmap (\act -> act.extension.ref) (opAct o)+    forM_ shapes $ \shape -> do+        let root = generate shape million 0+        assertEqual (show shape <> ": root") (Just (nodeRef 0)) (refOf root)+        case below root of+            first : _ -> assertEqual (show shape <> ": first dependency") (Just (nodeRef 1)) (refOf first)+            [] -> assertFailure (show shape <> ": a million-node graph whose root depends on nothing")+    assertEqual+        "a fan's root is over everything else"+        (fmap (Just . nodeRef) [1, 2, 3])+        (fmap refOf (take 3 (below (generate Fan million 0))))+    -- the closed forms are arithmetic, however large the expansion is.+    assertEqual "nodes of a million-node chain of diamonds" 1000000 (counts (Diamonds 2) million).countNodes+    assertBool+        "whose expansion could never be walked"+        ((counts (Diamonds 2) million).countOccurrences > 10 ^ (1000 :: Int))++perNodeWalk :: IO ()+perNodeWalk =+    forM_ shapes $ \shape -> do+        let size = 20000+        assertEqual (show shape) (counts shape size).countNodes (fromIntegral (distinctRefs (generate shape size 3)))++-- | Set to a node count (e.g. @1000000@) to run 'largeWalk'.+largeEnv :: String+largeEnv = "SALMON_TEST_GRAPH_FIXTURE_SIZE"++{- | Every shape at an operator-chosen size, visited once per node, and the+shapes without sharing expanded and folded as well. Opt-in: it is the one+case here whose cost is not negligible, and the host decides how much memory+it may take.+-}+largeWalk :: IO ()+largeWalk = do+    requested <- lookupEnv largeEnv+    case requested >>= readMaybe of+        Nothing ->+            hPutStrLn stderr ("SKIPPED: set " <> largeEnv <> " to a node count to walk every fixture shape at that size")+        Just size -> do+            forM_ shapes $ \shape ->+                assertEqual+                    (show shape <> ": per-node walk")+                    (counts shape size).countNodes+                    (fromIntegral (distinctRefs (generate shape size 3)))+            -- not the fan: the fold keeps each node's edges as a list it+            -- searches on every insert, so one node over everything else is+            -- quadratic there. That is a finding for whoever profiles, not+            -- something to wait on here.+            forM_ [Chain, Tree 2] $ \shape -> do+                let dag = foldOf (generate shape size 3)+                    expected = counts shape size+                unless (expected.countOccurrences == expected.countNodes) $+                    assertFailure (show shape <> " shares nodes")+                assertEqual (show shape <> ": folded nodes") expected.countNodes (fromIntegral (Map.size (Dag.dagNodes dag)))+                assertEqual (show shape <> ": folded edges") expected.countEdges (fromIntegral (Set.size (Dag.dagEdges dag)))
+ test/Test/GraphScaleSpec.hs view
@@ -0,0 +1,132 @@+{-# LANGUAGE OverloadedRecordDot #-}+{-# LANGUAGE OverloadedStrings #-}++{- | A cheap guard on how the graph operations scale: a few of them over the+shapes that share nodes ("Test.GraphFixture"), at a size where an operation+that pays per node answers in well under a second and one that pays per+path never answers at all.++The bounds are deliberately loose (a minute where a few milliseconds to a+second are expected): this suite shares its process and its machine, and+the point is to tell "per node" from "per path", not to time anything. The+measurements themselves are the @graph-profiles@ benchmark and+@resources\/graph-profiles.md@.++'Salmon.Op.Dag.foldDag' (and so declaring a graph in @serve@ and every+command-line entry that folds) paid per path when this was written, which is+why the walks here are given a 'Dag' built by 'dagOf', and why 'dagOf' is+checked against the fold first. The fold is per node now, and guarded by+"Test.DagPerNodeSpec" (a thousand diamonds), not here.+-}+module Test.GraphScaleSpec (tests) where++import Control.Exception (evaluate)+import Control.Monad (forM_)+import qualified Data.Map.Strict as Map+import qualified Data.Set as Set+import System.Timeout (timeout)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, assertEqual, assertFailure, testCase)++import qualified Salmon.Actions.Concurrent as Concurrent+import qualified Salmon.Actions.Query as Query+import qualified Salmon.Actions.UpDown as UpDown+import Salmon.Builtin.Extension (Extension (..), evalDeps)+import qualified Salmon.Op.Dag as Dag+import Salmon.Reporter (silent)++import Test.GraphFixture++tests :: TestTree+tests =+    testGroup+        "graph operations at scale"+        [ testCase "dagOf builds what foldDag builds" dagOfMatchesTheFold+        , testCase "the outline, a selector and the listed paths of a shared graph are per node" outlineIsPerNode+        , testCase "the listed paths of a deep chain do not cost its depth per node" listedPathsOfADeepChain+        , testCase "rebuilding and walking a shared graph is per node" walksArePerNode+        ]++-------------------------------------------------------------------------------++-- | The shapes whose expansion is exponentially larger than the graph.+shared :: [Shape]+shared = [Diamonds 2, Layered 8 2, RandomLayered 8 3]++{- | Two hundred diamonds: six hundred nodes and @2^200@ paths. Enough+that anything following paths cannot finish. (The size was also kept small+for 'Dag.stuck', which was quadratic in depth until it went one pass; see+"Test.DagStuckSpec".)+-}+size :: Int+size = 601++-- | Generous: see the module header.+bound :: Int+bound = 60++-- | Fail, rather than hang the suite, if the action is still running at 'bound'.+within :: String -> IO a -> IO a+within what act = do+    outcome <- timeout (bound * 1000000) act+    case outcome of+        Just x -> pure x+        Nothing -> assertFailure (what <> ": still running after " <> show bound <> "s, which is what paying per path looks like")++-------------------------------------------------------------------------------++dagOfMatchesTheFold :: IO ()+dagOfMatchesTheFold =+    forM_ ([Chain, Fan, Tree 2, Tree 3, Layered 3 3] <> shared) $ \shape ->+        forM_ [1, 2, 7, 13, 25] $ \n -> do+            let label = show shape <> " at " <> show n+                folded = Dag.foldDag Dag.sameRepresentative (evalDeps (generate shape n 3)) :: Dag.Dag Extension+                built = dagOf (generate shape n 3)+            assertEqual (label <> ": order") (Dag.dagOrder folded) (Dag.dagOrder built)+            assertEqual (label <> ": dependencies") (Dag.dagDependencies folded) (Dag.dagDependencies built)+            assertEqual (label <> ": dependants") (fmap Set.fromList (Dag.dagDependants folded)) (fmap Set.fromList (Dag.dagDependants built))+            assertEqual (label <> ": nodes") (Map.keysSet (Dag.dagNodes folded)) (Map.keysSet (Dag.dagNodes built))++outlineIsPerNode :: IO ()+outlineIsPerNode =+    forM_ shared $ \shape -> do+        let label = show shape <> " at " <> show size+            expected = fromIntegral (counts shape size).countNodes+            cograph = evalDeps (generate shape size 3)+        nodes <- within (label <> ": outline") (evaluate (Set.size (Query.outlineRefs (Query.outline cograph))))+        assertEqual (label <> ": every node outlined") expected nodes+        selected <- within (label <> ": resolveSelectors") (evaluate (Set.size (fst (Query.resolveSelectors cograph ["**"] ["n0/n1"]))))+        assertEqual (label <> ": every node but one selected") (expected - 1) selected+        listed <- within (label <> ": outlinePaths") (evaluate (Map.foldl' (\k ps -> k + length ps) 0 (Query.outlinePaths Query.pathLimit Query.pathCap (Query.outline cograph))))+        assertBool (label <> ": at least one path per node, at most pathLimit") (listed >= expected && listed <= expected * Query.pathLimit)++{- | A chain of twenty thousand nodes: the uncapped listing held one path of+up to twenty thousand segments per node (about 1.2 GB of text, and minutes);+capped at 'Query.pathCap' it is a few hundred characters a node.+-}+listedPathsOfADeepChain :: IO ()+listedPathsOfADeepChain = do+    let n = 20000+        cograph = evalDeps (generate Chain n 3)+    held <- within "chain: outlinePaths" (evaluate (Map.foldl' (\k ps -> k + sum [length p.listedSegments | p <- ps]) 0 (Query.outlinePaths Query.pathLimit Query.pathCap (Query.outline cograph))))+    assertBool ("chain: segments held " <> show held <> ", at most a cap's worth per node") (held <= n * Query.pathCap `div` 2)++walksArePerNode :: IO ()+walksArePerNode =+    forM_ shared $ \shape -> do+        let label = show shape <> " at " <> show size+            expected = fromIntegral (counts shape size).countNodes+            dag = dagOf (generate shape size 3)+        rebuilt <- within (label <> ": fromMagma") (evaluate (Dag.fromMagma (Dag.dagNodes dag) (Dag.dagEdges dag)))+        assertEqual (label <> ": every node rebuilt") expected (Map.size (Dag.dagNodes rebuilt))+        assertEqual (label <> ": every edge rebuilt") (Dag.dagEdges dag) (Dag.dagEdges rebuilt)+        merged <- within (label <> ": mergeDag") (evaluate (Dag.mergeDag Dag.sameRepresentative rebuilt dag))+        assertEqual (label <> ": merging a graph onto itself adds nothing") (Dag.dagEdges dag) (Dag.dagEdges merged)+        cyclic <- within (label <> ": stuck") (evaluate (Set.size (Dag.stuck Dag.dependenciesOf rebuilt)))+        assertEqual (label <> ": nothing on a cycle") 0 cyclic+        up <- within (label <> ": upDag") (UpDown.upDag UpDown.alwaysRequired silent rebuilt)+        assertBool (label <> ": the sequential bring-up succeeds") up+        down <- within (label <> ": downDag") (UpDown.downDag UpDown.alwaysRequired silent rebuilt)+        assertBool (label <> ": the sequential teardown succeeds") down+        upConcurrent <- within (label <> ": upDagConcurrent") (Concurrent.upDagConcurrent UpDown.alwaysRequired silent Concurrent.noMailboxes Nothing rebuilt)+        assertBool (label <> ": the concurrent bring-up succeeds") upConcurrent
+ test/Test/HaproxySpec.hs view
@@ -0,0 +1,147 @@+{-# LANGUAGE OverloadedStrings #-}++{- | Layer 0 coverage for "Salmon.Builtin.Nodes.Haproxy": what it renders and+what it refuses to render.++The property the module exists for is not a line of configuration but the+absence of one: nothing in @haproxy.cfg@ says which member leads, so a+failover is not a configuration change (@specs\/pg-patroni.md@, T3). The+golden text below is that claim spelled out -- every member is behind every+listener, and only the health-check path differs.+-}+module Test.HaproxySpec (tests) where++import qualified Data.Text as Text+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, assertEqual, testCase)++import qualified Salmon.Builtin.Nodes.Haproxy as Haproxy+import Test.Harness (runUp)++tests :: TestTree+tests =+    testGroup+        "Salmon.Builtin.Nodes.Haproxy"+        [ testCase "the leader port and the replica port, over the same members" $+            assertEqual "" golden (Haproxy.renderConfig cfg)+        , testCase "each role asks Patroni's own endpoint" $+            assertEqual+                ""+                ["/primary", "/replica", "/replica?lag=1048576", "/read-only"]+                (fmap Haproxy.checkPath [Haproxy.Primary, Haproxy.Replica, Haproxy.ReplicaWithin 1048576, Haproxy.ReadOnly])+        , testCase "members are listed in declared order under every listener, whoever leads" $ do+            let servers = filter ("    server " `Text.isPrefixOf`) (Text.lines (Haproxy.renderConfig cfg))+            assertEqual "" ["pg-a", "pg-b", "pg-c", "pg-a", "pg-b", "pg-c"] (fmap ((!! 1) . Text.words) servers)+        , testCase "a demoted leader's sessions are closed, not left writing into a replica" $+            assertBool "" ("on-marked-down shutdown-sessions" `Text.isInfixOf` Haproxy.renderConfig cfg)+        , testCase "the status page is there only when asked for" $ do+            assertBool "" (not ("listen stats" `Text.isInfixOf` Haproxy.renderConfig cfg))+            let withStats = Haproxy.renderConfig cfg{Haproxy.haproxy_stats = Just ("127.0.0.1", 7000)}+            assertBool "" ("listen stats\n    mode http\n    bind 127.0.0.1:7000\n" `Text.isInfixOf` withStats)+        , testCase "the watched file is the one rendered" $+            assertEqual "" "/etc/haproxy/haproxy.cfg" (Haproxy.configPath cfg)+        , testCase "a sound configuration has nothing said against it" $+            assertEqual "" [] (Haproxy.validate cfg)+        , testCase "objections are collected, not first-found" $+            assertEqual+                ""+                [ "listener name \"primary\" is declared more than once"+                , "member name \"pg-a\" is declared more than once"+                , "port 5000 is bound more than once"+                ]+                ( Haproxy.validate+                    cfg+                        { Haproxy.haproxy_listeners =+                            [ Haproxy.Listener "primary" "*" 5000 Haproxy.Primary+                            , Haproxy.Listener "primary" "*" 5000 Haproxy.Replica+                            ]+                        , Haproxy.haproxy_members = [member "pg-a" "10.0.0.1", member "pg-a" "10.0.0.2"]+                        }+                )+        , testCase "an empty configuration is refused" $+            assertEqual+                ""+                ["no listeners", "no members"]+                (Haproxy.validate cfg{Haproxy.haproxy_listeners = [], Haproxy.haproxy_members = []})+        , testCase "a name or host that would split into two words is refused" $ do+            let bad = Haproxy.validate cfg{Haproxy.haproxy_members = [(member "pg a" "10.0.0.1 backup")]}+            assertEqual+                ""+                [ "member name \"pg a\" is not a valid server name"+                , "member \"pg a\" has host \"10.0.0.1 backup\", which is not one word"+                ]+                bad+        , testCase "the status page's port and name are not a listener's to take" $+            assertEqual+                ""+                [ "listener name \"stats\" is taken by the status page"+                , "port 5000 is bound more than once"+                ]+                ( Haproxy.validate+                    cfg+                        { Haproxy.haproxy_stats = Just ("*", 5000)+                        , Haproxy.haproxy_listeners = [Haproxy.Listener "stats" "*" 5000 Haproxy.Primary]+                        }+                )+        , testCase "out-of-range ports and non-positive check settings are refused" $+            assertEqual+                ""+                [ "port 0 is not a port"+                , "port 70000 is not a port"+                , "checks need a positive interval, fall, rise and timeout"+                ]+                ( Haproxy.validate+                    cfg+                        { Haproxy.haproxy_members = [(member "pg-a" "10.0.0.1"){Haproxy.member_pg_port = 0, Haproxy.member_rest_port = 70000}]+                        , Haproxy.haproxy_checks = Haproxy.defaultChecks{Haproxy.checks_fall = 0}+                        }+                )+        , -- a file haproxy refuses is a router that stays down at its next+          -- restart, so the node fails instead of writing it.+          testCase "an invalid configuration fails the node rather than being written" $ do+            ok <- runUp (Haproxy.configFiles cfg{Haproxy.haproxy_config_dir = "/nonexistent-salmon-test", Haproxy.haproxy_members = []})+            assertBool "the pass reported a failure" (not ok)+        ]++member :: Text.Text -> Text.Text -> Haproxy.Member+member name host = Haproxy.Member name host 5432 8008++cfg :: Haproxy.HaproxyConfig+cfg = Haproxy.patroniRouter [member "pg-a" "10.0.0.1", member "pg-b" "10.0.0.2", member "pg-c" "10.0.0.3"]++golden :: Text.Text+golden =+    Text.unlines+        [ "# written by salmon (Salmon.Builtin.Nodes.Haproxy); edits are overwritten"+        , "global"+        , "    maxconn 100"+        , "    log stdout format short daemon"+        , ""+        , "defaults"+        , "    log global"+        , "    mode tcp"+        , "    retries 2"+        , "    timeout connect 4s"+        , "    timeout client 1800s"+        , "    timeout server 1800s"+        , "    timeout check 5s"+        , ""+        , "listen primary"+        , "    bind *:5000"+        , "    option httpchk GET /primary"+        , "    http-check expect status 200"+        , "    default-server inter 3s fall 3 rise 2 on-marked-down shutdown-sessions"+        , "    server pg-a 10.0.0.1:5432 maxconn 100 check port 8008"+        , "    server pg-b 10.0.0.2:5432 maxconn 100 check port 8008"+        , "    server pg-c 10.0.0.3:5432 maxconn 100 check port 8008"+        , ""+        , "listen replicas"+        , "    bind *:5001"+        , "    option httpchk GET /replica"+        , "    http-check expect status 200"+        , "    default-server inter 3s fall 3 rise 2 on-marked-down shutdown-sessions"+        , "    balance leastconn"+        , "    server pg-a 10.0.0.1:5432 maxconn 100 check port 8008"+        , "    server pg-b 10.0.0.2:5432 maxconn 100 check port 8008"+        , "    server pg-c 10.0.0.3:5432 maxconn 100 check port 8008"+        ]
test/Test/Harness.hs view
@@ -25,6 +25,8 @@      -- * scratch filesystem     withTempDir,+    replaceFile,+    writeExecutable,     privatePipe,      -- * skipping tests when a precondition isn't met@@ -33,6 +35,9 @@     -- * podman-backed sandboxes (Layer 2)     podmanTrack,     withContainer,+    freeTcpPort,+    freshContainerName,+    podmanFailures,     podmanExec_,     podmanExecCapture, @@ -51,6 +56,7 @@     withVmAt,     VmAccess (..),     sshToVm,+    sshArgv,     quoteForRemoteShell,     scpToVm,     testHarnessUser,@@ -58,13 +64,17 @@ ) where  import Control.Concurrent (threadDelay)-import Control.Exception (bracket, bracket_)+import Control.Exception (bracket, bracket_, onException) import Control.Monad (forM_, unless, void) import Control.Monad.Identity (Identity, runIdentity)+import qualified Data.ByteString.Char8 as ByteString import Data.IORef+import qualified Data.ByteString.Lazy as LByteString import Data.List (isInfixOf) import qualified Data.List as List+import Data.Maybe (mapMaybe) import qualified Data.Text as Text+import qualified Network.Socket as Socket import Numeric (showHex) import qualified Salmon.Actions.UpDown as UpDown import Salmon.Actions.UpDown (downTree, upTree)@@ -78,15 +88,15 @@ import qualified Salmon.Builtin.Nodes.Systemd as Systemd import Salmon.Reporter (Reporter, ReporterM (..)) import System.CPUTime (getCPUTime)-import System.Directory (XdgDirectory (XdgConfig), canonicalizePath, createDirectoryIfMissing, findExecutable, getPermissions, getXdgDirectory, setOwnerExecutable, setPermissions)+import System.Directory (XdgDirectory (XdgConfig), canonicalizePath, createDirectoryIfMissing, findExecutable, getXdgDirectory, renameFile) import System.Environment (lookupEnv, setEnv, unsetEnv) import System.Exit (ExitCode (..))-import System.FilePath ((</>))+import System.FilePath (takeDirectory, takeFileName, (</>)) import System.IO (Handle, hPutStrLn, stderr) import System.Posix.IO (FdOption (CloseOnExec), createPipe, fdToHandle, setFdOption) import System.IO.Temp (withSystemTempDirectory) import System.Posix.User (getEffectiveUserID, getLoginName)-import System.Process (readProcessWithExitCode)+import System.Process (CmdSpec (..), CreateProcess (cmdspec), readProcessWithExitCode)  -- | Build a reporter that accumulates every emitted value, in order, plus a -- way to read them back out. Good enough for single-threaded test runs.@@ -155,6 +165,42 @@ withTempDir :: (FilePath -> IO a) -> IO a withTempDir = withSystemTempDirectory "salmon-ops-recipes-test" +{- | Put these bytes at this path the way a publisher should: written to a+dot-file beside it and renamed over it. Use it for any file a reader in the+same process may have open at that moment (a @--follow@ registry document+under a running fetcher): GHC locks files per process, so writing in place+while another thread has the file open for reading throws "resource busy+(file is locked)". A rename opens nothing and cannot be refused, and the+reader sees the old bytes or the new ones, never half of each. The parent+directory is created if missing. -}+replaceFile :: FilePath -> LByteString.ByteString -> IO ()+replaceFile path bytes = do+    createDirectoryIfMissing True (takeDirectory path)+    let tmp = takeDirectory path </> ("." <> takeFileName path <> ".tmp")+    LByteString.writeFile tmp bytes+    renameFile tmp path++{- | Write an executable stand-in (a script some test will exec, or have the+code under test exec through @PATH@), mode 0755. Use it, never 'writeFile'+then a chmod, for any file that is executed afterwards.++The bytes are written by a child (@sh -c 'cat > ...'@), not by this process.+GHC opens files without @O_CLOEXEC@, the suite runs its groups in parallel in+one process, and nothing passes @close_fds@: a child another group spawns+while this process holds the script open for writing inherits that+descriptor and keeps it until it exits, and exec'ing a file anything holds+open for writing fails with ETXTBSY ("Text file busy"). Marking the handle+close-on-exec would only narrow that to the window between another child's+fork and its exec; writing in a child leaves no copy of the write end in+this process for anyone to inherit. A write-then-rename does not help: the+rename keeps the inode, and the inode is what is busy.+-}+writeExecutable :: FilePath -> String -> IO ()+writeExecutable path body = do+    (code, out, err) <- readProcessWithExitCode "sh" ["-c", "cat > \"$1\" && chmod 755 \"$1\"", "sh", path] body+    unless (code == ExitSuccess) $+        ioError (userError ("writing the executable " <> path <> " failed:\n" <> out <> err))+ -- | Layer-2 tests need a real binary on PATH (podman, postgres, ...). Rather -- than failing the suite on a machine that doesn't have it, skip loudly: -- print a note and report the test as passing-vacuously.@@ -200,17 +246,84 @@     bringUp :: IO (String, Op)     bringUp = do         cname <- freshContainerName-        (reporter, _) <- capture+        (reporter, readBack) <- capture         let reg = Podman.dockerRegistry             opts = Podman.noRunOptions{Podman.runPorts = [pm]}             pullOp = Podman.pullImage reporter podmanTrack reg img             runOp = Podman.runContainer reporter podmanTrack reg img cname opts+            failWith what = do+                failures <- podmanFailures <$> readBack+                fail $+                    "withContainer: "+                        <> what+                        <> " failed"+                        <> concatMap ("\n  " <>) failures+                        <> portHint failures         pullOk <- runUp pullOp-        unless pullOk (fail "withContainer: pulling the sandbox image failed")-        runOk <- runUp runOp-        unless runOk (fail "withContainer: starting the sandbox container failed")+        unless pullOk (failWith "pulling the sandbox image")+        -- A `podman run` that fails at start leaves the container behind,+        -- Created, under a name 'bracket' never hands to 'cleanup': remove it+        -- here, or every failed run leaks one.+        runOk <- runUp runOp `onException` runDown runOp+        unless runOk $ do+            _ <- runDown runOp+            failWith "starting the sandbox container"         pure (Text.unpack (Podman.getContainerName cname), runOp) +    portHint :: [String] -> String+    portHint failures+        | any ("address already in use" `isInfixOf`) failures =+            "\n  host port "+                <> Text.unpack (Podman.portOnHost pm)+                <> " is taken: another run of the suite on this host, or a container left over from a killed one"+                <> " (podman ps -a --filter name=salmon-ops-recipes-test-)"+        | otherwise = ""++{- | Every podman command in a captured trace that exited non-zero, as its+argv, exit code and stderr: what 'withContainer' says when it fails, instead+of a bare "failed".+-}+podmanFailures :: [Podman.Report] -> [String]+podmanFailures = mapMaybe (failed . binaryReport)+  where+    binaryReport :: Podman.Report -> Maybe Binary.Report+    binaryReport r = case r of+        Podman.PullImage _ _ b -> Just b+        Podman.BuildImage _ _ b -> Just b+        Podman.PushImage _ _ b -> Just b+        Podman.LoginRegistry{} -> Nothing+        Podman.LogoutRegistry _ _ b -> Just b+        Podman.RunContainer _ _ _ _ b -> Just b+        Podman.CreateNetwork _ b -> Just b+        Podman.RemoveImage _ _ b -> Just b+        Podman.RemoveBuiltImage _ b -> Just b+        Podman.RemoveContainer _ b -> Just b+        Podman.RemoveNetwork _ b -> Just b++    failed :: Maybe Binary.Report -> Maybe String+    failed (Just (Binary.Requested _ b)) = failed (Just b)+    failed (Just (Binary.CommandStopped p (ExitFailure n) _ err)) =+        Just (unwords (argv p) <> " exited " <> show n <> ": " <> ByteString.unpack (trimEnd err))+    failed _ = Nothing++    argv p = case cmdspec p of+        RawCommand exe args -> exe : args+        ShellCommand cmd -> [cmd]++    trimEnd = ByteString.dropWhileEnd (`elem` (" \n\r\t" :: String))++{- | A TCP port nothing listens on right now, from the kernel: bind port 0 on+every address (as podman's rootlessport binds a published port), read it+back, close. Still a race with whatever binds next, but no longer one fixed+number shared by every concurrent run of the suite on the host.+-}+freeTcpPort :: IO Text.Text+freeTcpPort =+    bracket (Socket.socket Socket.AF_INET Socket.Stream Socket.defaultProtocol) Socket.close $ \sock -> do+        Socket.bind sock (Socket.SockAddrInet 0 0)+        port <- Socket.socketPort sock+        pure (Text.pack (show port))+ -- | CPU time at picosecond resolution is more than enough entropy to keep -- concurrent\/successive test containers from colliding on a name. freshContainerName :: IO Podman.ContainerName@@ -259,8 +372,7 @@   where     writeShim :: FilePath -> String -> IO ()     writeShim dir cmd = do-        let path = dir </> cmd-        writeFile path $+        writeExecutable (dir </> cmd) $             unlines                 [ -- Absolute shebang on purpose: `#!/usr/bin/env bash` would                   -- have `env` resolve "bash" via the (now shim-prepended)@@ -270,8 +382,6 @@                   "#!/bin/bash"                 , "exec podman exec -i " <> containerId <> " " <> cmd <> " \"$@\""                 ]-        perms <- getPermissions path-        setPermissions path (setOwnerExecutable True perms)  withPrependedPath :: FilePath -> IO a -> IO a withPrependedPath dir act = do@@ -645,9 +755,13 @@ -- 'quoteForRemoteShell' before it goes in 'args' — see that function's -- haddock for why this isn't done unconditionally for every element here. sshToVm :: VmAccess -> [String] -> IO (ExitCode, String, String)-sshToVm access args =-    readProcessWithExitCode-        "ssh"+sshToVm access args = readProcessWithExitCode "ssh" (sshArgv access args) ""++{- | The arguments 'sshToVm' hands @ssh@, for a caller that has to start the+process itself (e.g. to connect two guests' stdin and stdout through the host).+-}+sshArgv :: VmAccess -> [String] -> [String]+sshArgv access args =         ( [ "-o"           , "BatchMode=yes"           , "-o"@@ -662,7 +776,6 @@           ]             <> args         )-        ""  {- | Single-quotes a string so it survives 'sshToVm''s ssh-level space-join as one remote token, e.g. a whole SQL statement or @bash -c@ script that
+ test/Test/HttpServerFlagSpec.hs view
@@ -0,0 +1,119 @@+{- | @salmon-ops@'s @http-server@ cabal flag: on by default, and off it must+still build, without warp, warp-tls or wai anywhere in the library's plan.++The default suite can only see the default build (this suite imports+"Salmon.Actions.Serve.Http" itself), so the flag-off side is opt-in: set+@SALMON_TEST_NO_HTTP_SERVER_BUILD=1@ and it builds the library with+@-http-server@ into a temporary @--builddir@, from the project root, and+reads the plan that build wrote. It costs a full compile of @salmon-ops@+(about two minutes when the store already has the dependencies), which is+why it is not in the default run. Without the variable it skips loudly.+-}+module Test.HttpServerFlagSpec (tests) where++import Control.Monad (unless)+import Data.Aeson (Value (..))+import qualified Data.Aeson as Aeson+import qualified Data.Aeson.Key as Key+import qualified Data.Aeson.KeyMap as KeyMap+import Data.Foldable (toList)+import qualified Data.Map.Strict as Map+import qualified Data.Set as Set+import Data.Text (Text)+import qualified Data.Text as Text+import System.Directory (doesFileExist, getCurrentDirectory)+import System.Environment (lookupEnv)+import System.Exit (ExitCode (..))+import System.FilePath (takeDirectory, (</>))+import System.IO (hPutStrLn, stderr)+import System.Process (CreateProcess (..), proc, readCreateProcessWithExitCode)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, assertEqual, assertFailure, testCase)++import qualified Salmon.Actions.Serve.Http as Http+import qualified Salmon.Actions.Serve.HttpBind as HttpBind+import qualified Salmon.Builtin.CommandLine as CLI++import Test.Harness (withTempDir)++tests :: TestTree+tests =+    testGroup+        "salmon-ops http-server flag"+        [ testCase "the default build has the HTTP server" $+            assertBool "httpServerAvailable" CLI.httpServerAvailable+        , testCase "Serve.Http re-exports the listener configuration it moved out" $+            assertEqual "defaultSessionPolicy" HttpBind.defaultSessionPolicy Http.defaultSessionPolicy+        , testCase "the refusal names the flag and both options" $ do+            let msg = Text.pack CLI.httpServerUnavailable+            mapM_ (\w -> assertBool (Text.unpack w) (w `Text.isInfixOf` msg)) ["-http-server", "--http ", "--http-tcp"]+        , testCase "built with -http-server: no warp, warp-tls or wai in the library's plan (opt-in)" withoutHttpServer+        ]++optInEnv :: String+optInEnv = "SALMON_TEST_NO_HTTP_SERVER_BUILD"++withoutHttpServer :: IO ()+withoutHttpServer = do+    requested <- lookupEnv optInEnv+    case requested of+        Just v | not (null v) -> run+        _ -> hPutStrLn stderr ("SKIPPED: set " <> optInEnv <> "=1 to build salmon-ops with -http-server and check its plan")+  where+    run = do+        root <- projectRoot =<< getCurrentDirectory+        withTempDir $ \builddir -> do+            let args = ["build", "salmon-ops:lib:salmon-ops", "--constraint=salmon-ops -http-server", "--builddir=" <> builddir]+            (code, out, err) <- readCreateProcessWithExitCode (proc "cabal" args){cwd = Just root, close_fds = True} ""+            unless (code == ExitSuccess) $+                assertFailure ("cabal " <> unwords args <> " failed:\n" <> lastLines out <> lastLines err)+            plan <- Aeson.eitherDecodeFileStrict (builddir </> "cache" </> "plan.json")+            case plan of+                Left e -> assertFailure ("plan.json: " <> e)+                Right v -> do+                    let names = libraryClosure v+                    assertBool "the closure was found at all" (Set.member "salmon-core" names)+                    mapM_+                        (\p -> assertBool (Text.unpack p <> " is in salmon-ops -http-server's plan") (not (Set.member p names)))+                        ["warp", "warp-tls", "wai"]+    lastLines = unlines . reverse . take 40 . reverse . lines++-- | The directory holding @cabal.project@, from here upwards.+projectRoot :: FilePath -> IO FilePath+projectRoot dir = do+    found <- doesFileExist (dir </> "cabal.project")+    if found+        then pure dir+        else+            let up = takeDirectory dir+             in if up == dir then fail "no cabal.project above the test's directory" else projectRoot up++{- | Package names reachable from @salmon-ops@'s library in a cabal+@plan.json@: the whole project's plan lists every local package, some of+which (this suite) depend on warp themselves, so only the library's own+closure says anything.+-}+libraryClosure :: Value -> Set.Set Text+libraryClosure plan = go Set.empty roots+  where+    units = [u | Object o <- [plan], Just (Array us) <- [KeyMap.lookup "install-plan" o], Object u <- toList us]+    byId = Map.fromList [(i, u) | u <- units, Just (String i) <- [KeyMap.lookup "id" u]]+    str k u = case KeyMap.lookup (Key.fromText k) u of+        Just (String s) -> Just s+        _ -> Nothing+    depsOf u = case KeyMap.lookup "depends" u of+        Just (Array ds) -> [d | String d <- toList ds]+        _ -> case KeyMap.lookup "components" u of+            Just (Object cs) -> concat [[d | String d <- toList ds] | Object c <- KeyMap.elems cs, Just (Array ds) <- [KeyMap.lookup "depends" c]]+            _ -> []+    roots =+        concat+            [ depsOf u+            | u <- units+            , str "pkg-name" u == Just "salmon-ops"+            , str "component-name" u == Just "lib"+            ]+    go seen [] = Set.fromList [n | i <- Set.toList seen, Just u <- [Map.lookup i byId], Just n <- [str "pkg-name" u]]+    go seen (i : rest)+        | Set.member i seen = go seen rest+        | otherwise = go (Set.insert i seen) (maybe [] depsOf (Map.lookup i byId) <> rest)
+ test/Test/ListedPathsSpec.hs view
@@ -0,0 +1,284 @@+{-# LANGUAGE OverloadedRecordDot #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE TupleSections #-}++{- | The listed paths with a length cap ('Query.outlinePaths', 'Query.pathCap'),+against the uncapped listing they replaced ("Test.PathListReference") and+against every root-to-node path of the graph ('Query.pathedRefs'), on random+small graphs: the shapes of "Test.GraphFixture" with a random size and+seed, and random small graphs whose shorthands differ in length and repeat.++Every property lists every path ('pathedRefs') to have something to compare+with, so the graphs stay small (at most 'maxNodes' nodes); the caps drawn+are small too, so that the cap is crossed on most of them.+-}+module Test.ListedPathsSpec (tests) where++import Control.Monad.Identity (runIdentity)+import Data.List (isSuffixOf, nub)+import Data.Map.Strict (Map)+import qualified Data.Map.Strict as Map+import qualified Data.Set as Set+import Data.Set (Set)+import Data.Text (Text)+import qualified Data.Text as Text+import Test.QuickCheck (Gen, Property, choose, classify, conjoin, counterexample, elements, forAll, frequency, sublistOf, vectorOf, (===))+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, assertEqual, testCase)+import Test.Tasty.QuickCheck (testProperty)++import Salmon.Actions.Help (pathText)+import qualified Salmon.Actions.Query as Query+import Salmon.Actions.Query (ListedPath (..))+import Salmon.Builtin.Extension (Op, deps, evalDeps, op, ref)+import Salmon.Op.Eval (expand)+import Salmon.Op.Ref (Ref, mkRef)++import Test.GraphFixture (Shape (..), generate, nodeRef)+import Test.PathListReference (outlinePathsUncapped, renderedWidth)++tests :: TestTree+tests =+    testGroup+        "listed paths with a length cap"+        [ testProperty "at or under the cap, the listing is the uncapped one, in the same order and positions" prop_underTheCapUnchanged+        , testProperty "with no cap reached, the listing is the uncapped one" prop_noCapReachedIsIdentical+        , testProperty "every listed path is a real path, or the real tail of one, and selects its node" prop_listedPathsAreReal+        , testProperty "the shortest path's length is always the first listed, and the rest are in length order" prop_shortestFirst+        , testProperty "at most the limit, no text twice, a path for every node the uncapped listing had" prop_bounded+        , testCase "a chain at the cap: spelled out up to it, a tail after it" chainAtTheCap+        , testCase "the rendering of a capped path is a pattern that selects the node" cappedRenderingSelects+        , testCase "query show is not capped: chain, diamond and tree print every path in full" queryShowIsNotCapped+        ]++-------------------------------------------------------------------------------+-- Graphs++maxNodes :: Int+maxNodes = 30++-- | A graph to test on, with what to call it in a counterexample.+data Case = Case+    { caseName :: String+    , caseGraph :: Op+    }++instance Show Case where+    show = caseName++genCase :: Gen Case+genCase = frequency [(3, genFixture), (2, genRandomNames)]++genFixture :: Gen Case+genFixture = do+    shape <- elements [Chain, Fan, Tree 2, Tree 3, Diamonds 2, Diamonds 3, Layered 3 2, Layered 2 2, RandomLayered 4 2]+    n <- choose (1, maxNodes)+    seed <- choose (0, 1000 :: Int)+    pure (Case (show shape <> " " <> show n <> " seed " <> show seed) (generate shape n (fromIntegral seed)))++{- | Nodes @0..n-1@, each depending on up to three nodes numbered above it,+with shorthands of differing lengths that repeat (distinct 'Ref's under+one name, as recipes reusing a builder have).+-}+genRandomNames :: Gen Case+genRandomNames = do+    n <- choose (1, 14)+    names <- vectorOf n (elements ["a", "bb", "ccc", "dddddd", "x", "yy"])+    edges <- traverse (\i -> take 3 <$> sublistOf [i + 1 .. n - 1]) [0 .. n - 1]+    let node :: Int -> Op+        node i = op (names !! i) (deps (map node (edges !! i))) (\x -> x{ref = mkRef "listed-paths" i})+        description = "random names " <> show (zip3 [0 :: Int ..] names edges)+    pure (Case description (node 0))++genCap :: Gen Int+genCap = frequency [(6, choose (1, 40)), (1, choose (40, 200)), (1, pure maxBound)]++genLimit :: Gen Int+genLimit = choose (1, 8)++-- | Everything a property needs about one graph.+data Listed = Listed+    { lOutline :: Query.Outline+    , lReal :: Map Ref (Set [Text])+    , lOld :: Map Ref [[Text]]+    , lNew :: Map Ref [ListedPath]+    }++listed :: Int -> Int -> Op -> Listed+listed limit cap graph =+    Listed+        { lOutline = o+        , lReal = Map.fromListWith Set.union [(r, Set.singleton p) | (p, r) <- Query.pathedRefs cograph]+        , lOld = outlinePathsUncapped limit o+        , lNew = Query.outlinePaths limit cap o+        }+  where+    cograph = runIdentity (expand graph)+    o = Query.outline cograph++withListed :: (Int -> Int -> Listed -> Property) -> Property+withListed k =+    forAll genCase $ \c ->+        forAll genLimit $ \limit ->+            forAll genCap $ \cap ->+                counterexample ("limit " <> show limit <> ", cap " <> show cap) (k limit cap (listed limit cap c.caseGraph))++perNode :: Listed -> (Ref -> [[Text]] -> [ListedPath] -> Property) -> Property+perNode l k =+    conjoin+        ( counterexample "the same nodes" (Map.keysSet l.lOld === Map.keysSet l.lNew)+            : [ counterexample (show r) (k r old (Map.findWithDefault [] r l.lNew))+              | (r, old) <- Map.toList l.lOld+              ]+        )++-------------------------------------------------------------------------------+-- Properties++prop_underTheCapUnchanged :: Property+prop_underTheCapUnchanged = withListed $ \_ cap l ->+    classify (any (any (.listedCapped)) (Map.elems l.lNew)) "some path capped" $+    conjoin+        [ counterexample "the same nodes" (Map.keysSet l.lOld === Map.keysSet l.lNew)+        , conjoin+            [ counterexample (show r) $+                let new = Map.findWithDefault [] r l.lNew+                    spelled = takeWhile (not . (.listedCapped)) new+                 in conjoin+                        [ map (.listedSegments) spelled === takeWhile ((<= cap) . renderedWidth) old+                        , counterexample "nothing spelled out after a capped path" (all (.listedCapped) (drop (length spelled) new))+                        , counterexample "spelled out iff at or under the cap" (all (\p -> p.listedCapped == (p.listedWidth > cap)) new)+                        ]+            | (r, old) <- Map.toList l.lOld+            ]+        ]++prop_noCapReachedIsIdentical :: Property+prop_noCapReachedIsIdentical =+    forAll genCase $ \c ->+        forAll genLimit $ \limit ->+            let o = Query.outline (runIdentity (expand c.caseGraph))+                old = outlinePathsUncapped limit o+                widest = maximum (0 : [renderedWidth p | ps <- Map.elems old, p <- ps])+                asOld = Map.map (map (\p -> (p.listedCapped, p.listedSegments)))+             in conjoin+                    [ counterexample "no cap at all" (asOld (Query.outlinePaths limit maxBound o) === Map.map (map (False,)) old)+                    , counterexample "the cap at the widest listed path" (asOld (Query.outlinePaths limit widest o) === Map.map (map (False,)) old)+                    , counterexample "the default cap on these small graphs" (asOld (Query.outlinePaths limit Query.pathCap o) === Map.map (map (False,)) old)+                    ]++prop_listedPathsAreReal :: Property+prop_listedPathsAreReal = withListed $ \_ cap l ->+    conjoin+        [ counterexample (show r <> " " <> show p) (realPath cap l r p)+        | (r, ps) <- Map.toList l.lNew+        , p <- ps+        ]++realPath :: Int -> Listed -> Ref -> ListedPath -> Property+realPath cap l r p+    | p.listedCapped =+        conjoin+            [ counterexample "over the cap" (p.listedWidth > cap)+            , counterexample "the longest tail within the cap of a real path of that length" (any (\q -> renderedWidth q == p.listedWidth && p.listedSegments `isSuffixOf` q && longest q) paths)+            , counterexample "a tail within the cap, or the node's own segment alone" (renderedWidth p.listedSegments <= cap || length p.listedSegments == 1)+            , counterexample "its rendering selects the node" selects+            ]+    | otherwise =+        conjoin+            [ counterexample "a real root-to-node path" (p.listedSegments `Set.member` reals)+            , counterexample "its length" (p.listedWidth === renderedWidth p.listedSegments)+            , counterexample "its rendering selects the node" selects+            ]+  where+    reals = Map.findWithDefault Set.empty r l.lReal+    paths = Set.toList reals+    selects = r `Set.member` Query.matchOutline (Query.parsePattern (Query.renderListedPath p)) l.lOutline+    -- one segment more of @q@ would not fit (or there is none).+    longest q =+        let k = length p.listedSegments+            more = drop (length q - k - 1) q+         in length q == k || renderedWidth more > cap++prop_shortestFirst :: Property+prop_shortestFirst = withListed $ \_ _ l ->+    perNode l $ \_ old new ->+        conjoin+            [ map (.listedWidth) (take 1 new) === map renderedWidth (take 1 old)+            , counterexample "in length order" (let ws = map (.listedWidth) new in and (zipWith (<=) ws (drop 1 ws)))+            ]++prop_bounded :: Property+prop_bounded = withListed $ \limit _ l ->+    perNode l $ \_ old new ->+        let texts = map Query.renderListedPath new+         in conjoin+                [ counterexample "at most the limit" (length new <= limit)+                , counterexample "no text twice" (texts === nub texts)+                , counterexample "a path where the uncapped listing had one" (null old === null new)+                ]++-------------------------------------------------------------------------------+-- Unit tests++-- | @n0@ to @n9@: the path to @nk@ renders in @3 (k + 1)@ characters.+chainAtTheCap :: IO ()+chainAtTheCap = do+    let o = Query.outline (evalDeps (generate Chain 10 0))+        paths = Query.outlinePaths Query.pathLimit 15 o+        at k = Map.findWithDefault [] (nodeRef k) paths+        names k = [Text.pack ('n' : show i) | i <- [0 .. k]]+    assertEqual "the root" [ListedPath 3 False ["n0"]] (at 0)+    assertEqual "n4, exactly at the cap: spelled out" [ListedPath 15 False (names 4)] (at 4)+    assertEqual "n5, one segment over: its tail within the cap" [ListedPath 18 True (drop 1 (names 5))] (at 5)+    assertEqual "rendered as a pattern" ["**/n1/n2/n3/n4/n5"] (map Query.renderListedPath (at 5))+    assertEqual "n9: its last five" [ListedPath 30 True (drop 5 (names 9))] (at 9)+    assertEqual "spelled out the way worldPaths always rendered" ["n0/n1/n2/n3/n4"] (map Query.renderListedPath (at 4))++-- | A capped path's rendering is a pattern, and it selects the node it was listed for.+cappedRenderingSelects :: IO ()+cappedRenderingSelects = do+    let cograph = evalDeps (generate (Diamonds 2) 31 0)+        o = Query.outline cograph+        paths = Query.outlinePaths Query.pathLimit 20 o+        capped = [(r, p) | (r, ps) <- Map.toList paths, p <- ps, p.listedCapped]+    assertBool "some paths are capped" (not (null capped))+    mapM_+        ( \(r, p) ->+            assertBool+                (show p <> " selects " <> show r)+                (r `Set.member` Query.matchOutline (Query.parsePattern (Query.renderListedPath p)) o)+        )+        capped++{- | @query show@ lists full paths by definition, so it is left uncapped:+on a chain whose deepest path is far over 'Query.pathCap', with and without+dedupe, and on a diamond chain and a tree (whose paths are short: the+boundary is not reachable on them at a size @--no-dedupe@ can list), every+line is a full path.+-}+queryShowIsNotCapped :: IO ()+queryShowIsNotCapped = do+    let chain = evalDeps (generate Chain 100 0)+        deepest = pathText [Text.pack ('n' : show i) | i <- [0 .. 99 :: Int]]+    assertBool "the chain's deepest path is over the cap" (Text.length deepest > Query.pathCap)+    mapM_+        ( \dedupe -> do+            let ls = Query.renderAnnotated chain Set.empty Set.empty dedupe False+            assertEqual ("one line per node, dedupe " <> show dedupe) 100 (length ls)+            assertEqual ("the last line is the deepest path in full, dedupe " <> show dedupe) (Just deepest) (lastMay ls)+            assertBool "nothing shown as a tail" (not (any ("**" `Text.isInfixOf`) ls))+        )+        [True, False]+    mapM_+        ( \(name, graph) -> do+            let cograph = evalDeps graph+                every = [pathText p | (p, _) <- Query.pathedRefs cograph]+                firsts = [pathText p | (p, _, _) <- Query.outlineFirstPaths (Query.outline cograph)]+            assertEqual (name <> ": --no-dedupe prints every path in full") every (Query.renderAnnotated cograph Set.empty Set.empty False False)+            assertEqual (name <> ": dedupe prints each node's first path in full") firsts (Query.renderAnnotated cograph Set.empty Set.empty True False)+        )+        [("diamonds", generate (Diamonds 2) 31 0), ("tree", generate (Tree 2) 63 0)]+  where+    lastMay [] = Nothing+    lastMay xs = Just (last xs)
test/Test/LlamaServerSpec.hs view
@@ -76,6 +76,11 @@                 assertBool "halfvec" (maybe False ("halfvec" `Text.isInfixOf`) (dimensionNote 3072))                 assertBool "truncate" (maybe False ("truncate" `Text.isInfixOf`) (dimensionNote 8192))             ]+        , testCase "the embedding request is JSON-encoded, and the answer is read with its width" $ do+            assertEqual "" "{\"input\":\"a \\\"b\\\"\\nc\"}" (embeddingRequest "a \"b\"\nc")+            assertEqual "" (Right [0.1, 0.2, 0.3]) (parseEmbedding 3 embedding3)+            assertEqual "" (Left "the model produces 3 dimensions, 4 declared") (parseEmbedding 4 embedding3)+            assertEqual "" (Left "not an embeddings answer") (parseEmbedding 3 "{}")         , testCase "the key is in curl's stdin configuration and never its argv" $ do             let cfg = curlConfig (Loopback 8080) "/v1/embeddings" (Just "{\"input\":\"x\"}") (Just "s3cret\"key")             assertBool "in the config, quoted" ("header = \"Authorization: Bearer s3cret\\\"key\"" `Text.isInfixOf` cfg)@@ -119,7 +124,10 @@             "a model of another width"             (Failure "the model produces 384 dimensions, 768 declared")             =<< llamaCheck s{lsDimension = 768}+        assertEqual "a loader's embedding, through the same curl" (Right (replicate 384 0.25)) =<< llamaEmbed s "a text with \"quotes\" and\na newline"+        assertEqual "of another width, refused" (Left "the model produces 384 dimensions, 768 declared") =<< llamaEmbed s{lsDimension = 768} "x"         writeFile keyFile "another-key\n"+        assertEqual "a refused key is no vector" (Left "llama-server answered 401") =<< llamaEmbed s "x"         assertEqual "the file's key is not the server's" (Failure "the api key was refused") =<< llamaCheck s         writeFile keyFile "top-secret\n"         writeIORef healthy False
+ test/Test/NetbirdSpec.hs view
@@ -0,0 +1,216 @@+{-# LANGUAGE OverloadedStrings #-}++{- | Layer 0 coverage for "Salmon.Builtin.Nodes.Netbird": the argv of the+client's commands (the setup key is a path, never a value), the verdict+drawn from @netbird status --json@, the refusal to move a connected peer,+the redaction of the key from a failed command's output, and the shape of+the graph.++__The status documents below are written by hand__ from the field names in+the upstream client's output structs; none was captured from a running+daemon, and no NetBird binary is run here. The one process the suite starts+is @sh@, standing in for a client that fails while quoting the key.+-}+module Test.NetbirdSpec (tests) where++import Control.Exception (try)+import qualified Data.ByteString as ByteString+import qualified Data.ByteString.Char8 as C8+import Data.Functor.Identity (runIdentity)+import Data.Maybe (isJust)+import qualified Data.Text as Text+import System.Process.ListLike (CmdSpec (..), CreateProcess (..), proc)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, assertEqual, assertFailure, testCase)++import Salmon.Actions.Query (pathedNodes)+import Salmon.Actions.UpDown (CheckResult (..))+import Salmon.Builtin.Extension+import Salmon.Builtin.Nodes.Binary (Command (..), CommandFailed (..))+import qualified Salmon.Builtin.Nodes.Binary as Binary+import Salmon.Builtin.Nodes.Debian.AptRepository (AptRepository (..), renderSources, resolveSuite, viaRepository)+import Salmon.Builtin.Nodes.Debian.Package (Package (..))+import Salmon.Builtin.Nodes.Netbird+import Salmon.Op.Actions (Act (..))+import Salmon.Op.Eval (expand)+import Salmon.Op.Ref (Ref)+import Salmon.Reporter (silent)++import Test.Harness (capture)++selfHosted :: Enrolment+selfHosted =+    Enrolment+        { enrolSetupKeyFile = "/etc/netbird-setup.key"+        , enrolManagementUrl = Just "https://netbird.example.org"+        , enrolHostname = Just "laptop-1"+        }++-- | A hand-written status document: the fields the check reads, among a few+-- of the others the client prints.+statusDoc :: Maybe C8.ByteString -> C8.ByteString -> Bool -> C8.ByteString+statusDoc daemon url connected =+    mconcat+        [ "{\"peers\":{\"total\":2,\"connected\":1,\"details\":[]},"+        , "\"cliVersion\":\"0.80.0\",\"daemonVersion\":\"0.80.0\","+        , maybe "" (\d -> "\"daemonStatus\":\"" <> d <> "\",") daemon+        , "\"management\":{\"url\":\"" <> url <> "\",\"connected\":" <> (if connected then "true" else "false") <> ",\"error\":\"\"},"+        , "\"signal\":{\"url\":\"https://netbird.example.org:443\",\"connected\":true,\"error\":\"\"},"+        , "\"netbirdIp\":\"100.64.0.7/16\",\"publicKey\":\"PUBKEY=\",\"fqdn\":\"laptop-1.netbird.example\"}"+        ]++verdict :: Enrolment -> C8.ByteString -> Maybe CheckResult+verdict enrol doc = interpretStatus enrol <$> parseStatus doc++tests :: TestTree+tests =+    testGroup+        "Salmon.Builtin.Nodes.Netbird"+        [ testGroup+            "commands"+            [ testCase "enrolment names the key by its file, with the declared server and name" $+                assertEqual+                    ""+                    (RawCommand "netbird" ["up", "--setup-key-file", "/etc/netbird-setup.key", "--management-url", "https://netbird.example.org", "--hostname", "laptop-1"])+                    (cmdspec (prepare netbirdcommand (Up selfHosted)))+            , testCase "nothing declared, nothing passed" $+                assertEqual+                    ""+                    (RawCommand "netbird" ["up", "--setup-key-file", "/k"])+                    (cmdspec (prepare netbirdcommand (Up (enrolment "/k"))))+            , testCase "no form of the enrolment takes the key as a value" $+                case cmdspec (prepare netbirdcommand (Up selfHosted)) of+                    RawCommand _ args -> assertBool "" (not (any (`elem` ["--setup-key", "-k"]) args))+                    other -> assertFailure ("unexpected " <> show other)+            , testCase "status is asked as json" $+                assertEqual "" (RawCommand "netbird" ["status", "--json"]) (cmdspec (prepare netbirdcommand StatusJson))+            , testCase "down disconnects" $+                assertEqual "" (RawCommand "netbird" ["down"]) (cmdspec (prepare netbirdcommand Down))+            ]+        , testGroup+            "interpretStatus"+            [ testCase "connected to the declared server is satisfied" $+                assertEqual "" (Just Success) (verdict selfHosted (statusDoc (Just "Connected") "https://netbird.example.org:443" True))+            , testCase "an undeclared server is not compared" $+                assertEqual "" (Just Success) (verdict (enrolment "/k") (statusDoc (Just "Connected") "https://api.other.example:443" True))+            , testCase "a peer that needs a login is not enrolled" $+                assertEqual "" (Just (Failure "netbird daemon is NeedsLogin")) (verdict selfHosted (statusDoc (Just "NeedsLogin") "" False))+            , testCase "an idle daemon (after a down) is not connected" $+                assertEqual "" (Just (Failure "netbird daemon is Idle")) (verdict selfHosted (statusDoc (Just "Idle") "https://netbird.example.org:443" False))+            , testCase "connecting is transitional" $+                assertEqual "" (Just Unknown) (verdict selfHosted (statusDoc (Just "Connecting") "https://netbird.example.org:443" False))+            , testCase "connected without the management connection is reported" $+                assertEqual+                    ""+                    (Just (Failure "not connected to the management server"))+                    (verdict selfHosted (statusDoc (Just "Connected") "https://netbird.example.org:443" False))+            , testCase "connected to another server is reported, naming it" $+                assertEqual+                    ""+                    (Just (Failure "connected to another management server: https://api.other.example:443"))+                    (verdict selfHosted (statusDoc (Just "Connected") "https://api.other.example:443" True))+            , testCase "a client without daemonStatus is judged on the management connection" $ do+                assertEqual "" (Just Success) (verdict selfHosted (statusDoc Nothing "https://netbird.example.org:443" True))+                assertEqual+                    ""+                    (Just (Failure "not connected to the management server"))+                    (verdict selfHosted (statusDoc Nothing "https://netbird.example.org:443" False))+            , testCase "output that is not a status document is not interpreted" $ do+                assertEqual "" Nothing (parseStatus "Daemon status: NeedsLogin\n")+                assertEqual "" Nothing (parseStatus "{\"peers\":{}}")+            , testCase "the reason never quotes the rest of the document" $+                case verdict selfHosted (statusDoc (Just "Connected") "https://api.other.example:443" True) of+                    Just (Failure msg) -> assertBool "" (not ("PUBKEY" `Text.isInfixOf` msg) && not ("100.64" `Text.isInfixOf` msg))+                    other -> assertFailure ("expected Failure, got " <> show other)+            ]+        , testGroup+            "management URL"+            [ testCase "a default port, a trailing path and case do not make another server" $ do+                assertBool "" (sameManagement "https://netbird.example.org" "https://NetBird.example.org:443/")+                assertBool "" (sameManagement "http://netbird.example.org" "http://netbird.example.org:80")+                assertBool "" (sameManagement "https://netbird.example.org" "netbird.example.org:443")+            , testCase "another host, port or scheme does" $ do+                assertBool "" (not (sameManagement "https://netbird.example.org" "https://api.other.example"))+                assertBool "" (not (sameManagement "https://netbird.example.org" "https://netbird.example.org:33073"))+                assertBool "" (not (sameManagement "https://netbird.example.org" "http://netbird.example.org"))+            , testCase "only a connected peer counts as enrolled elsewhere" $ do+                let st url connected = Status (Just "Connected") url connected+                assertEqual "" (Just "https://api.other.example:443") (connectedElsewhere selfHosted (st "https://api.other.example:443" True))+                assertEqual "" Nothing (connectedElsewhere selfHosted (st "https://api.other.example:443" False))+                assertEqual "" Nothing (connectedElsewhere selfHosted (st "https://netbird.example.org:443" True))+                assertEqual "" Nothing (connectedElsewhere selfHosted (st "" True))+                assertEqual "" Nothing (connectedElsewhere (enrolment "/k") (st "https://api.other.example:443" True))+            ]+        , testGroup+            "redaction"+            [ testCase "every occurrence of the key is replaced" $+                assertEqual "" "key [redacted] rejected ([redacted])" (redact "S3CR3T" "key S3CR3T rejected (S3CR3T)")+            , testCase "text without the key is untouched" $+                assertEqual "" "login failed" (redact "S3CR3T" "login failed")+            , testCase "an empty key redacts nothing" $+                assertEqual "" "login failed" (redact "" "login failed")+            , testCase "a failing command that quotes the key leaks it nowhere" $ do+                (r, reports) <- capture+                -- the script spells the key in two halves, so that the argv+                -- (which a failure quotes, and which is public) does not hold it+                let p = proc "sh" ["-c", "k=S3C; echo \"invalid setup key ${k}R3T\"; echo \"${k}R3T rejected\" >&2; exit 3"]+                res <- try (runRedacted "S3CR3T" p r)+                case res of+                    Right () -> assertFailure "a non-zero exit must throw"+                    Left e@(CommandFailed{}) -> do+                        assertEqual "" 3 e.commandFailed_exitCode+                        assertEqual "" "invalid setup key [redacted]\n" e.commandFailed_stdout+                        assertEqual "" "[redacted] rejected\n" e.commandFailed_stderr+                        assertBool "exception text" (not ("S3CR3T" `Text.isInfixOf` Text.pack (show e)))+                seen <- reports+                assertBool "reported something" (not (null seen))+                assertBool "reports" (not (any (mentions "S3CR3T") seen))+            , testCase "a command that succeeds does not throw" $+                runRedacted "S3CR3T" (proc "sh" ["-c", "exit 0"]) silent+            ]+        , testGroup+            "graph"+            [ testCase "the key file appears in the notes as a path, the server in the help" $+                case opAct (node ignoreTrack) of+                    Nothing -> assertFailure "no node"+                    Just act -> do+                        assertEqual "" "netbird peer enrolled against https://netbird.example.org" act.extension.help+                        assertEqual+                            ""+                            ["setup key read from: /etc/netbird-setup.key", "registers as: laptop-1"]+                            act.extension.notes+            , testCase "one daemon is one effect site, whatever is declared" $+                assertEqual "" (rootRef (node ignoreTrack)) (rootRef (peer silent ignoreTrack ignoreTrack (enrolment "/other")))+            , testCase "building the graph does not read the key file" $+                assertBool "" (isJust (opAct (peer silent ignoreTrack ignoreTrack (enrolment "/nonexistent/netbird.key"))))+            , testCase "the package provider installs netbird, after the repository when one is given" $ do+                let withRepo = node (package (viaRepository repo))+                    bare = node (package ignoreTrack)+                assertEqual "" [Package "netbird"] (packagesOf bare)+                assertBool "repo node present" (any ("apt index" `Text.isInfixOf`) (shorthands withRepo))+                assertBool "no repo node" (not (any ("apt index" `Text.isInfixOf`) (shorthands bare)))+            , testCase "the repository is upstream's, pinned to the one package" $+                assertEqual+                    ""+                    ( Text.unlines+                        [ "Types: deb"+                        , "URIs: https://pkgs.netbird.io/debian"+                        , "Suites: stable"+                        , "Components: main"+                        , "Signed-By: /etc/apt/keyrings/netbird.asc"+                        ]+                    )+                    (renderSources repo (resolveSuite repo.repoSuite "bookworm"))+            ]+        ]+  where+    repo = netbirdRepository "/k/netbird.asc" "AAAA"+    node bin = peer silent bin ignoreTrack selfHosted+    packagesOf :: Op -> [Package]+    packagesOf = concatMap snd . collectDynamics+    shorthands :: Op -> [Text.Text]+    shorthands o = [h | (_, _, h) <- pathedNodes (runIdentity (expand o))]+    rootRef :: Op -> Maybe Ref+    rootRef o = (\act -> act.extension.ref) <$> opAct o+    mentions :: ByteString.ByteString -> Binary.Report -> Bool+    mentions needle rep = needle `ByteString.isInfixOf` C8.pack (show rep)
+ test/Test/PathListReference.hs view
@@ -0,0 +1,72 @@+{- | 'Salmon.Actions.Query.outlinePaths' as it was before paths got a length+cap (the profiles' finding 4), kept as the reference "Test.ListedPathsSpec"+compares the current one with: at most @limit@ paths per node, every one in+full, shortest first by (rendered length, segments).++It holds nodes x depth segments on a deep graph: keep its inputs small.+Nothing outside the tests and the @graph-profiles@ benchmark uses it.++Written against the exported 'Query.Outline' functions: a node's shorthand+is the last segment of its first path, its children are 'outlineChildren',+and the walk visits the roots and children in the order the original did+(last met first), which only matters for an edge that closes a cycle.+-}+module Test.PathListReference (+    outlinePathsUncapped,+    renderedWidth,+) where++import qualified Data.List as List+import Data.Map.Strict (Map)+import qualified Data.Map.Strict as Map+import Data.Set (Set)+import qualified Data.Set as Set+import Data.Text (Text)+import qualified Data.Text as Text++import qualified Salmon.Actions.Query as Query+import Salmon.Op.Ref (Ref)++-- | A path's rendered length as @\/a\/b@: what the listing sorts by first.+renderedWidth :: [Text] -> Int+renderedWidth p = sum (map Text.length p) + length p++outlinePathsUncapped :: Int -> Query.Outline -> Map Ref [[Text]]+outlinePathsUncapped limit o = List.foldl' place Map.empty topDown+  where+    shorthands :: Map Ref Text+    shorthands = Map.fromList [(ref, last path) | (path, ref, _) <- Query.outlineFirstPaths o, not (null path)]++    childrenRev ref = reverse (Query.outlineChildren o ref)++    rootsRev = reverse (Query.outlineRoots o)++    parents :: Map Ref [Ref]+    parents = Map.fromListWith (<>) [(c, [p]) | p <- Set.toList (Query.outlineRefs o), c <- childrenRev p]++    roots = Set.fromList rootsRev++    topDown :: [Ref]+    topDown = snd (List.foldl' visit (Set.empty, []) rootsRev)++    visit :: (Set Ref, [Ref]) -> Ref -> (Set Ref, [Ref])+    visit (seen, done) ref+        | ref `Set.member` seen = (seen, done)+        | otherwise =+            let (seen', done') = List.foldl' visit (Set.insert ref seen, done) (childrenRev ref)+             in (seen', ref : done')++    place :: Map Ref [[Text]] -> Ref -> Map Ref [[Text]]+    place acc ref = case Map.lookup ref shorthands of+        Nothing -> acc+        Just seg ->+            let own = [[seg] | ref `Set.member` roots]+                inherited =+                    [ p <> [seg]+                    | parent <- Map.findWithDefault [] ref parents+                    , p <- Map.findWithDefault [] parent acc+                    ]+             in Map.insert ref (shortest (own <> inherited)) acc++    shortest :: [[Text]] -> [[Text]]+    shortest = take limit . map snd . Set.toAscList . Set.fromList . map (\p -> (renderedWidth p, p))
+ test/Test/PatroniArchiveVmsSpec.hs view
@@ -0,0 +1,412 @@+{-# LANGUAGE OverloadedStrings #-}++{- | T7 of @specs\/pg-patroni.md@ on the three-VM harness ("Test.PatroniVms"):+a Patroni member that has nothing but its configuration is made, by Patroni,+from the pgBackRest archive rather than by a base backup from the leader.++The guests are driven with the pure output of "Salmon.Builtin.Nodes.Patroni"+and "Salmon.Builtin.Nodes.PgBackRest" ('Patroni.renderPatroni',+'renderConf', 'patroniArchive', the argument lists), written over ssh, as+"Test.PgBackRestSpec" does in a container; their 'Op's are not run here. The+one thing the module's code does run is 'Patroni.checkMember', from the host,+against each member's REST address on the bridge.++What is placed where:++* guest 2: one etcd, plain HTTP. The @Etcd@ builtin speaks TLS only and has+  its own Layer 3 spec; T7's "the join phase runs" half is not this test.+* guest 1: the first member. Patroni initialises it, it archives from its+  first segment, and the stanza and a full backup are taken on it.+* guest 3: the member under test, configuration only.++__The repository is copied, not shared.__ The spec wants a repository every+member reads (a shared mount or S3); the guests have neither, so the+repository on guest 1 is copied to the same path on guest 3 (tar through the+host) after the backup, which is what a shared mount would show guest 3 at+that moment. Rows written after the copy reach guest 3 by streaming.++'tests' is Layer 0: the configurations the scenario lays down, and the+properties that make it mean anything (the restore lands in the data+directory Patroni starts, pgBackRest is the first method, nothing secret or+primary in public text). 'vmTests' is Layer 3.+-}+module Test.PatroniArchiveVmsSpec (+    tests,+    vmTests,+    archiveFor,+    memberFor,+    etcdRunArgv,+    patroniRunArgv,+) where++import Control.Concurrent (threadDelay)+import Control.Exception (onException)+import Control.Monad (unless)+import Data.List (isInfixOf, nub)+import Data.Text (Text)+import qualified Data.Text as Text+import Data.Time (addUTCTime, getCurrentTime)+import System.Exit (ExitCode (..))+import System.FilePath (takeDirectory, (</>))+import System.IO (hClose)+import System.IO.Temp (withSystemTempDirectory)+import System.Process (CreateProcess (..), StdStream (..), createProcess, proc, terminateProcess, waitForProcess)+import Test.QuickCheck (Gen, chooseInt, elements, forAll)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, assertEqual, assertFailure, testCase)+import Test.Tasty.QuickCheck (testProperty)++import Salmon.Actions.UpDown (CheckResult (..))+import Salmon.Builtin.Nodes.LinuxBridge (Cidr (..))+import qualified Salmon.Builtin.Nodes.Patroni as Patroni+import Salmon.Builtin.Nodes.PgBackRest+import Test.Harness+import Test.PatroniVms++-------------------------------------------------------------------------------+-- What the scenario lays down (pure)++-- | The archive as every member sees it: the same repository path, the cluster @main@ of a version.+archiveFor :: Text -> PgBackRestConfig+archiveFor version =+    PgBackRestConfig+        { pbr_stanza = "t7"+        , pbr_pg_path = "/var/lib/postgresql/" <> Text.unpack version <> "/main"+        , pbr_pg_port = 5432+        , pbr_repo = PosixRepo repoPath+        , pbr_retention_full = 2+        , pbr_global = []+        , pbr_stanza_options = []+        , pbr_config_file = "/etc/pgbackrest/pgbackrest.conf"+        , pbr_secrets_file = Nothing+        , pbr_log_path = "/var/log/pgbackrest"+        , pbr_user = "postgres"+        , pbr_run_as = Nothing+        , pbr_binary = "/usr/bin/pgbackrest"+        }++-- | Debian's own path, which 'resetGuest' empties.+repoPath :: FilePath+repoPath = "/var/lib/pgbackrest"++{- | One member: a name, the guest's own address, the etcd's address. The+@pg_hba@ admits the bridge's /24, replication included, with passwords.+-}+memberFor :: Text -> Text -> Text -> Text -> Patroni.PatroniConfig+memberFor version name addr etcdAddr =+    Patroni.PatroniConfig+        { Patroni.pat_scope = "t7"+        , Patroni.pat_namespace = "/service/"+        , Patroni.pat_name = name+        , Patroni.pat_etcd_hosts = [etcdAddr <> ":2379"]+        , Patroni.pat_etcd_tls = Nothing+        , Patroni.pat_rest_listen = "0.0.0.0:8008"+        , Patroni.pat_rest_connect_address = addr <> ":8008"+        , Patroni.pat_pg_version = version+        , Patroni.pat_pg_cluster = "main"+        , Patroni.pat_pg_listen = "0.0.0.0:5432"+        , Patroni.pat_pg_connect_address = addr <> ":5432"+        , Patroni.pat_pg_hba =+            [ "local all all peer"+            , "host all all " <> bridgeNet <> " scram-sha-256"+            , "host replication replicator " <> bridgeNet <> " scram-sha-256"+            ]+        , Patroni.pat_bootstrap_parameters = Patroni.defaultBootstrapParameters+        , Patroni.pat_config_dir = "/etc/patroni"+        , Patroni.pat_secrets_file = "/etc/patroni/secrets.yml"+        , Patroni.pat_user = "postgres"+        , Patroni.pat_binary = "/usr/bin/patroni"+        , Patroni.pat_ready_timeout_seconds = 120+        , Patroni.pat_archive = Just (patroniArchive (archiveFor version))+        }+  where+    bridgeNet = testBridgeCidr.cidrAddr `withLastOctet` "0" <> "/24"+    withLastOctet a o = Text.intercalate "." (take 3 (Text.splitOn "." a) <> [o])++{- | Where etcd keeps its data on the guest: @\/dev\/shm@, gone with the+guest. Not under @\/var\/lib\/etcd@, because these guests' root filesystem is+the host's directory over 9p, where bbolt's @mmap@ of its database fails+with @invalid argument@ and etcd panics at start; and not on @\/run@, a tmpfs+of a tenth of 512 MB, smaller than the 64 MB WAL segment etcd preallocates.+-}+etcdDataDir :: FilePath+etcdDataDir = "/dev/shm/salmon-t7-etcd"++{- | etcd as a transient unit (it dies with the guest), plain HTTP, one+member, advertising the guest's address. 'etcdDataDir' must exist first,+owned by @etcd@.+-}+etcdRunArgv :: Text -> [Text]+etcdRunArgv addr =+    [ "systemd-run"+    , "--unit=salmon-t7-etcd"+    , "-p"+    , "User=etcd"+    , "/usr/bin/etcd"+    , "--name=t7"+    , "--data-dir=" <> Text.pack etcdDataDir+    , "--listen-peer-urls=http://127.0.0.1:2380"+    , "--initial-advertise-peer-urls=http://127.0.0.1:2380"+    , "--initial-cluster=t7=http://127.0.0.1:2380"+    , "--listen-client-urls=http://0.0.0.0:2379"+    , "--advertise-client-urls=http://" <> addr <> ":2379"+    ]++{- | Patroni as a transient unit, on the same words as 'Patroni.unitConfig'+(binary, configuration directory, user). @KillMode=process@ as there:+stopping Patroni must not take Postgres with it.+-}+patroniRunArgv :: Patroni.PatroniConfig -> [Text]+patroniRunArgv c =+    [ "systemd-run"+    , patroniUnit+    , "-p"+    , "User=" <> c.pat_user+    , "-p"+    , "KillMode=process"+    , Text.pack c.pat_binary+    , Text.pack c.pat_config_dir+    ]++patroniUnit :: Text+patroniUnit = "--unit=salmon-t7-patroni"++-- | The pre-provisioned fragment. Test-only values; the one place a credential is.+secretsFragment :: String+secretsFragment =+    unlines+        [ "postgresql:"+        , "  authentication:"+        , "    superuser:"+        , "      username: postgres"+        , "      password: not-a-secret-1"+        , "    replication:"+        , "      username: replicator"+        , "      password: not-a-secret-2"+        ]++-------------------------------------------------------------------------------+-- Layer 0++genVersion :: Gen Text+genVersion = Text.pack . show <$> chooseInt (13, 19)++genMember :: Gen Patroni.PatroniConfig+genMember = do+    v <- genVersion+    (name, addr) <- elements (zip ["n1", "n2", "n3"] patroniAddrs)+    etcd <- elements patroniAddrs+    pure (memberFor v name addr etcd)++memberVersion :: Patroni.PatroniConfig -> Text+memberVersion = Patroni.pat_pg_version++tests :: TestTree+tests =+    testGroup+        "Patroni T7 on three VMs: what is laid down"+        [ testProperty "the restore lands in the data directory Patroni starts" $+            forAll genMember $ \m ->+                pbr_pg_path (archiveFor (memberVersion m)) == Patroni.pgDataDir m+        , testProperty "pgBackRest is the first replica method, a base backup the fallback" $+            forAll genMember $ \m ->+                let yml = Text.unpack (Patroni.renderPatroni m)+                 in "  create_replica_methods:\n    - \"pgbackrest\"\n    - \"basebackup\"\n" `isInfixOf` yml+                        && Text.unpack (replicaCreateCommand (archiveFor (memberVersion m))) `isInfixOf` yml+        , testProperty "no credential, no primary in patroni.yml or pgbackrest.conf" $+            forAll genMember $ \m ->+                let public = Text.unpack (Patroni.renderPatroni m <> renderConf (archiveFor (memberVersion m)))+                 in not (any (`isInfixOf` public) ["password", "not-a-secret", "primary_conninfo"])+        , testProperty "every member is configured identically but for its name and addresses" $+            forAll genVersion $ \v ->+                let ms = [memberFor v n a (patroniAddrs !! 1) | (n, a) <- zip ["n1", "n2", "n3"] patroniAddrs]+                    strip m =+                        let own = Text.takeWhile (/= ':') (Patroni.pat_rest_connect_address m)+                         in foldr+                                (\(from, to) -> Text.replace from to)+                                (Patroni.renderPatroni m)+                                [(Patroni.pat_name m, "N"), (own <> ":8008", "A:8008"), (own <> ":5432", "A:5432")]+                 in length (nub (fmap strip ms)) == 1+                        && length (nub (fmap Patroni.renderPatroni ms)) == 3+        , testProperty "Patroni runs on its configuration directory, as its user, and keeps Postgres on a stop" $+            forAll genMember $ \m ->+                let argv = patroniRunArgv m+                 in drop (length argv - 2) argv == [Text.pack (Patroni.pat_binary m), Text.pack (Patroni.pat_config_dir m)]+                        && ("User=" <> Patroni.pat_user m) `elem` argv+                        && "KillMode=process" `elem` argv+        , testCase "the guests' addresses are their own on the bridge" $ do+            assertEqual "three distinct" 3 (length (nub patroniAddrs))+            mapM_+                (\a -> assertBool (Text.unpack a) (a `notElem` [testVmAddr, testVmAddr2, testVmAddr3, testVmAddr4]))+                patroniAddrs+            mapM_ (\a -> assertBool (Text.unpack a) ("10.99.0." `Text.isPrefixOf` a)) patroniAddrs+        , testCase "pg_hba admits the bridge, replication included" $ do+            let m = memberFor "17" "n3" (patroniAddrs !! 2) (patroniAddrs !! 1)+            assertBool (show m.pat_pg_hba) ("host replication replicator 10.99.0.0/24 scram-sha-256" `elem` Patroni.pat_pg_hba m)+        , testCase "etcd advertises the guest it runs on, and is reached there" $ do+            let etcd = patroniAddrs !! 1+                m = memberFor "17" "n1" (patroniAddrs !! 0) etcd+            assertBool "" (("--advertise-client-urls=http://" <> etcd <> ":2379") `elem` etcdRunArgv etcd)+            assertEqual "" [etcd <> ":2379"] (Patroni.pat_etcd_hosts m)+        , testCase "the archive's commands name the configuration and the stanza" $ do+            let a = archiveFor "17"+            assertEqual "" "/usr/bin/pgbackrest --config=/etc/pgbackrest/pgbackrest.conf --stanza=t7 --delta restore" (replicaCreateCommand a)+            assertEqual "" "/var/lib/postgresql/17/main" (pbr_pg_path a)+            assertBool "" ("repo1-path=/var/lib/pgbackrest\n" `isInfixOf` Text.unpack (renderConf a))+        ]++-------------------------------------------------------------------------------+-- Layer 3++vmTests :: TestTree+vmTests =+    testGroup+        "Patroni T7 (Layer 3, three guests)"+        [ testCase "a member with only its configuration is made by Patroni from the pgBackRest archive" replicaFromArchive+        ]++replicaFromArchive :: IO ()+replicaFromArchive = requirePatroniVmPrereqs $+    withPatroniVms $ \vms -> case vms of+        [vm1, vm2, vm3] -> scenario vm1 vm2 vm3+        _ -> assertFailure ("expected three guests, got " <> show (length vms))++scenario :: VmAccess -> VmAccess -> VmAccess -> IO ()+scenario vm1 vm2 vm3 = do+    version <- Text.pack . takeWhile (/= '\n') <$> guest vm1 "ls /usr/lib/postgresql | sort -n | tail -n1"+    let [addr1, addr2, addr3] = patroniAddrs+        m1 = memberFor version "n1" addr1 addr2+        m3 = memberFor version "n3" addr3 addr2+        a = archiveFor version+        asPostgres args = "runuser -u postgres -- " <> Text.unpack (commandLine a args)+    lay vm1 m1 a+    lay vm3 m3 a++    -- the DCS+    guest_ vm2 ("install -d -o etcd -g etcd -m 0700 " <> etcdDataDir <> " && " <> unwords (Text.unpack <$> etcdRunArgv addr2))+    waitGuest vm2 "etcd" "etcdctl --endpoints=http://127.0.0.1:2379 endpoint health >/dev/null 2>&1 && echo ok" "ok"++    -- the first member: Patroni initialises it, and it archives from its first segment+    guest_ vm1 (unwords (Text.unpack <$> patroniRunArgv m1))+    waitMember vm1 m1+    waitGuest vm1 "archive_mode" (psqlOn "SHOW archive_mode") "on"+    guest_ vm1 (asPostgres (stanzaCreateArgs a))+    guest_ vm1 (asPostgres (backupArgs a Full))+    _ <- guest vm1 (psqlOn "CREATE TABLE canary (v text); INSERT INTO canary VALUES ('archived')")++    -- what a shared mount would show guest 3 now+    pipeBetween vm1 ("tar -C " <> repoPath <> " -cf - .") vm3 ("tar -C " <> repoPath <> " -xpf -")++    -- the member under test: nothing but configuration+    guest_ vm3 (unwords (Text.unpack <$> patroniRunArgv m3))+    waitMember vm3 m3+    logs <- guest vm3 "journalctl -u salmon-t7-patroni --no-pager"+    assertBool+        ("Patroni did not make the replica with pgbackrest:\n" <> logs)+        ("replica has been created using pgbackrest" `isInfixOf` logs)+    waitGuest vm3 "in recovery" (psqlOn "SELECT pg_is_in_recovery()") "t"+    waitGuest vm3 "the row written before" (psqlOn "SELECT string_agg(v, ',') FROM canary") "archived"+    waitGuest vm1 "a streaming standby" (psqlOn "SELECT count(*) FROM pg_stat_replication WHERE state = 'streaming'") "1"+    _ <- guest vm1 (psqlOn "INSERT INTO canary VALUES ('streamed')")+    waitGuest vm3 "a row written after" (psqlOn "SELECT string_agg(v, ',' ORDER BY v) FROM canary") "archived,streamed"++{- | Lays a member's files on its guest: the directories, @patroni.yml@, the+credentials fragment, the @postgresql.conf@ Patroni renames (what+'Patroni.pgConfSeed' does), and the archive's configuration.+-}+lay :: VmAccess -> Patroni.PatroniConfig -> PgBackRestConfig -> IO ()+lay vm m a = do+    guest_ vm . unwords $+        [ "mkdir -p"+        , m.pat_config_dir+        , Patroni.pgConfigDir m+        , takeDirectory a.pbr_config_file+        , repoPath+        , a.pbr_log_path+        , "&& touch " <> Patroni.pgConfigDir m <> "/postgresql.conf"+        , "&& chown -R postgres:postgres"+        , m.pat_config_dir+        , "/etc/postgresql"+        , repoPath+        , a.pbr_log_path+        ]+    writeOn vm (Patroni.patroniFile m) (Text.unpack (Patroni.renderPatroni m))+    writeOn vm m.pat_secrets_file secretsFragment+    writeOn vm a.pbr_config_file (Text.unpack (renderConf a))+    guest_ vm ("chown postgres:postgres " <> Patroni.patroniFile m <> " " <> m.pat_secrets_file <> " && chmod 0600 " <> m.pat_secrets_file)++-- | Polls the module's own check, from the host, until the member is healthy.+waitMember :: VmAccess -> Patroni.PatroniConfig -> IO ()+waitMember vm m = do+    -- a replica restores and replays before it is healthy+    deadline <- addUTCTime 300 <$> getCurrentTime+    let go = do+            v <- Patroni.checkMember m+            now <- getCurrentTime+            case () of+                _+                    | v == Success -> pure ()+                    | now > deadline -> do+                        logs <- guest vm "journalctl -u salmon-t7-patroni --no-pager | tail -n 80"+                        assertFailure ("member " <> Text.unpack m.pat_name <> " never became healthy: " <> show v <> "\n" <> logs)+                    | otherwise -> threadDelay 2000000 >> go+    go++psqlOn :: String -> String+psqlOn q = "runuser -u postgres -- psql -X -p 5432 -tAc " <> quoteForRemoteShell q++-- | Runs a script on a guest as root; a non-zero exit fails the test.+guest :: VmAccess -> String -> IO String+guest vm script = do+    (code, out, err) <- sshToVm vm ["bash", "-c", quoteForRemoteShell script]+    unless (code == ExitSuccess) $ assertFailure (script <> " failed:\n" <> out <> "\n" <> err)+    pure out++guest_ :: VmAccess -> String -> IO ()+guest_ vm script = () <$ guest vm script++{- | Polls a script until its first line of output is what is expected, for+two minutes of wall clock (an ssh round trip to a loaded guest is itself+seconds, so a count of tries says little).+-}+waitGuest :: VmAccess -> String -> String -> String -> IO ()+waitGuest vm what script expected = do+    deadline <- addUTCTime 120 <$> getCurrentTime+    let go lastSeen = do+            now <- getCurrentTime+            if now > deadline+                then do+                    -- whatever ran on this guest says why, more than the last answer does+                    (_, units, _) <- sshToVm vm ["bash", "-c", quoteForRemoteShell "journalctl -u 'salmon-t7-*' --no-pager | tail -n 60"]+                    assertFailure (what <> ": " <> script <> " never answered " <> expected <> "; last: " <> lastSeen <> "\n" <> units)+                else do+                    (code, out, err) <- sshToVm vm ["bash", "-c", quoteForRemoteShell script]+                    let got = takeWhile (/= '\n') out+                    if code == ExitSuccess && got == expected+                        then pure ()+                        else threadDelay 1000000 >> go (got <> err)+    go ""++writeOn :: VmAccess -> FilePath -> String -> IO ()+writeOn vm path contents =+    withSystemTempDirectory "salmon-t7" $ \dir -> do+        let local = dir </> "f"+        writeFile local contents+        scpToVm vm local path++{- | @src@'s stdout into @dst@'s stdin, through the host: two ssh processes and+one pipe, neither inheriting the test's stdin.+-}+pipeBetween :: VmAccess -> String -> VmAccess -> String -> IO ()+pipeBetween src srcScript dst dstScript = do+    (Just srcIn, Just out, _, ph1) <-+        createProcess (proc "ssh" (sshArgv src ["bash", "-c", quoteForRemoteShell srcScript])){std_in = CreatePipe, std_out = CreatePipe}+    hClose srcIn+    -- createProcess closes a handle it is given with UseHandle, in this process+    (_, _, _, ph2) <-+        createProcess (proc "ssh" (sshArgv dst ["bash", "-c", quoteForRemoteShell dstScript])){std_in = UseHandle out}+            `onException` (hClose out >> terminateProcess ph1)+    c1 <- waitForProcess ph1+    c2 <- waitForProcess ph2+    unless (c1 == ExitSuccess && c2 == ExitSuccess) $+        assertFailure ("copying " <> srcScript <> " -> " <> dstScript <> " failed: " <> show (c1, c2))
+ test/Test/PatroniPreferredLeaderSpec.hs view
@@ -0,0 +1,365 @@+{-# LANGUAGE OverloadedStrings #-}++{- | "Salmon.Builtin.Nodes.Patroni.PreferredLeader": the pure decision and its+parsers (unit cases and QuickCheck properties), the node's public text, and+@check@\/@up@ against an in-process fake of Patroni's REST API (warp). No real+Patroni is run here.+-}+module Test.PatroniPreferredLeaderSpec (tests) where++import Control.Exception (try)+import Control.Monad (forM)+import Data.Aeson (Value (..), decode, encode, object, (.=))+import qualified Data.Aeson.Key as Key+import qualified Data.Aeson.KeyMap as KeyMap+import qualified Data.ByteString as ByteString+import qualified Data.ByteString.Char8 as Char8+import qualified Data.ByteString.Lazy as LByteString+import Data.IORef (IORef, atomicModifyIORef', newIORef, readIORef)+import Data.List (isInfixOf, nub)+import Data.Maybe (mapMaybe)+import Data.Text (Text)+import qualified Data.Text as Text+import qualified Network.HTTP.Types as HTTP+import qualified Network.Wai as Wai+import qualified Network.Wai.Handler.Warp as Warp+import System.FilePath ((</>))+import Test.QuickCheck (Gen, chooseInt, counterexample, elements, forAll, frequency, listOf1, oneof, (.&&.), (===))+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, assertEqual, assertFailure, testCase)+import Test.Tasty.QuickCheck (testProperty)++import Salmon.Actions.UpDown (CheckResult (..))+import Salmon.Builtin.Extension+import Salmon.Builtin.Nodes.Patroni.PreferredLeader+import Salmon.Op.Actions (Act (..), Actions (..))+import Salmon.Op.OpGraph (OpGraph (..))+import Salmon.Op.Ref (mkRef)++import Test.Harness (withTempDir)++tests :: TestTree+tests =+    testGroup+        "Salmon.Builtin.Nodes.Patroni.PreferredLeader"+        [ testGroup "parsing" parsing+        , testGroup "decision" decisions+        , testGroup "properties" properties+        , testGroup "node" nodeText+        , testGroup "against a fake REST API" fakeApi+        ]++-------------------------------------------------------------------------------++parsing :: [TestTree]+parsing =+    [ testCase "a Patroni 4 /cluster body" $+        case parseCluster patroni4Cluster of+            Left e -> assertFailure (Text.unpack e)+            Right cv -> do+                assertEqual "scope" (Just "demo") cv.cv_scope+                assertEqual+                    "members"+                    [ Member "n1" "leader" (Just "running") Nothing False+                    , Member "n2" "replica" (Just "streaming") (Just 0) False+                    , Member "n3" "replica" (Just "streaming") Nothing True+                    ]+                    cv.cv_members+                assertEqual "sync is /config's to say" False cv.cv_synchronous+    , testCase "synchronous_mode: true, quorum, absent, off" $ do+        assertEqual "true" (Right True) (parseSynchronousMode "{\"synchronous_mode\":true}")+        assertEqual "quorum" (Right True) (parseSynchronousMode "{\"synchronous_mode\":\"quorum\"}")+        assertEqual "absent" (Right False) (parseSynchronousMode "{\"ttl\":30}")+        assertEqual "false" (Right False) (parseSynchronousMode "{\"synchronous_mode\":false}")+    , testCase "a synchronous_mode not understood is an error, not off" $ do+        assertBool "" (isLeft (parseSynchronousMode "{\"synchronous_mode\":\"maybe\"}"))+        assertBool "" (isLeft (parseSynchronousMode "{\"synchronous_mode\":3}"))+        assertBool "" (isLeft (parseSynchronousMode "nope"))+    , testCase "credentials: user:password on the first line" $ do+        assertEqual "" (Right ("patroni", "s3:cr3t")) (parseCredentials "patroni:s3:cr3t\nignored\n")+        assertEqual "crlf" (Right ("u", "p")) (parseCredentials "u:p\r\n")+        assertBool "no colon" (isLeft (parseCredentials "just-a-password\n"))+        assertBool "no user" (isLeft (parseCredentials ":pw\n"))+    , testCase "the switchover body names leader and candidate" $+        assertEqual "" (Just (object ["leader" .= ("n1" :: Text), "candidate" .= ("n2" :: Text)])) (decode (switchoverBody "n1" "n2"))+    ]++decisions :: [TestTree]+decisions =+    [ testCase "the preferred member leads" $+        assertEqual "" AlreadyLeads (decide "demo" "n1" (cluster False [leader "n1", replica "n2"]))+    , testCase "an eligible replica is switched to, from the actual leader" $+        assertEqual "" (SwitchOver "n1" "n2") (decide "demo" "n2" (cluster False [leader "n1", replica "n2"]))+    , testCase "in archive recovery is eligible; stopped and starting are not" $ do+        assertEqual "" (SwitchOver "n1" "n2") (decide "demo" "n2" (cluster False [leader "n1", (replica "n2"){m_state = Just "in archive recovery"}]))+        assertIsWait (decide "demo" "n2" (cluster False [leader "n1", (replica "n2"){m_state = Just "stopped"}]))+        assertIsWait (decide "demo" "n2" (cluster False [leader "n1", (replica "n2"){m_state = Just "starting"}]))+    , testCase "synchronous mode: an async preferred member waits, it does not fall back" $ do+        assertIsWait (decide "demo" "n2" (cluster True [leader "n1", replica "n2", (replica "n3"){m_role = "sync_standby"}]))+        assertEqual "" (SwitchOver "n1" "n3") (decide "demo" "n3" (cluster True [leader "n1", replica "n2", (replica "n3"){m_role = "sync_standby"}]))+        assertEqual "quorum" (SwitchOver "n1" "n3") (decide "demo" "n3" (cluster True [leader "n1", (replica "n3"){m_role = "quorum_standby"}]))+    , testCase "no leader: wait, a failover is Patroni's" $+        assertIsWait (decide "demo" "n2" (cluster False [replica "n1", replica "n2"]))+    , testCase "nofailover, unknown member and another scope wait" $ do+        assertIsWait (decide "demo" "n2" (cluster False [leader "n1", (replica "n2"){m_nofailover = True}]))+        assertIsWait (decide "demo" "n9" (cluster False [leader "n1", replica "n2"]))+        assertIsWait (decide "other" "n2" (cluster False [leader "n1", replica "n2"]))+    , testCase "the verdicts: lead is Success, due is Failure, wait is Unknown" $ do+        assertEqual "" Success (decisionVerdict AlreadyLeads)+        assertBool "" (isFailure (decisionVerdict (SwitchOver "a" "b")))+        assertEqual "" Unknown (decisionVerdict (Wait "x"))+    , testCase "the candidate is the lowest lag, unknown lag last, never the leader" $ do+        let cv = cluster False [(leader "n0"){m_lag = Just 0}, (replica "n1"){m_lag = Nothing}, (replica "n2"){m_lag = Just 50}, (replica "n3"){m_lag = Just 10}]+        assertEqual "" ["n3", "n2", "n1"] (fmap (.m_name) (switchoverCandidates cv))+    , testCase "no candidate in sync mode without a sync standby" $+        assertEqual "" Nothing (switchoverCandidate (cluster True [leader "n1", replica "n2"]))+    ]+  where+    assertIsWait (Wait _) = pure ()+    assertIsWait d = assertFailure ("expected Wait, got " <> show d)++-------------------------------------------------------------------------------++properties :: [TestTree]+properties =+    [ testProperty "a switchover goes from the leader to the preferred member, which is eligible" $+        forAll genCase $ \(pref, cv) -> case decide "demo" pref cv of+            SwitchOver from to ->+                counterexample (show cv) $+                    (fmap (.m_name) (leaderOf cv) === Just from)+                        .&&. (to === pref)+                        .&&. counterexample "eligible" (any (\m -> m.m_name == to && eligible cv m) cv.cv_members)+            _ -> counterexample "" True+    , testProperty "synchronous mode never targets an asynchronous member" $+        forAll genCase $ \(pref, cv0) ->+            let cv = cv0{cv_synchronous = True}+             in case decide "demo" pref cv of+                    SwitchOver _ to -> counterexample (show cv) (any (\m -> m.m_name == to && m.m_role `elem` syncRoles) cv.cv_members)+                    _ -> counterexample "" True+    , testProperty "the preferred member leads iff the decision is AlreadyLeads" $+        forAll genCase $ \(pref, cv) ->+            (decide "demo" pref cv == AlreadyLeads) === (fmap (.m_name) (leaderOf cv) == Just pref)+    , testProperty "after the switchover it asked for, the node asks for nothing more" $+        forAll genCase $ \(pref, cv) -> case decide "demo" pref cv of+            SwitchOver from to -> decide "demo" pref (applySwitch from to cv) === AlreadyLeads+            _ -> counterexample "" True+    , testProperty "the candidate is eligible, not the leader, and has the lowest known lag" $+        forAll genCluster $ \cv -> case switchoverCandidate cv of+            Nothing -> filter (eligible cv) cv.cv_members === []+            Just c ->+                let others = filter (eligible cv) cv.cv_members+                    known = mapMaybe (.m_lag) others+                 in counterexample (show cv) $+                        eligible cv c+                            && c.m_role `notElem` leaderRoles+                            && case c.m_lag of+                                Just l -> all (l <=) known+                                Nothing -> null known+    , testProperty "/cluster parses what Patroni would render" $+        forAll genCluster $ \cv ->+            parseCluster (LByteString.toStrict (encode (renderCluster cv))) === Right cv{cv_synchronous = False}+    , testProperty "credentials round-trip, and an error never quotes the file" $+        -- the Z marks the password (the error text has no Z), so a one-letter one is not "found" in it+        forAll ((,) <$> genWord False <*> (("Z" <>) <$> genWord True)) $ \(u, p) ->+            let file = Char8.pack (u <> ":" <> p <> "\n")+             in (parseCredentials file === Right (Char8.pack u, Char8.pack p))+                    .&&. case parseCredentials (Char8.pack p) of+                        Left e -> counterexample (Text.unpack e) (not (Text.pack p `Text.isInfixOf` e))+                        Right _ -> counterexample "" True+    , testProperty "the ref is the scope's alone; help and notes name the member, never the credentials" $+        forAll ((,,) <$> genWord False <*> genWord False <*> genWord False) $ \(m, addr, cred) ->+            let c = sample{pl_member = Text.pack m, pl_rest_address = Text.pack addr, pl_credentials_file = Just ("/etc/patroni" </> cred)}+                e = ext (preferredLeader c)+                public = Text.unpack (Text.unlines (e.help : e.notes))+             in (e.ref === mkRef "patroni-preferred-leader" ("demo" :: Text))+                    .&&. counterexample public (m `isInfixOf` public)+                    .&&. counterexample public (not (("/etc/patroni" </> cred) `isInfixOf` public))+    ]++-------------------------------------------------------------------------------++nodeText :: [TestTree]+nodeText =+    [ testCase "ref, help and notes" $ do+        let e = ext (preferredLeader sample)+        assertEqual "ref" (mkRef "patroni-preferred-leader" ("demo" :: Text)) e.ref+        assertEqual "help" "Patroni scope demo prefers n2 as leader" e.help+        assertEqual+            "notes"+            [ "opt-in: moves the leader back to n2 after a failover"+            , "switchover only to an eligible member; with synchronous_mode, only to a sync standby"+            , "down leaves the leader where it is"+            ]+            e.notes+    , testCase "a changed preference is a changed representative, same ref" $ do+        let a = ext (preferredLeader sample)+            b = ext (preferredLeader sample{pl_member = "n3"})+        assertEqual "" a.ref b.ref+        assertBool "" (a.help /= b.help && a.notes /= b.notes)+    ]++-------------------------------------------------------------------------------++-- | What the fake saw: switchover requests, as (authorization header, body).+data Fake = Fake+    { fLeader :: IORef Text+    , fRequests :: IORef [(Maybe ByteString.ByteString, LByteString.ByteString)]+    }++fakeApi :: [TestTree]+fakeApi =+    [ testCase "check, up with credentials, check again" $ withTempDir $ \tmp -> do+        let creds = tmp </> "rest-auth"+        writeFile creds "patroni:hunter2\n"+        withFake False [replica "n2"] $ \port fake -> do+            let c = sample{pl_rest_address = local port, pl_credentials_file = Just creds, pl_settle_seconds = 5}+                e = ext (preferredLeader c)+            v <- e.check+            assertBool (show v) (isFailure v)+            e.up+            reqs <- readIORef fake.fRequests+            assertEqual "one POST, basic auth, the body" [(Just "Basic cGF0cm9uaTpodW50ZXIy", switchoverBody "n1" "n2")] reqs+            assertEqual "leads now" Success =<< e.check+            e.up+            assertEqual "a second up sends nothing" 1 . length =<< readIORef fake.fRequests+    , testCase "sync mode without a sync standby: Unknown, and up sends nothing" $+        withFake True [replica "n2"] $ \port fake -> do+            let e = ext (preferredLeader sample{pl_rest_address = local port})+            assertEqual "" Unknown =<< e.check+            r <- try e.up+            case r of+                Left (SwitchoverSkipped why) -> assertBool (Text.unpack why) ("synchronous" `Text.isInfixOf` why)+                Right () -> assertFailure "up should have thrown"+            assertEqual "no POST" [] =<< readIORef fake.fRequests+            assertEqual "leader untouched" "n1" =<< readIORef fake.fLeader+    , testCase "refused credentials: the error says so and does not quote them" $ withTempDir $ \tmp -> do+        let creds = tmp </> "rest-auth"+        writeFile creds "patroni:wrong-one\n"+        withFake False [replica "n2"] $ \port _ -> do+            r <- try (ext (preferredLeader sample{pl_rest_address = local port, pl_credentials_file = Just creds})).up+            case r of+                Left (SwitchoverRefused why) -> do+                    assertBool (Text.unpack why) ("401" `Text.isInfixOf` why)+                    assertBool (Text.unpack why) (not ("wrong-one" `Text.isInfixOf` why))+                Right () -> assertFailure "up should have thrown"+    , testCase "nobody listening is a Failure" $ do+        v <- (ext (preferredLeader sample{pl_rest_address = "127.0.0.1:1"})).check+        assertBool (show v) (isFailure v)+    ]+  where+    local port = "127.0.0.1:" <> Text.pack (show port)++-- | A cluster whose leader is @n1@, the given replicas, and @n2@ as the member answering.+withFake :: Bool -> [Member] -> (Int -> Fake -> IO a) -> IO a+withFake sync replicas act = do+    fake <- Fake <$> newIORef "n1" <*> newIORef []+    Warp.testWithApplication (pure (app fake)) $ \port -> act port fake+  where+    app :: Fake -> Wai.Application+    app fake req respond = do+        current <- readIORef fake.fLeader+        let members = leader current : [if r.m_name == current then replica "n1" else r | r <- replicas]+        case (Wai.requestMethod req, Wai.pathInfo req) of+            ("GET", ["leader"]) -> respond (Wai.responseLBS (if current == "n2" then HTTP.status200 else HTTP.status503) [] "{}")+            ("GET", ["cluster"]) -> respond (json HTTP.status200 (renderCluster (cluster sync members)))+            ("GET", ["config"]) -> respond (json HTTP.status200 (object ["synchronous_mode" .= sync, "ttl" .= (30 :: Int)]))+            ("POST", ["switchover"]) -> do+                body <- Wai.strictRequestBody req+                let auth = lookup "Authorization" (Wai.requestHeaders req)+                atomicModifyIORef' fake.fRequests (\xs -> (xs <> [(auth, body)], ()))+                if auth == Just "Basic cGF0cm9uaTpodW50ZXIy"+                    then do+                        atomicModifyIORef' fake.fLeader (const ("n2", ()))+                        respond (Wai.responseLBS HTTP.status200 [] "Successfully switched over to \"n2\"")+                    else respond (Wai.responseLBS HTTP.status401 [] "no auth header received")+            _ -> respond (Wai.responseLBS HTTP.status404 [] "")+    json st v = Wai.responseLBS st [(HTTP.hContentType, "application/json")] (encode (v :: Value))++-------------------------------------------------------------------------------++sample :: PreferredLeader+sample = PreferredLeader "demo" "n2" "10.0.0.2:8008" Nothing defaultSettleSeconds++ext :: Op -> Extension+ext o = case o.node of+    Actions a -> a.extension+    Actionless -> error "no actions"++cluster :: Bool -> [Member] -> ClusterView+cluster sync ms = ClusterView (Just "demo") ms sync++leader, replica :: Text -> Member+leader n = Member n "leader" (Just "running") Nothing False+replica n = Member n "replica" (Just "streaming") (Just 0) False++applySwitch :: Text -> Text -> ClusterView -> ClusterView+applySwitch from to cv = cv{cv_members = fmap swap cv.cv_members}+  where+    swap m+        | m.m_name == to = m{m_role = "leader", m_state = Just "running", m_lag = Nothing}+        | m.m_name == from = m{m_role = "replica", m_state = Just "streaming"}+        | otherwise = m++-- | @GET \/cluster@ as Patroni renders it (the fields this module reads, plus some it does not).+renderCluster :: ClusterView -> Value+renderCluster cv =+    object $+        ["members" .= fmap member cv.cv_members]+            <> maybe [] (\s -> ["scope" .= s]) cv.cv_scope+  where+    member :: Member -> Value+    member m =+        Object . KeyMap.fromList $+            [ ("name", String m.m_name)+            , ("role", String m.m_role)+            , ("host", String "10.0.0.9")+            , ("port", Number 5432)+            , ("lag", maybe (String "unknown") (Number . fromInteger) m.m_lag)+            ]+                <> maybe [] (\s -> [("state", String s)]) m.m_state+                <> [(Key.fromText "tags", object ["nofailover" .= True]) | m.m_nofailover]++patroni4Cluster :: ByteString.ByteString+patroni4Cluster =+    "{\"members\":[\+    \{\"name\":\"n1\",\"role\":\"leader\",\"state\":\"running\",\"api_url\":\"http://10.0.0.1:8008/patroni\",\"host\":\"10.0.0.1\",\"port\":5432,\"timeline\":2},\+    \{\"name\":\"n2\",\"role\":\"replica\",\"state\":\"streaming\",\"api_url\":\"http://10.0.0.2:8008/patroni\",\"host\":\"10.0.0.2\",\"port\":5432,\"timeline\":2,\"receive_lag\":0,\"replay_lag\":0,\"lag\":0},\+    \{\"name\":\"n3\",\"role\":\"replica\",\"state\":\"streaming\",\"host\":\"10.0.0.3\",\"port\":5432,\"timeline\":2,\"lag\":\"unknown\",\"tags\":{\"nofailover\":true}}\+    \],\"scope\":\"demo\"}"++-------------------------------------------------------------------------------++genCluster :: Gen ClusterView+genCluster = do+    names <- nub <$> listOf1 (elements ["n0", "n1", "n2", "n3", "n4", "n5"])+    hasLeader <- frequency [(5, pure True), (1, pure False)]+    leaderIdx <- chooseInt (0, length names - 1)+    ms <- forM (zip [0 :: Int ..] names) $ \(i, n) ->+        if hasLeader && i == leaderIdx+            then Member n <$> elements leaderRoles <*> pure (Just "running") <*> pure Nothing <*> pure False+            else+                Member n+                    <$> elements ["replica", "replica", "sync_standby", "quorum_standby", "standby_leader"]+                    <*> oneof [pure Nothing, Just <$> elements (healthyStates <> ["stopped", "starting", "crashed", "creating replica"])]+                    <*> oneof [pure Nothing, Just . fromIntegral <$> chooseInt (0, 1000)]+                    <*> frequency [(4, pure False), (1, pure True)]+    sync <- elements [False, True]+    scope <- elements [Just "demo", Nothing]+    pure (ClusterView scope ms sync)++genCase :: Gen (Text, ClusterView)+genCase = do+    cv <- genCluster+    pref <- elements ("n9" : fmap (.m_name) cv.cv_members)+    pure (pref, cv)++-- | A short word; with @free@, any printable but newline (a password may hold colons).+genWord :: Bool -> Gen String+genWord free = listOf1 (elements (if free then ['a' .. 'z'] <> ":/+= !@" else ['a' .. 'z'] <> ['0' .. '9']))++isFailure :: CheckResult -> Bool+isFailure (Failure _) = True+isFailure _ = False++isLeft :: Either a b -> Bool+isLeft = either (const True) (const False)
+ test/Test/PatroniRollingMaintenanceSpec.hs view
@@ -0,0 +1,440 @@+{-# LANGUAGE OverloadedStrings #-}++{- | "Salmon.Builtin.Nodes.Patroni.RollingMaintenance": the pure step over+member tables (unit cases and QuickCheck properties, including a simulated+run to the end and a run "killed" after every step), the nodes' public text+and graph shape, and @check@\/@up@ against three in-process fakes of Patroni's+REST API (warp), one per member. No real Patroni is run here.+-}+module Test.PatroniRollingMaintenanceSpec (tests) where++import Control.Exception (try)+import Control.Monad (forM)+import Data.Aeson (Value (..), decode, encode, object, (.=))+import qualified Data.Aeson.Key as Key+import qualified Data.Aeson.KeyMap as KeyMap+import qualified Data.ByteString.Lazy as LByteString+import Data.Functor.Identity (runIdentity)+import Data.IORef (IORef, atomicModifyIORef', newIORef, readIORef)+import Data.List (isInfixOf, nub)+import qualified Data.Map.Strict as Map+import Data.Text (Text)+import qualified Data.Text as Text+import qualified Network.HTTP.Types as HTTP+import qualified Network.Wai as Wai+import qualified Network.Wai.Handler.Warp as Warp+import System.FilePath ((</>))+import Test.QuickCheck (Gen, chooseInt, counterexample, elements, forAll, frequency, listOf1, oneof, (.&&.), (===))+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, assertEqual, assertFailure, testCase)+import Test.Tasty.QuickCheck (testProperty)++import Salmon.Actions.UpDown (CheckResult (..))+import Salmon.Builtin.Extension+import Salmon.Builtin.Nodes.Patroni.PreferredLeader (ClusterView (..), Member (..), eligible, leaderOf, leaderRoles, syncRoles)+import Salmon.Builtin.Nodes.Patroni.RollingMaintenance+import Salmon.Op.Actions (Act (..), Actions (..))+import Salmon.Op.Graph (Graph (..))+import Salmon.Op.OpGraph (OpGraph (..))+import Salmon.Op.Ref (mkRef)+import qualified Salmon.Op.Window as Window++import Test.Harness (withTempDir)++tests :: TestTree+tests =+    testGroup+        "Salmon.Builtin.Nodes.Patroni.RollingMaintenance"+        [ testGroup "parsing" parsing+        , testGroup "step" steps+        , testGroup "properties" properties+        , testGroup "nodes" nodeText+        , testGroup "against fake REST APIs" fakeApi+        ]++-------------------------------------------------------------------------------++parsing :: [TestTree]+parsing =+    [ testCase "a Patroni 4 /patroni body" $+        assertEqual+            ""+            (Right (MemberStatus (Just "n2") (Just "demo") (Just 170004) True))+            ( parseMemberStatus+                "{\"state\":\"running\",\"postmaster_start_time\":\"2026-10-01 10:00:00+00:00\",\"role\":\"replica\",\+                \\"server_version\":170004,\"xlog\":{\"received_location\":1,\"replayed_location\":1,\"paused\":false},\+                \\"timeline\":2,\"pending_restart\":true,\"pending_restart_reason\":{\"max_connections\":{\"old_value\":\"100\",\"new_value\":\"200\"}},\+                \\"dcs_last_seen\":1,\"database_system_identifier\":\"7\",\"patroni\":{\"version\":\"4.0.7\",\"scope\":\"demo\",\"name\":\"n2\"}}"+            )+    , testCase "absent fields: no name, no version, nothing pending" $+        assertEqual "" (Right (MemberStatus Nothing Nothing Nothing False)) (parseMemberStatus "{\"state\":\"running\"}")+    , testCase "not JSON is an error" $+        assertBool "" (either (const True) (const False) (parseMemberStatus "nope"))+    , testCase "the restart body asks Patroni to restart only a replica" $+        assertEqual "" (Just (object ["role" .= ("replica" :: Text)])) (decode restartBody :: Maybe Value)+    , testCase "stale: pending_restart, or a running version below the target" $ do+        assertEqual "pending" (Right True) (stale Nothing (fresh 170004){ms_pending_restart = True})+        assertEqual "no target" (Right False) (stale Nothing (fresh 170004))+        assertEqual "older" (Right True) (stale (Just 170005) (fresh 170004))+        assertEqual "same" (Right False) (stale (Just 170004) (fresh 170004))+        assertBool "unknown version" (either (const True) (const False) (stale (Just 170005) (fresh 170004){ms_server_version = Nothing}))+    ]++steps :: [TestTree]+steps =+    [ testCase "nothing stale: finished" $+        assertEqual "" Finished (step AllMembers "demo" target (world ["n1"] [] False))+    , testCase "stale replicas first, by name, one at a time" $ do+        assertEqual "" (Restart "n2") (step AllMembers "demo" target (world ["n1", "n2", "n3"] ["n1", "n3", "n2"] False))+        assertEqual "" (Restart "n3") (step AllMembers "demo" target (world ["n1", "n2", "n3"] ["n1", "n3"] False))+    , testCase "replicas done, leader stale: switch over to the lowest lag" $+        assertEqual "" (SwitchOverTo "n1" "n3") (step AllMembers "demo" target (lagged [("n2", 50), ("n3", 10)] (world ["n1", "n2", "n3"] ["n1"] False)))+    , testCase "the replicas node never touches the leader" $+        assertEqual "" Finished (step ReplicasOnly "demo" target (world ["n1", "n2", "n3"] ["n1"] False))+    , testCase "sync mode: only the sync standby, and no fallback" $ do+        let w = world ["n1", "n2", "n3"] ["n1"] True+        assertIsHold (step AllMembers "demo" target w)+        assertEqual "" (SwitchOverTo "n1" "n3") (step AllMembers "demo" target (roled "n3" "sync_standby" w))+    , testCase "no candidate at all: the leader is left alone" $ do+        assertIsHold (step AllMembers "demo" target (world ["n1"] ["n1"] False))+        assertIsHold (step AllMembers "demo" target (nofailover "n2" (world ["n1", "n2"] ["n1"] False)))+    , testCase "a transitioning, unreadable, missing or mislabelled member holds everything" $ do+        let w = world ["n1", "n2", "n3"] ["n2"] False+        assertIsHold (step AllMembers "demo" target (stated "n3" (Just "starting") w))+        assertIsHold (step AllMembers "demo" target (stated "n3" Nothing w))+        assertIsHold (step AllMembers "demo" target w{ob_members = [(n, if n == "n3" then Left "down" else s) | (n, s) <- w.ob_members]})+        assertIsHold (step AllMembers "demo" target w{ob_members = filter ((/= "n3") . fst) w.ob_members})+        assertIsHold (step AllMembers "demo" target w{ob_members = w.ob_members <> [("n9", Right (fresh 170005))]})+        assertIsHold (step AllMembers "demo" target w{ob_members = [(n, if n == "n3" then Right (fresh 170005){ms_name = Just "n1"} else s) | (n, s) <- w.ob_members]})+    , testCase "no leader, another scope, a standby cluster: hold" $ do+        let w = world ["n1", "n2"] ["n2"] False+        assertIsHold (step AllMembers "demo" target w{ob_cluster = w.ob_cluster{cv_members = [m{m_role = "replica", m_state = Just "streaming"} | m <- w.ob_cluster.cv_members]}})+        assertIsHold (step AllMembers "other" target w)+        assertIsHold (step AllMembers "demo" target (roled "n1" "standby_leader" w))+    , testCase "verdicts: finished Success, due Failure, held Unknown" $ do+        assertEqual "" Success (stepVerdict Finished)+        assertBool "" (isFailure (stepVerdict (Restart "n2")))+        assertBool "" (isFailure (stepVerdict (SwitchOverTo "n1" "n2")))+        assertEqual "" Unknown (stepVerdict (Hold "x"))+    ]+  where+    assertIsHold (Hold _) = pure ()+    assertIsHold s = assertFailure ("expected Hold, got " <> show s)++-------------------------------------------------------------------------------++properties :: [TestTree]+properties =+    [ testProperty "a restart is never of the member that leads" $+        forAll genWorld $ \(phase, w) -> case step phase "demo" target w of+            Restart r -> counterexample (show w) (fmap (.m_name) (leaderOf w.ob_cluster) =/= Just r)+            _ -> ok+    , testProperty "a switchover goes from the leader to an eligible, up-to-date member, and only once every replica is" $+        forAll genWorld $ \(phase, w) -> case step phase "demo" target w of+            SwitchOverTo from to ->+                counterexample (show w) $+                    (phase === AllMembers)+                        .&&. (fmap (.m_name) (leaderOf w.ob_cluster) === Just from)+                        .&&. counterexample "eligible" (any (\m -> m.m_name == to && eligible w.ob_cluster m) w.ob_cluster.cv_members)+                        .&&. counterexample "stale" ([n | n <- names w, n /= from, isStale w n] === [])+            _ -> ok+    , testProperty "synchronous mode never targets an asynchronous member" $+        forAll genWorld $ \(phase, w0) ->+            let w = w0{ob_cluster = w0.ob_cluster{cv_synchronous = True}}+             in case step phase "demo" target w of+                    SwitchOverTo _ to -> counterexample (show w) (any (\m -> m.m_name == to && m.m_role `elem` syncRoles) w.ob_cluster.cv_members)+                    _ -> ok+    , testProperty "anything transitioning or unreadable holds, whatever else is due" $+        forAll ((,) <$> genWorld <*> elements [Just "starting", Just "stopped", Just "restarting", Nothing]) $ \((phase, w), st) ->+            case names w of+                [] -> ok+                (n : _) -> case step phase "demo" target (stated n st w) of+                    Hold _ -> ok+                    s -> counterexample (show s) False+    , testProperty "a run ends Finished with nothing stale, or held with the leader left alone; replicas strictly before the switchover" $+        forAll genWorld $ \(phase, w) ->+            let (trace, end) = simulate phase w+                switchAt = [i | (i, SwitchOverTo{}) <- zip [0 :: Int ..] trace]+                firstLeader = fmap (.m_name) (leaderOf w.ob_cluster)+             in counterexample (show (trace, end)) $+                    (length trace <= 2 * length (names w) + 1)+                        .&&. case step phase "demo" target end of+                            Finished ->+                                counterexample "stale left" $+                                    [n | n <- names end, isStale end n, phase == AllMembers || Just n /= fmap (.m_name) (leaderOf end.ob_cluster)] === []+                            Hold _ -> counterexample "held after a switchover" (null switchAt)+                            s -> counterexample ("not a fixed point: " <> show s) False+                        .&&. case switchAt of+                            [] -> ok+                            [i] -> counterexample "a replica restarted before the switchover is not done" ([r | Restart r <- take i trace, Just r == firstLeader] === [])+                            _ -> counterexample "more than one switchover" False+    , testProperty "killed after any step, the next pass finishes the same way (no progress is remembered)" $+        forAll genWorld $ \(phase, w) ->+            let (trace, end) = simulate phase w+             in conjoin'+                    [ simulate phase mid === (drop k trace, end)+                    | k <- [0 .. length trace]+                    , let mid = applyAll (take k trace) w+                    ]+    , testProperty "/patroni parses what Patroni would render" $+        forAll genStatus $ \s -> parseMemberStatus (LByteString.toStrict (encode (renderStatus "running" s))) === Right s+    , testProperty "refs are the scope's alone; notes carry the members, never the credentials" $+        forAll ((,,) <$> genWord <*> genWord <*> genWord) $ \(m, addr, cred) ->+            let c = sample{rm_members = [(Text.pack m, Text.pack addr)], rm_credentials_file = Just ("/etc/patroni" </> cred)}+                (l, r) = (ext (rollingMaintenance c), ext (rollingReplicas c))+                public = Text.unpack (Text.unlines (l.help : l.notes <> (r.help : r.notes)))+             in (l.ref === mkRef "patroni-rolling-leader" ("demo" :: Text))+                    .&&. (r.ref === mkRef "patroni-rolling-replicas" ("demo" :: Text))+                    .&&. counterexample public ((m <> " at " <> addr) `isInfixOf` public)+                    .&&. counterexample public (not (("/etc/patroni" </> cred) `isInfixOf` public))+    ]+  where+    ok = counterexample "" True+    a =/= b = counterexample (show a <> " == " <> show b) (a /= b)+    conjoin' = foldr (.&&.) ok++-------------------------------------------------------------------------------++nodeText :: [TestTree]+nodeText =+    [ testCase "the leader node depends on the replicas node, and only it is disruptive" $ do+        let top = rollingMaintenance sample+            preds = case runIdentity top.predecessors of+                Vertices xs -> xs+                _ -> []+        case preds of+            [p] -> assertEqual "it is the replicas node" (ext (rollingReplicas sample)).ref (ext p).ref+            _ -> assertFailure ("expected one predecessor, got " <> show (length preds))+        assertBool "leader disruptive" (Window.isDisruptive (ext top))+        assertBool "replicas not" (not (Window.isDisruptive (ext (rollingReplicas sample))))+    , testCase "help and notes" $ do+        let r = ext (rollingReplicas sample)+        assertEqual "help" "Patroni scope demo: restart the replicas that need it, one at a time" r.help+        assertEqual+            "notes"+            [ "members: n1 at 10.0.0.1:8008, n2 at 10.0.0.2:8008"+            , "needs a restart: pending_restart, or server_version below 170005"+            , "never restarts the member that leads"+            ]+            r.notes+    , testCase "a changed target version is a changed representative, same ref" $ do+        let a = ext (rollingMaintenance sample)+            b = ext (rollingMaintenance sample{rm_server_version = Just 170006})+        assertEqual "" a.ref b.ref+        assertBool "" (a.notes /= b.notes)+    ]++-------------------------------------------------------------------------------++-- | Shared state of the three fakes: per member (role, version, pending), and the requests seen.+data Fake = Fake+    { fMembers :: IORef (Map.Map Text (Text, Int, Bool))+    , fRequests :: IORef [(Text, Text, Maybe Value)]+    }++fakeApi :: [TestTree]+fakeApi =+    [ testCase "replicas, then a switchover, then the old leader; a second up sends nothing" $ withTempDir $ \tmp -> do+        let creds = tmp </> "rest-auth"+        writeFile creds "patroni:hunter2\n"+        withFakes [("n1", ("leader", 170004, False)), ("n2", ("replica", 170004, False)), ("n3", ("replica", 170004, True))] $ \c fake -> do+            let c' = c{rm_credentials_file = Just creds, rm_settle_seconds = 5}+                (leaderNode, replicasNode) = (ext (rollingMaintenance c'), ext (rollingReplicas c'))+            assertBool "replicas due" . isFailure =<< replicasNode.check+            replicasNode.up+            assertEqual "replicas done" Success =<< replicasNode.check+            assertBool "leader due" . isFailure =<< leaderNode.check+            leaderNode.up+            assertEqual "all done" Success =<< leaderNode.check+            reqs <- readIORef fake.fRequests+            assertEqual+                "order"+                [ ("n2", "restart", Just (object ["role" .= ("replica" :: Text)]))+                , ("n3", "restart", Just (object ["role" .= ("replica" :: Text)]))+                , ("n1", "switchover", Just (object ["leader" .= ("n1" :: Text), "candidate" .= ("n2" :: Text)]))+                , ("n1", "restart", Just (object ["role" .= ("replica" :: Text)]))+                ]+                [(m, p, b) | (m, p, b) <- reqs]+            leaderNode.up+            assertEqual "nothing more" 4 . length =<< readIORef fake.fRequests+    , testCase "a member with no declared address: Unknown, and up sends nothing" $+        withFakes [("n1", ("leader", 170004, False)), ("n2", ("replica", 170005, False))] $ \c fake -> do+            let c' = c{rm_members = [m | m <- c.rm_members, fst m == "n1"]}+            assertEqual "" Unknown =<< (ext (rollingMaintenance c')).check+            r <- try (ext (rollingMaintenance c')).up+            case r of+                Left (MaintenanceHeld why) -> assertBool (Text.unpack why) ("n2" `Text.isInfixOf` why)+                Right () -> assertFailure "up should have thrown"+            assertEqual "no request" [] =<< readIORef fake.fRequests+    , testCase "nobody listening is Unknown, not Failure" $ do+        v <- (ext (rollingMaintenance sample{rm_members = [("n1", "127.0.0.1:1")]})).check+        assertEqual "" Unknown v+    ]++{- | Three (or more) fake members on their own ports. A restart sets the+member to 170005 and clears pending; one of a leader is refused, as Patroni+does with @role: replica@. A switchover moves the leader.+-}+withFakes :: [(Text, (Text, Int, Bool))] -> (RollingMaintenance -> Fake -> IO a) -> IO a+withFakes initial act = do+    fake <- Fake <$> newIORef (Map.fromList initial) <*> newIORef []+    go fake (fmap fst initial) []+  where+    go fake [] ports = act sample{rm_members = reverse ports} fake+    go fake (n : rest) ports =+        Warp.testWithApplication (pure (app fake n)) $ \port ->+            go fake rest ((n, "127.0.0.1:" <> Text.pack (show port)) : ports)++    app :: Fake -> Text -> Wai.Application+    app fake self req respond = do+        ms <- readIORef fake.fMembers+        let members = [Member n r (Just (if r == "leader" then "running" else "streaming")) (Just 0) False | (n, (r, _, _)) <- Map.toList ms]+        case (Wai.requestMethod req, Wai.pathInfo req) of+            ("GET", ["cluster"]) -> respond (json (renderCluster members))+            ("GET", ["config"]) -> respond (json (object ["ttl" .= (30 :: Int)]))+            ("GET", ["patroni"]) -> case Map.lookup self ms of+                Just (_, v, p) -> respond (json (renderStatus "running" (MemberStatus (Just self) (Just "demo") (Just v) p)))+                Nothing -> respond (Wai.responseLBS HTTP.status503 [] "")+            ("POST", [path]) -> do+                body <- Wai.strictRequestBody req+                let auth = lookup "Authorization" (Wai.requestHeaders req)+                atomicModifyIORef' fake.fRequests (\xs -> (xs <> [(self, path, decode body)], ()))+                case (auth, path) of+                    (Just "Basic cGF0cm9uaTpodW50ZXIy", "restart")+                        | Just ("leader", _, _) <- Map.lookup self ms -> respond (Wai.responseLBS HTTP.status503 [] "restart conditions are not satisfied")+                        | otherwise -> do+                            atomicModifyIORef' fake.fMembers (\m -> (Map.adjust (\(r, _, _) -> (r, 170005, False)) self m, ()))+                            respond (Wai.responseLBS HTTP.status200 [] "restarted successfully")+                    (Just "Basic cGF0cm9uaTpodW50ZXIy", "switchover") | Just (Object o) <- decode body, Just (String cand) <- KeyMap.lookup "candidate" o -> do+                        atomicModifyIORef' fake.fMembers (\m -> (Map.mapWithKey (\k (_, v, p) -> (if k == cand then "leader" else "replica", v, p)) m, ()))+                        respond (Wai.responseLBS HTTP.status200 [] "Successfully switched over")+                    _ -> respond (Wai.responseLBS HTTP.status401 [] "no auth header received")+            _ -> respond (Wai.responseLBS HTTP.status404 [] "")+    json v = Wai.responseLBS HTTP.status200 [(HTTP.hContentType, "application/json")] (encode (v :: Value))++-------------------------------------------------------------------------------++target :: Maybe Int+target = Just 170005++sample :: RollingMaintenance+sample = RollingMaintenance "demo" [("n1", "10.0.0.1:8008"), ("n2", "10.0.0.2:8008")] target Nothing defaultSettleSeconds++ext :: Op -> Extension+ext o = case o.node of+    Actions a -> a.extension+    Actionless -> error "no actions"++fresh :: Int -> MemberStatus+fresh v = MemberStatus Nothing (Just "demo") (Just v) False++-- | The first name leads; the second list is who is stale (on 170004, others on 170005).+world :: [Text] -> [Text] -> Bool -> Observation+world ns staleOnes sync =+    Observation+        (ClusterView (Just "demo") [Member n (if i == 0 then "leader" else "replica") (Just (if i == 0 then "running" else "streaming")) (Just 0) False | (i, n) <- zip [0 :: Int ..] ns] sync)+        [(n, Right (MemberStatus (Just n) (Just "demo") (Just (if n `elem` staleOnes then 170004 else 170005)) False)) | n <- ns]++onMember :: Text -> (Member -> Member) -> Observation -> Observation+onMember n f w = w{ob_cluster = w.ob_cluster{cv_members = [if m.m_name == n then f m else m | m <- w.ob_cluster.cv_members]}}++lagged :: [(Text, Integer)] -> Observation -> Observation+lagged ls w = foldr (\(n, l) -> onMember n (\m -> m{m_lag = Just l})) w ls++roled :: Text -> Text -> Observation -> Observation+roled n r = onMember n (\m -> m{m_role = r})++stated :: Text -> Maybe Text -> Observation -> Observation+stated n s = onMember n (\m -> m{m_state = s})++nofailover :: Text -> Observation -> Observation+nofailover n = onMember n (\m -> m{m_nofailover = True})++names :: Observation -> [Text]+names w = fmap (.m_name) w.ob_cluster.cv_members++isStale :: Observation -> Text -> Bool+isStale w n = case lookup n w.ob_members of+    Just (Right s) -> stale target s == Right True+    _ -> False++-- | What the step would do to the cluster, had Patroni done it at once.+apply :: Step -> Observation -> Observation+apply (Restart r) w = w{ob_members = [(n, if n == r then fmap (\s -> s{ms_pending_restart = False, ms_server_version = target}) s else s) | (n, s) <- w.ob_members]}+apply (SwitchOverTo from to) w =+    onMember from (\m -> m{m_role = "replica", m_state = Just "streaming"}) $+        onMember to (\m -> m{m_role = "leader", m_state = Just "running"}) w+apply _ w = w++applyAll :: [Step] -> Observation -> Observation+applyAll ss w = foldl (flip apply) w ss++-- | Steps taken until Finished or Hold (bounded), and the world they leave.+simulate :: Phase -> Observation -> ([Step], Observation)+simulate phase = go (50 :: Int)+  where+    go 0 w = ([], w)+    go k w = case step phase "demo" target w of+        Finished -> ([], w)+        Hold _ -> ([], w)+        s -> let (rest, end) = go (k - 1) (apply s w) in (s : rest, end)++-- | A cluster where everyone answers and is healthy, which members are stale varies.+genWorld :: Gen (Phase, Observation)+genWorld = do+    ns <- nub <$> listOf1 (elements ["n0", "n1", "n2", "n3", "n4"])+    ms <- forM (zip [0 :: Int ..] ns) $ \(i, n) ->+        if i == 0+            then Member n <$> elements leaderRoles <*> pure (Just "running") <*> pure Nothing <*> pure False+            else+                Member n+                    <$> elements ["replica", "replica", "sync_standby", "quorum_standby"]+                    <*> elements [Just "streaming", Just "running", Just "in archive recovery"]+                    <*> oneof [pure Nothing, Just . fromIntegral <$> chooseInt (0, 1000)]+                    <*> frequency [(4, pure False), (1, pure True)]+    sts <- forM ns $ \n -> (,) n . Right <$> (MemberStatus (Just n) (Just "demo") <$> (Just <$> elements [170004, 170005]) <*> frequency [(4, pure False), (1, pure True)])+    sync <- elements [False, True]+    phase <- elements [ReplicasOnly, AllMembers]+    pure (phase, Observation (ClusterView (Just "demo") ms sync) sts)++genStatus :: Gen MemberStatus+genStatus =+    MemberStatus+        <$> oneof [pure Nothing, Just . Text.pack <$> genWord]+        <*> oneof [pure Nothing, Just . Text.pack <$> genWord]+        <*> oneof [pure Nothing, Just <$> chooseInt (90000, 180000)]+        <*> elements [False, True]++genWord :: Gen String+genWord = listOf1 (elements (['a' .. 'z'] <> ['0' .. '9']))++renderStatus :: Text -> MemberStatus -> Value+renderStatus st s =+    Object . KeyMap.fromList $+        [("state", String st), ("pending_restart", Bool s.ms_pending_restart), ("timeline", Number 2)]+            <> maybe [] (\v -> [("server_version", Number (fromIntegral v))]) s.ms_server_version+            <> [ ( Key.fromText "patroni"+                 , Object . KeyMap.fromList $+                    [("version", String "4.0.7")]+                        <> maybe [] (\n -> [("name", String n)]) s.ms_name+                        <> maybe [] (\sc -> [("scope", String sc)]) s.ms_scope+                 )+               ]++renderCluster :: [Member] -> Value+renderCluster ms = object ["scope" .= ("demo" :: Text), "members" .= fmap member ms]+  where+    member :: Member -> Value+    member m =+        object+            [ "name" .= m.m_name+            , "role" .= m.m_role+            , "state" .= m.m_state+            , "lag" .= m.m_lag+            , "host" .= ("127.0.0.1" :: Text)+            , "port" .= (5432 :: Int)+            ]++isFailure :: CheckResult -> Bool+isFailure (Failure _) = True+isFailure _ = False
+ test/Test/PatroniSpec.hs view
@@ -0,0 +1,121 @@+{-# LANGUAGE OverloadedStrings #-}++{- | Layer 0 coverage for "Salmon.Builtin.Nodes.Patroni" and the masked-unit+node it leans on: rendering, the REST verdict, and what the check reads from+@systemctl is-enabled@. The three-VM case needs the Patroni harness.+-}+module Test.PatroniSpec (tests) where++import Data.ByteString (ByteString)+import Data.List (isInfixOf)+import Data.Text (Text)+import qualified Data.Text as Text+import qualified Data.Text.Encoding as Text+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, assertEqual, assertFailure, testCase)++import Salmon.Actions.UpDown (CheckResult (..))+import Salmon.Builtin.Nodes.Etcd (TlsFiles (..))+import Salmon.Builtin.Nodes.Patroni+import Salmon.Builtin.Nodes.Systemd (interpretIsEnabled)+import Salmon.Builtin.Extension (ignoreTrack)+import Salmon.Reporter (silent)+import Test.SharedDirSpec (assertOnSharedDir)++tests :: TestTree+tests =+    testGroup+        "Salmon.Builtin.Nodes.Patroni"+        [ testCase "the config declares the scope, the member and the common layout" $ do+            has "scope: \"demo\""+            has "name: \"n2\""+            has "data_dir: \"/var/lib/postgresql/16/main\""+            has "config_dir: \"/etc/postgresql/16/main\""+            has "bin_dir: \"/usr/lib/postgresql/16/bin\""+        , testCase "etcd is v3, with its TLS paths as given" $ do+            has "etcd3:"+            has "hosts: \"10.0.0.1:2379,10.0.0.2:2379\""+            has "cacert: \"/pki/ca.crt\""+            assertBool "no v2 section" (not ("\netcd:" `isInfixOf` rendered))+        , testCase "plain etcd has no TLS keys" $+            assertBool "" (not ("cacert" `isInfixOf` Text.unpack (renderPatroni cfg{pat_etcd_tls = Nothing})))+        , testCase "no credential is rendered" $ do+            assertBool "" (not ("password" `isInfixOf` rendered))+            assertBool "" (not ("authentication" `isInfixOf` rendered))+        , testCase "the render is sensitive to a change" $+            assertBool "" (renderPatroni cfg /= renderPatroni cfg{pat_name = "n3"})+        , testCase "bootstrap parameters carry wal_log_hints and the hba lines" $ do+            has "wal_log_hints: \"on\""+            has "- \"host replication repl 10.0.0.0/24 scram-sha-256\""+        , testCase "pg_hba is local configuration, which a replica reads too, not bootstrap's" $ do+            let (bootstrap, local) = breakOn "\npostgresql:\n" rendered+            assertBool bootstrap (not ("pg_hba" `isInfixOf` bootstrap))+            assertBool local ("  pg_hba:\n    - \"host replication repl" `isInfixOf` local)+        , testCase "the Debian unit to mask is the cluster's own" $+            assertEqual "" "postgresql@16-main.service" (debianClusterUnit cfg)+        , testCase "running and healthy is Success, whatever the role" $ do+            assertEqual "leader" Success (verdict 200 (body "running" "master"))+            assertEqual "replica" Success (verdict 200 (body "running" "replica"))+        , testCase "running while /health fails is a Failure" $+            assertBool "" (isFailure (verdict 503 (body "running" "replica")))+        , testCase "transitional states are Unknown, not Failure" $ do+            assertEqual "starting" Unknown (verdict 503 (body "starting" "replica"))+            assertEqual "restarting" Unknown (verdict 503 (body "restarting" "master"))+        , testCase "an uninitialised member is Unknown" $+            assertEqual "" Unknown (verdict 503 (body "stopped" "uninitialized"))+        , testCase "stopped and crashed are Failures naming the state" $ do+            assertBool "" (isFailure (verdict 503 (body "stopped" "replica")))+            case verdict 503 (body "crashed" "replica") of+                Failure why -> assertBool (Text.unpack why) ("crashed" `isInfixOf` Text.unpack why)+                other -> assertFailure (show other)+        , testCase "another scope is a Failure" $+            assertBool "" (isFailure (interpretMember "other" 200 (body "running" "replica")))+        , testCase "pending_restart does not change the verdict" $+            assertEqual "" Success (verdict 200 "{\"state\":\"running\",\"role\":\"replica\",\"pending_restart\":true,\"patroni\":{\"scope\":\"demo\"}}")+        , testCase "garbage is a Failure, not an exception" $+            assertBool "" (isFailure (verdict 200 "nope"))+        , testCase "is-enabled: only masked is satisfied" $ do+            assertEqual "masked" Success (interpretIsEnabled "masked\n")+            assertBool "disabled" (isFailure (interpretIsEnabled "disabled\n"))+            assertBool "enabled" (isFailure (interpretIsEnabled "enabled\n"))+            assertBool "runtime mask" (isFailure (interpretIsEnabled "masked-runtime\n"))+            assertEqual "nothing said" Unknown (interpretIsEnabled "")+        , testCase "patroni.yml stands on a shared configuration directory, which also holds the pre-provisioned secrets" $+            assertOnSharedDir "/etc/patroni" (patroniMember silent ignoreTrack ignoreTrack cfg)+        ]+  where+    rendered = Text.unpack (renderPatroni cfg)+    has s = assertBool (s <> " in:\n" <> rendered) (s `isInfixOf` rendered)+    verdict = interpretMember "demo"+    isFailure (Failure _) = True+    isFailure _ = False+    breakOn needle hay = case Text.breakOn needle (Text.pack hay) of+        (a, b) -> (Text.unpack a, Text.unpack b)++body :: Text -> Text -> ByteString+body st role =+    Text.encodeUtf8 ("{\"state\":\"" <> st <> "\",\"role\":\"" <> role <> "\",\"patroni\":{\"scope\":\"demo\"}}")++cfg :: PatroniConfig+cfg =+    PatroniConfig+        { pat_scope = "demo"+        , pat_namespace = "/service/"+        , pat_name = "n2"+        , pat_etcd_hosts = ["10.0.0.1:2379", "10.0.0.2:2379"]+        , pat_etcd_tls = Just (TlsFiles "/pki/ca.crt" "/pki/c.crt" "/pki/c.key")+        , pat_rest_listen = "0.0.0.0:8008"+        , pat_rest_connect_address = "10.0.0.2:8008"+        , pat_pg_version = "16"+        , pat_pg_cluster = "main"+        , pat_pg_listen = "0.0.0.0:5432"+        , pat_pg_connect_address = "10.0.0.2:5432"+        , pat_pg_hba = ["host replication repl 10.0.0.0/24 scram-sha-256"]+        , pat_bootstrap_parameters = defaultBootstrapParameters+        , pat_config_dir = "/etc/patroni"+        , pat_secrets_file = "/etc/patroni/secrets.yml"+        , pat_user = "postgres"+        , pat_binary = "/usr/bin/patroni"+        , pat_ready_timeout_seconds = 60+        , pat_archive = Nothing+        }
test/Test/PatroniVms.hs view
@@ -15,7 +15,8 @@ history. 'withPatroniVms' therefore /normalizes/ each guest on the way in ('resetGuest') instead of assuming a blank machine: it stops the three services and removes what they persist (etcd's data, Patroni's cluster-directory, the Postgres cluster the package created). It starts nothing:+directory, the Postgres cluster the package created, pgBackRest's+repository). It starts nothing: what runs where is the scenario's declaration. -} module Test.PatroniVms (@@ -43,9 +44,14 @@     | n <- [1 .. 3 :: Int]     ] --- | The guests' addresses on the shared test bridge, in the order of 'patroniRootfs'.+{- | The guests' addresses on the shared test bridge, in the order of+'patroniRootfs'. Their own, not 'testVmAddr'..'testVmAddr3': the serialized+group keeps one suite's guests apart, but the host is shared, and a guest+another run left on @10.99.0.2@ would answer for this one (failing its ssh,+whose CA is per boot) rather than collide visibly.+-} patroniAddrs :: [Text]-patroniAddrs = [testVmAddr, testVmAddr2, testVmAddr3]+patroniAddrs = ["10.99.0.11", "10.99.0.12", "10.99.0.13"]  {- | Skips loudly rather than failing when the machine cannot run these: qemu, the bridge privileges, and the three rootfses.@@ -101,6 +107,8 @@                 , "systemctl disable patroni haproxy etcd 2>/dev/null || true;"                 , "pkill -u postgres 2>/dev/null || true;"                 , "rm -rf /var/lib/etcd/* /var/lib/postgresql/*/main /etc/postgresql/*/main;"+                , -- a stanza holds the system identifier of the cluster that made it+                  "rm -rf /var/lib/pgbackrest/* /var/log/pgbackrest/*;"                 , "rm -f /etc/patroni/*.yml /etc/patroni.yml"                 ]             ]
+ test/Test/PgBackRestSpec.hs view
@@ -0,0 +1,427 @@+{-# LANGUAGE OverloadedStrings #-}++{- | "Salmon.Builtin.Nodes.PgBackRest" at two layers.++Layer 0 ('tests'): the configuration, the command lines, what Patroni is+handed, and the verdicts drawn from @pgbackrest info --output=json@.++Layer 2 ('sandboxTests'): the same rendered words run for real in a disposable+Debian container holding a Postgres and a pgBackRest. It shows what Layer 0+cannot: that the archive they describe restores to a point in time, and that a+second data directory built from it comes up as a streaming standby. The+filesystem nodes write on the machine salmon runs on, so the container is+driven with the module's pure output rather than through its 'Op's; and there+is no Patroni in it, so "Patroni builds a replica from the archive" is /not/+what this shows -- it shows the command Patroni is handed doing that job.+-}+module Test.PgBackRestSpec (tests, sandboxTests) where++import Control.Concurrent (threadDelay)+import Control.Monad (unless)+import Data.ByteString (ByteString)+import qualified Data.ByteString.Char8 as C8+import Data.List (isInfixOf)+import qualified Data.Text as Text+import System.Exit (ExitCode (..))+import System.Process (readProcessWithExitCode)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, assertEqual, assertFailure, testCase)++import Salmon.Actions.UpDown (CheckResult (..))+import qualified Salmon.Builtin.Nodes.CronTask as Cron+import qualified Salmon.Builtin.Nodes.Patroni as Patroni+import Salmon.Builtin.Nodes.PgBackRest+import qualified Salmon.Builtin.Nodes.Podman as Podman+import Test.Harness (podmanExec_, requireExecutable, withContainer)+import Test.SharedDirSpec (assertOnSharedDir)+import Salmon.Builtin.Extension (ignoreTrack)++tests :: TestTree+tests =+    testGroup+        "Salmon.Builtin.Nodes.PgBackRest"+        [ testCase "the configuration file stands on a shared directory, which also holds conf.d" $+            assertOnSharedDir "/etc/pgbackrest" (configuration ignoreTrack cfg)+        , testCase "the configuration names the repository, the retention and the cluster" $ do+            has "[global]\nrepo1-type=posix\nrepo1-path=/srv/archive\nrepo1-retention-full=2\nlog-path=/var/log/pgbackrest\n"+            has "[demo]\npg1-path=/var/lib/postgresql/16/main\npg1-port=5432\n"+        , testCase "an S3 repository is rendered without any key" $ do+            let out = Text.unpack (renderConf s3cfg)+            assertBool out ("repo1-type=s3\nrepo1-path=/pgbackrest\nrepo1-s3-bucket=wal\nrepo1-s3-endpoint=s3.example.net\nrepo1-s3-region=eu\nrepo1-s3-uri-style=path\n" `isInfixOf` out)+            assertBool out (not ("key" `isInfixOf` out))+        , testCase "extra options are passed through, in their section" $ do+            let out = Text.unpack (renderConf cfg{pbr_global = [("compress-type", "zst")], pbr_stanza_options = [("pg1-socket-path", "/run/pg")]})+            assertBool out ("compress-type=zst\n\n[demo]" `isInfixOf` out)+            assertBool out ("pg1-port=5432\npg1-socket-path=/run/pg\n" `isInfixOf` out)+        , testCase "every command names the configuration, the include directory and the stanza" $ do+            assertEqual "" ["--config=/etc/pgbackrest/pgbackrest.conf", "--stanza=demo", "stanza-create"] (stanzaCreateArgs cfg)+            assertEqual+                ""+                ["--config=/etc/pgbackrest/pgbackrest.conf", "--config-include-path=/etc/pgbackrest/conf.d", "--stanza=demo", "--type=incr", "backup"]+                (backupArgs s3cfg Incremental)+        , testCase "archive and restore commands leave %p and %f to Postgres" $ do+            assertEqual "" "/usr/bin/pgbackrest --config=/etc/pgbackrest/pgbackrest.conf --stanza=demo archive-push %p" (archiveCommand cfg)+            assertEqual "" "/usr/bin/pgbackrest --config=/etc/pgbackrest/pgbackrest.conf --stanza=demo archive-get %f \"%p\"" (restoreCommand cfg)+            assertEqual "" [("archive_mode", "on"), ("archive_command", archiveCommand cfg)] (archiveParameters cfg)+        , testCase "a restore never carries --delta or --force, whatever the target" $+            mapM_+                (\t -> assertBool (show t) (not (any (`elem` ["--delta", "--force"]) (restoreArgs cfg t))))+                [Latest, Immediate, AtTime "2026-10-02 14:03:00+00", AtLsn "0/3000000", AtXid "740", AtName "before-migration"]+        , testCase "a point-in-time restore names its target and promotes" $ do+            assertEqual "" (commonArgs cfg <> ["restore"]) (restoreArgs cfg Latest)+            assertEqual+                ""+                (commonArgs cfg <> ["--type=time", "--target=2026-10-02 14:03:00+00", "--target-action=promote", "restore"])+                (restoreArgs cfg (AtTime "2026-10-02 14:03:00+00"))+            assertEqual "" (commonArgs cfg <> ["--type=immediate", "--target-action=promote", "restore"]) (restoreArgs cfg Immediate)+        , testCase "a command line quotes what a shell would split" $ do+            assertEqual "" "'2026-10-02 14:03:00+00'" (shellWord "2026-10-02 14:03:00+00")+            assertEqual "" "'it'\\''s'" (shellWord "it's")+            assertEqual "" "--stanza=demo" (shellWord "--stanza=demo")+            assertBool "" ("'--target=2026-10-02 14:03:00+00'" `isInfixOf` Text.unpack (commandLine cfg (restoreArgs cfg (AtTime "2026-10-02 14:03:00+00"))))+        , testCase "sudo is used only when a user is declared" $ do+            assertBool "" (not ("sudo" `isInfixOf` show (process cfg (infoArgs cfg))))+            assertBool "" ("\"sudo\" [\"-u\",\"postgres\",\"/usr/bin/pgbackrest\"" `isInfixOf` show (process cfg{pbr_run_as = Just "postgres"} (infoArgs cfg)))+        , testCase "Patroni is handed a delta restore first and a base backup as the fallback" $ do+            let yml = Text.unpack (Patroni.renderPatroni (patroniCfg (Just (patroniArchive cfg))))+                hasY s = assertBool (s <> " in:\n" <> yml) (s `isInfixOf` yml)+            hasY "  create_replica_methods:\n    - \"pgbackrest\"\n    - \"basebackup\"\n"+            hasY "  pgbackrest:\n    command: \"/usr/bin/pgbackrest --config=/etc/pgbackrest/pgbackrest.conf --stanza=demo --delta restore\"\n    keep_data: true\n    no_params: true\n"+            hasY "  recovery_conf:\n    restore_command: \"/usr/bin/pgbackrest --config=/etc/pgbackrest/pgbackrest.conf --stanza=demo archive-get %f \\\"%p\\\"\"\n"+            hasY "  parameters:\n    archive_mode: \"on\"\n    archive_command: \"/usr/bin/pgbackrest --config=/etc/pgbackrest/pgbackrest.conf --stanza=demo archive-push %p\"\n"+            assertBool "no custom bootstrap unless asked" (not ("method:" `isInfixOf` yml))+        , testCase "without an archive, patroni.yml says nothing about one" $ do+            let yml = Text.unpack (Patroni.renderPatroni (patroniCfg Nothing))+            assertBool yml (not ("create_replica_methods" `isInfixOf` yml))+            assertBool yml (not ("archive" `isInfixOf` yml))+        , testCase "a recovering cluster bootstraps from the archive, to the target" $ do+            let yml = Text.unpack (Patroni.renderPatroni (patroniCfg (Just (patroniArchiveRecovering cfg (AtName "before-migration")))))+            assertBool yml ("  method: \"pgbackrest\"\n  pgbackrest:\n    command: \"/usr/bin/pgbackrest --config=/etc/pgbackrest/pgbackrest.conf --stanza=demo --type=name --target=before-migration --target-action=promote restore\"\n    keep_existing_recovery_conf: true\n    no_params: true\n" `isInfixOf` yml)+        , testCase "an archive never puts a credential or a primary in patroni.yml" $ do+            let yml = Text.unpack (Patroni.renderPatroni (patroniCfg (Just (patroniArchive s3cfg))))+            mapM_ (\w -> assertBool w (not (w `isInfixOf` yml))) ["password", "s3-key", "primary_conninfo"]+        , testCase "info: a stanza with a backup satisfies both checks" $ do+            assertEqual "" Success (interpretStanza "demo" infoOk)+            assertEqual "" Success (interpretHasBackup "demo" infoOk)+            assertEqual+                ""+                (Right [StanzaInfo "demo" 0 "ok" [BackupInfo "20261002-140000F" "full" (Just 1790949615)]])+                (parseInfo infoOk)+        , testCase "info: a stanza without a backup exists, and has no backup" $ do+            assertEqual "" Success (interpretStanza "demo" infoNoBackup)+            assertBool "" (isFailure (interpretHasBackup "demo" infoNoBackup))+        , testCase "info: a missing stanza fails both, naming pgBackRest's reason" $ do+            case interpretStanza "demo" infoMissing of+                Failure why -> assertBool (Text.unpack why) ("missing stanza path" `isInfixOf` Text.unpack why)+                other -> assertFailure (show other)+            assertBool "" (isFailure (interpretHasBackup "demo" infoMissing))+        , testCase "info: another stanza's backups do not count" $ do+            assertBool "" (isFailure (interpretStanza "other" infoOk))+            assertBool "" (isFailure (interpretHasBackup "other" infoOk))+            assertBool "" (isFailure (interpretStanza "demo" "[]"))+        , testCase "info: garbage is a Failure, not an exception" $+            assertBool "" (isFailure (interpretStanza "demo" "nope"))+        , testCase "the scheduled command gates itself, and exits 0 when it is not this member's turn" $ do+            let s = BackupSchedule "nightly" Incremental (Cron.dailyAt "3" "17") "/usr/local/bin/pgbackrest-nightly.sh" (Just "http://127.0.0.1:8008/primary")+            assertEqual+                ""+                "#!/bin/bash\nset -euo pipefail\ncurl -sf -o /dev/null http://127.0.0.1:8008/primary || exit 0\nexec /usr/bin/pgbackrest --config=/etc/pgbackrest/pgbackrest.conf --stanza=demo --type=incr backup\n"+                (backupScript cfg s)+            assertBool "" (not ("curl" `isInfixOf` Text.unpack (backupScript cfg s{bs_gate_url = Nothing})))+        ]+  where+    rendered = Text.unpack (renderConf cfg)+    has s = assertBool (s <> " in:\n" <> rendered) (s `isInfixOf` rendered)++isFailure :: CheckResult -> Bool+isFailure (Failure _) = True+isFailure _ = False++cfg :: PgBackRestConfig+cfg =+    PgBackRestConfig+        { pbr_stanza = "demo"+        , pbr_pg_path = "/var/lib/postgresql/16/main"+        , pbr_pg_port = 5432+        , pbr_repo = PosixRepo "/srv/archive"+        , pbr_retention_full = 2+        , pbr_global = []+        , pbr_stanza_options = []+        , pbr_config_file = "/etc/pgbackrest/pgbackrest.conf"+        , pbr_secrets_file = Nothing+        , pbr_log_path = "/var/log/pgbackrest"+        , pbr_user = "postgres"+        , pbr_run_as = Nothing+        , pbr_binary = "/usr/bin/pgbackrest"+        }++s3cfg :: PgBackRestConfig+s3cfg =+    cfg+        { pbr_repo = S3Repo "wal" "s3.example.net" "eu" "/pgbackrest" True+        , pbr_secrets_file = Just "/etc/pgbackrest/conf.d/secrets.conf"+        }++patroniCfg :: Maybe Patroni.Archive -> Patroni.PatroniConfig+patroniCfg archive =+    Patroni.PatroniConfig+        { Patroni.pat_scope = "demo"+        , Patroni.pat_namespace = "/service/"+        , Patroni.pat_name = "n2"+        , Patroni.pat_etcd_hosts = ["10.0.0.1:2379"]+        , Patroni.pat_etcd_tls = Nothing+        , Patroni.pat_rest_listen = "0.0.0.0:8008"+        , Patroni.pat_rest_connect_address = "10.0.0.2:8008"+        , Patroni.pat_pg_version = "16"+        , Patroni.pat_pg_cluster = "main"+        , Patroni.pat_pg_listen = "0.0.0.0:5432"+        , Patroni.pat_pg_connect_address = "10.0.0.2:5432"+        , Patroni.pat_pg_hba = []+        , Patroni.pat_bootstrap_parameters = Patroni.defaultBootstrapParameters+        , Patroni.pat_config_dir = "/etc/patroni"+        , Patroni.pat_secrets_file = "/etc/patroni/secrets.yml"+        , Patroni.pat_user = "postgres"+        , Patroni.pat_binary = "/usr/bin/patroni"+        , Patroni.pat_ready_timeout_seconds = 60+        , Patroni.pat_archive = archive+        }++-- The shapes below are pgBackRest 2.x's @info --output=json@, cut down to what is read plus a few fields that are not.+infoOk, infoNoBackup, infoMissing :: ByteString+infoOk =+    "[{\"archive\":[{\"database\":{\"id\":1},\"id\":\"16-1\",\"max\":\"000000010000000000000005\",\"min\":\"000000010000000000000002\"}],\"backup\":[{\"label\":\"20261002-140000F\",\"type\":\"full\",\"timestamp\":{\"start\":1790949600,\"stop\":1790949615}}],\"name\":\"demo\",\"status\":{\"code\":0,\"lock\":{\"backup\":{\"held\":false}},\"message\":\"ok\"}}]"+infoNoBackup =+    "[{\"archive\":[],\"backup\":[],\"name\":\"demo\",\"status\":{\"code\":2,\"message\":\"no valid backups\"}}]"+infoMissing =+    "[{\"archive\":[],\"backup\":[],\"name\":\"demo\",\"status\":{\"code\":1,\"message\":\"missing stanza path\"}}]"++-------------------------------------------------------------------------------++sandboxTests :: TestTree+sandboxTests =+    testGroup+        "Salmon.Builtin.Nodes.PgBackRest (Layer 2, a real archive in a podman sandbox)"+        [ testCase "archives, restores to a point in time, and builds a standby from the archive" archiveRoundTrip+        , testCase "Patroni builds its second member from the archive" patroniReplicaFromArchive+        ]++{- | Two Patroni members and one etcd in one container, no systemd: each+member is started on the @patroni.yml@ 'Patroni.renderPatroni' renders with+'patroniArchive' declared. The second member has nothing but its+configuration, and must come up as a replica that Patroni made with+pgBackRest -- which its own log is asked about, since a base backup from the+leader would produce the same replica and prove nothing.+-}+patroniReplicaFromArchive :: IO ()+patroniReplicaFromArchive = requireExecutable "podman" $+    withContainer (Podman.Image "debian:trixie") (Podman.PortMapping "15440" "5432" Podman.TCPPort) $ \cid -> do+        podmanExec_ cid ["apt-get", "update", "-qq"]+        podmanExec_ cid ["bash", "-c", "DEBIAN_FRONTEND=noninteractive apt-get install -y -qq postgresql pgbackrest patroni python3-etcd etcd-server curl"]+        version <- head . lines <$> sh cid "root" "ls /usr/lib/postgresql | sort -n | tail -n1"+        -- Debian's own cluster would hold port 5432 and is nobody's here+        sh_ cid "root" ("pg_dropcluster --stop " <> version <> " main || true")+        let archiveOf name port =+                cfg+                    { pbr_pg_path = "/var/lib/postgresql/" <> version <> "/" <> name+                    , pbr_pg_port = port+                    , pbr_repo = PosixRepo "/var/lib/pgbackrest-test"+                    , pbr_log_path = "/var/log/pgbackrest-test"+                    , pbr_config_file = "/etc/pgbackrest-test/" <> name <> ".conf"+                    }+            member name port rest =+                (patroniCfg (Just (patroniArchive (archiveOf name port))))+                    { Patroni.pat_name = Text.pack name+                    , Patroni.pat_etcd_hosts = ["127.0.0.1:2379"]+                    , Patroni.pat_rest_listen = "127.0.0.1:" <> Text.pack (show rest)+                    , Patroni.pat_rest_connect_address = "127.0.0.1:" <> Text.pack (show rest)+                    , Patroni.pat_pg_version = Text.pack version+                    , Patroni.pat_pg_cluster = Text.pack name+                    , Patroni.pat_pg_listen = "127.0.0.1:" <> Text.pack (show port)+                    , Patroni.pat_pg_connect_address = "127.0.0.1:" <> Text.pack (show port)+                    , Patroni.pat_pg_hba = ["local all all peer", "host all all 127.0.0.1/32 scram-sha-256", "host replication replicator 127.0.0.1/32 scram-sha-256"]+                    , Patroni.pat_config_dir = "/etc/patroni-" <> name+                    , Patroni.pat_secrets_file = "/etc/patroni-" <> name <> "/secrets.yml"+                    }+            lay name port rest = do+                let m = member name port rest+                sh_ cid "root" ("mkdir -p " <> Patroni.pat_config_dir m <> " " <> Patroni.pgConfigDir m <> " && touch " <> Patroni.pgConfigDir m <> "/postgresql.conf && chown -R postgres " <> Patroni.pgConfigDir m)+                write cid (Patroni.patroniFile m) (Text.unpack (Patroni.renderPatroni m))+                -- the pre-provisioned fragment: the one place a credential is+                write cid (Patroni.pat_secrets_file m) secretsFragment+                write cid (pbr_config_file (archiveOf name port)) (Text.unpack (renderConf (archiveOf name port)))+            start name =+                sh_ cid "postgres" ("nohup patroni /etc/patroni-" <> name <> " > /tmp/patroni-" <> name <> ".log 2>&1 &")+            verdict rest = do+                (_, health, _) <- argv cid "postgres" ["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", "http://127.0.0.1:" <> show rest <> "/health"]+                (_, body, _) <- argv cid "postgres" ["curl", "-s", "http://127.0.0.1:" <> show rest <> "/patroni"]+                pure (Patroni.interpretMember "demo" (if all (`elem` ['0' .. '9']) health && not (null health) then read health else 0) (C8.pack body), body)+            waitMember name rest = go (90 :: Int)+              where+                go 0 = do+                    (v, body) <- verdict rest+                    logs <- sh cid "root" ("tail -n 60 /tmp/patroni-" <> name <> ".log")+                    assertFailure ("member " <> name <> " never became healthy: " <> show v <> "\n" <> body <> "\n" <> logs)+                go n = do+                    (v, _) <- verdict rest+                    unless (v == Success) (threadDelay 1000000 >> go (n - 1))+            a = archiveOf "a" 5432+            pgbackrest args = do+                (code, out, err) <- argv cid "postgres" (pbr_binary a : fmap Text.unpack args)+                assertEqual (show args <> " failed:\n" <> out <> "\n" <> err) ExitSuccess code++        sh_ cid "root" "mkdir -p /etc/pgbackrest-test /var/lib/pgbackrest-test /var/log/pgbackrest-test /var/lib/etcd-test && chown postgres /var/lib/pgbackrest-test /var/log/pgbackrest-test /var/lib/etcd-test"+        sh_ cid "postgres" "nohup etcd --data-dir /var/lib/etcd-test > /tmp/etcd.log 2>&1 &"+        lay "a" 5432 8008+        lay "b" 5433 8009++        -- the first member: Patroni initialises it, and it archives from its first segment+        start "a"+        waitMember "a" 8008+        waitFor cid 5432 "SHOW archive_mode" "on"+        pgbackrest (stanzaCreateArgs a)+        pgbackrest (backupArgs a Full)+        _ <- psql cid 5432 "CREATE TABLE canary (v text); INSERT INTO canary VALUES ('archived')"++        -- the second: nothing but configuration+        start "b"+        waitMember "b" 8009+        logB <- sh cid "root" "cat /tmp/patroni-b.log"+        assertBool ("Patroni did not make the replica with pgbackrest:\n" <> logB) ("replica has been created using pgbackrest" `isInfixOf` logB)+        waitFor cid 5433 "SELECT pg_is_in_recovery()" "t"+        waitFor cid 5433 "SELECT string_agg(v, ',') FROM canary" "archived"+        waitFor cid 5432 "SELECT count(*) FROM pg_stat_replication WHERE state = 'streaming'" "1"+  where+    secretsFragment =+        unlines+            [ "postgresql:"+            , "  authentication:"+            , "    superuser:"+            , "      username: postgres"+            , "      password: not-a-secret-1"+            , "    replication:"+            , "      username: replicator"+            , "      password: not-a-secret-2"+            ]++archiveRoundTrip :: IO ()+archiveRoundTrip = requireExecutable "podman" $+    withContainer (Podman.Image "debian:trixie") (Podman.PortMapping "15439" "5432" Podman.TCPPort) $ \cid -> do+        podmanExec_ cid ["apt-get", "update", "-qq"]+        podmanExec_ cid ["bash", "-c", "DEBIAN_FRONTEND=noninteractive apt-get install -y -qq postgresql pgbackrest"]+        version <- head . lines <$> sh cid "root" "ls /usr/lib/postgresql | sort -n | tail -n1"+        let c =+                cfg+                    { pbr_pg_path = "/var/lib/postgresql/" <> version <> "/main"+                    , pbr_repo = PosixRepo "/var/lib/pgbackrest-test"+                    , pbr_log_path = "/var/log/pgbackrest-test"+                    , pbr_config_file = "/etc/pgbackrest-test/pgbackrest.conf"+                    }+            -- the same archive seen from a second data directory: what a second member's configuration is+            c2 = c{pbr_pg_path = "/var/lib/postgresql/" <> version <> "/standby", pbr_pg_port = 5433, pbr_config_file = "/etc/pgbackrest-test/standby.conf"}+            pgbackrest = argv cid "postgres" . (pbr_binary c :) . fmap Text.unpack+            ctl cluster verb = sh_ cid "postgres" ("pg_ctlcluster " <> version <> " " <> cluster <> " " <> verb)+            info = do+                (code, out, err) <- pgbackrest (infoArgs c)+                assertEqual ("info failed: " <> err) ExitSuccess code+                pure (C8.pack out)+            run_ what args = do+                (code, out, err) <- pgbackrest args+                assertEqual (what <> " failed:\n" <> out <> "\n" <> err) ExitSuccess code++        -- what `configuration` would lay down, and what `archiveParameters` asks of Postgres+        sh_ cid "root" "mkdir -p /etc/pgbackrest-test /var/lib/pgbackrest-test /var/log/pgbackrest-test && chown postgres /var/lib/pgbackrest-test /var/log/pgbackrest-test"+        write cid (pbr_config_file c) (Text.unpack (renderConf c))+        write cid (pbr_config_file c2) (Text.unpack (renderConf c2))+        write+            cid+            ("/etc/postgresql/" <> version <> "/main/conf.d/archive.conf")+            (unlines [Text.unpack k <> " = '" <> Text.unpack v <> "'" | (k, v) <- archiveParameters c])+        sh_ cid "postgres" ("pg_ctlcluster " <> version <> " main restart || pg_ctlcluster " <> version <> " main start")+        waitFor cid 5432 "SELECT 1" "1"++        -- the stanza and the first backup, with the checks read before and after+        before <- info+        assertBool ("before stanza-create: " <> show before) (isFailure (interpretStanza "demo" before))+        run_ "stanza-create" (stanzaCreateArgs c)+        created <- info+        assertEqual (show created) Success (interpretStanza "demo" created)+        assertBool (show created) (isFailure (interpretHasBackup "demo" created))+        run_ "backup" (backupArgs c Full)+        backedUp <- info+        assertEqual (show backedUp) Success (interpretHasBackup "demo" backedUp)++        -- one row before the target, one after; both archived+        _ <- psql cid 5432 "CREATE TABLE canary (v text); INSERT INTO canary VALUES ('before')"+        threadDelay 1500000+        target <- psql cid 5432 "SELECT now()"+        threadDelay 1500000+        _ <- psql cid 5432 "INSERT INTO canary VALUES ('after')"+        segment <- psql cid 5432 "SELECT pg_walfile_name(pg_current_wal_lsn())"+        _ <- psql cid 5432 "SELECT pg_switch_wal()"+        waitFor cid 5432 ("SELECT last_archived_wal >= '" <> segment <> "' FROM pg_stat_archiver") "t"++        -- the guard: a data directory that holds a cluster is refused+        ctl "main" "stop"+        (refused, _, refusedErr) <- pgbackrest (restoreArgs c (AtTime (Text.pack target)))+        assertBool ("a restore over a live data directory was not refused: " <> refusedErr) (refused /= ExitSuccess)++        -- the disaster, and the recovery to the target+        sh_ cid "postgres" ("find " <> pbr_pg_path c <> " -mindepth 1 -delete")+        run_ "restore" (restoreArgs c (AtTime (Text.pack target)))+        ctl "main" "start"+        waitFor cid 5432 "SELECT pg_is_in_recovery()" "f"+        rows <- psql cid 5432 "SELECT string_agg(v, ',') FROM canary"+        assertEqual "the cluster is not at the target" "before" rows++        -- a second data directory made by the command Patroni is handed, started as a standby of the first+        run_ "backup after recovery" (backupArgs c Incremental)+        sh_ cid "root" ("pg_createcluster " <> version <> " standby -p 5433")+        (code, out, err) <- argv cid "postgres" ["bash", "-c", Text.unpack (replicaCreateCommand c2)]+        assertEqual ("replica creation failed:\n" <> out <> "\n" <> err) ExitSuccess code+        -- what Patroni writes after the method succeeds+        sh_ cid "postgres" ("touch " <> pbr_pg_path c2 <> "/standby.signal")+        write+            cid+            ("/etc/postgresql/" <> version <> "/standby/conf.d/standby.conf")+            (unlines ["primary_conninfo = 'host=/var/run/postgresql port=5432 user=postgres'", "restore_command = '" <> Text.unpack (restoreCommand c2) <> "'"])+        ctl "standby" "start"+        waitFor cid 5433 "SELECT pg_is_in_recovery()" "t"+        _ <- psql cid 5432 "INSERT INTO canary VALUES ('later')"+        waitFor cid 5433 "SELECT string_agg(v, ',') FROM canary" "before,later"+        waitFor cid 5432 "SELECT count(*) FROM pg_stat_replication WHERE state = 'streaming'" "1"++-- | Runs an argv in the container as a user.+argv :: String -> String -> [String] -> IO (ExitCode, String, String)+argv cid user args = readProcessWithExitCode "podman" (["exec", "-i", "--user", user, cid] <> args) ""++sh :: String -> String -> String -> IO String+sh cid user script = do+    (code, out, err) <- argv cid user ["bash", "-c", script]+    unless (code == ExitSuccess) $ assertFailure (script <> " failed:\n" <> out <> "\n" <> err)+    pure out++sh_ :: String -> String -> String -> IO ()+sh_ cid user script = () <$ sh cid user script++write :: String -> FilePath -> String -> IO ()+write cid path contents = do+    (code, _, err) <- readProcessWithExitCode "podman" ["exec", "-i", cid, "bash", "-c", "cat > " <> path] contents+    unless (code == ExitSuccess) $ assertFailure ("writing " <> path <> " failed: " <> err)++psql :: String -> Int -> String -> IO String+psql cid port q = do+    (code, out, err) <- argv cid "postgres" ["psql", "-p", show port, "-tAc", q]+    unless (code == ExitSuccess) $ assertFailure (q <> " failed:\n" <> err)+    pure (concat (take 1 (reverse (filter (not . null) (lines out)))))++-- | Polls a query until it answers what is expected, 60 x 1s; a server that is still coming up is not an error yet.+waitFor :: String -> Int -> String -> String -> IO ()+waitFor cid port q expected = go (60 :: Int) ""+  where+    go 0 lastSeen = assertFailure (q <> " on port " <> show port <> " never answered " <> expected <> "; last: " <> lastSeen)+    go n _ = do+        (code, out, err) <- argv cid "postgres" ["psql", "-p", show port, "-tAc", q]+        let got = concat (take 1 (lines out))+        if code == ExitSuccess && got == expected+            then pure ()+            else threadDelay 1000000 >> go (n - 1) (got <> err)
+ test/Test/PgTextSearchSpec.hs view
@@ -0,0 +1,937 @@+{-# LANGUAGE OverloadedStrings #-}++{- | Coverage for "Salmon.Builtin.Nodes.PgTextSearch", and for the+@shared_preload_libraries@ node it shares with @pg_turret@+('Salmon.Builtin.Nodes.Postgres.preloadLibrary').++Layer 0: the version gate, the names of a release's files, the install+plan, the control file, the settings and their SQL, the verdicts, and the+shape of the graph. The listings and the control file below are the ones of+the upstream release v1.5.1 (the zip and the package were listed, never+installed or loaded), and 'upstreamSettings' is the README's table, checked+against @src\/mod.c@ at that tag, written by hand.++Layer 1 ('installs'): 'releaseFiles' run for real against a package built+here with @dpkg-deb@ and a stand-in @pg_config@ naming two temporary+directories. It is not the upstream package and no PostgreSQL is involved.++Layer 2 ('sandboxTests'): the upstream release on a real PostgreSQL 17 in a+podman container, when @SALMON_TEST_PG_TEXTSEARCH_ZIP@ names the zip. It+is the only place the preload, the restart, the gate on the loaded library,+the settings round trip, @CREATE EXTENSION@ and a @bm25@ index are run.+-}+module Test.PgTextSearchSpec (tests, sandboxTests) where++import Control.Concurrent (threadDelay)+import Control.Monad (forM_, unless)+import Data.List (isInfixOf, nub, sort)+import qualified Data.Map.Strict as Map+import Data.Maybe (isJust, isNothing)+import Data.Text (Text)+import qualified Data.Text as Text+import System.Directory (createDirectoryIfMissing, doesFileExist, findExecutable, getPermissions, executable, listDirectory)+import System.Environment (lookupEnv)+import System.Exit (ExitCode (..))+import System.FilePath ((</>))+import System.IO (hPutStrLn, stderr)+import System.Process (readProcessWithExitCode)+import Test.QuickCheck (Gen, Property, choose, chooseInt, counterexample, elements, forAll, frequency, listOf, listOf1, oneof, shuffle, sublistOf, vectorOf, (===), (==>))+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, assertEqual, assertFailure, testCase)+import Test.Tasty.QuickCheck (testProperty)++import Salmon.Actions.UpDown (CheckResult (..))+import Salmon.Builtin.Extension+import Salmon.Builtin.Nodes.LlamaServer (sha256File)+import Salmon.Builtin.Nodes.PgTextSearch+import qualified Salmon.Builtin.Nodes.Podman as Podman+import qualified Salmon.Builtin.Nodes.Postgres as Postgres+import Salmon.Op.Actions (Act (..))+import qualified Salmon.Op.Dag as Dag+import Salmon.Op.Ref (Ref, mkRef)+import Salmon.Reporter (silent)++import Test.Harness (podmanExec_, requireExecutable, runDown, runUp, withContainer, withShimmedPath, withTempDir, writeExecutable)++-------------------------------------------------------------------------------+-- what was read upstream++-- | The ten settings of the README's table at v1.5.1.+upstreamSettings :: [Text]+upstreamSettings =+    [ "pg_textsearch.default_limit"+    , "pg_textsearch.compress_segments"+    , "pg_textsearch.segments_per_level"+    , "pg_textsearch.max_segment_size"+    , "pg_textsearch.background_compaction_schedule"+    , "pg_textsearch.bulk_load_threshold"+    , "pg_textsearch.memtable_pages_threshold"+    , "pg_textsearch.allow_rls"+    , "pg_textsearch.memtable_cache_enabled"+    , "pg_textsearch.memory_limit"+    ]++-- | @unzip -Z1 pg-textsearch-v1.5.1-pg17-amd64.zip@.+zipListing :: [Text]+zipListing = ["pg-textsearch-postgresql-17_1.5.1-1_amd64.deb"]++-- | The regular files of that package (@dpkg-deb -c@), some of the upgrade scripts left out.+packageListing :: [FilePath]+packageListing =+    [ "usr/lib/postgresql/17/lib/pg_textsearch.so"+    , "usr/share/postgresql/17/extension/pg_textsearch--1.4.0--1.5.0.sql"+    , "usr/share/postgresql/17/extension/pg_textsearch--1.5.0--1.5.1.sql"+    , "usr/share/postgresql/17/extension/pg_textsearch--1.5.1.sql"+    , "usr/share/postgresql/17/extension/pg_textsearch--0.5.0.sql"+    , "usr/share/postgresql/17/extension/pg_textsearch.control"+    ]++-- | The package's @pg_textsearch.control@, whole.+controlFile :: Text+controlFile =+    Text.unlines+        [ "# pg_textsearch extension"+        , "comment = 'Full-text search with BM25 ranking'"+        , "default_version = '1.5.1'"+        , "module_pathname = '$libdir/pg_textsearch'"+        , "relocatable = false"+        ]++dirs :: InstallDirs+dirs = InstallDirs "/usr/lib/postgresql/17/lib" "/usr/share/postgresql/17/extension"++release :: Release+release = Release "1.5.1" "/srv/artifacts/pg-textsearch-v1.5.1-pg17-amd64.zip" "ed4410e8b6879a8971cd87fd03853f182e47b85b199c9bfcd9b34f159caba9fb"++someSettings :: Settings+someSettings = defaultSettings{setMemoryLimitKb = Just 1048576, setAllowRls = Just False, setMaxSegmentSizeMb = Just 512}++config :: PgTextSearch+config = (pgTextSearchOn 17 5432 (RestartWhenPending "main") release){ptsDatabases = ["app", "other"], ptsSettings = someSettings}++-------------------------------------------------------------------------------++tests :: TestTree+tests =+    testGroup+        "Salmon.Builtin.Nodes.PgTextSearch"+        [ testGroup+            "version gate"+            [ testCase "17 and 18 pass when they are the declared major" $ do+                assertEqual "" (Right ()) (gateVerdict 17 "170004\n")+                assertEqual "" (Right ()) (gateVerdict 18 "180000")+            , testCase "an older server is refused with its version in the message" $+                assertEqual+                    ""+                    (Left "pg_textsearch supports PostgreSQL 17 and 18 only, this server is PostgreSQL 16 (server_version_num 160009)")+                    (gateVerdict 17 "160009")+            , testCase "a newer server is refused too: there is a ceiling" $+                assertEqual+                    ""+                    (Left "pg_textsearch supports PostgreSQL 17 and 18 only, this server is PostgreSQL 19 (server_version_num 190000)")+                    (gateVerdict 18 "190000")+            , testCase "a supported server of another major than the declared one is refused" $+                assertEqual "" (Left "this server is PostgreSQL 18 (server_version_num 180001), the declaration is for 17") (gateVerdict 17 "180001")+            , testCase "something that is not a number is refused, not read as zero" $ do+                assertBool "" (either (const True) (const False) (gateVerdict 17 ""))+                assertBool "" (either (const True) (const False) (gateVerdict 17 "17.4"))+            , testCase "the supported majors are the two upstream publishes for" $+                assertEqual "" [17, 18] supportedMajors+            ]+        , testGroup+            "release names"+            [ testCase "the zip is named as the asset is upstream" $+                assertEqual "" "pg-textsearch-v1.5.1-pg17-amd64.zip" (releaseZipName "1.5.1" 17 "amd64")+            , testCase "the package name of v1.5.1 is read" $+                assertEqual "" (Just (DebName 17 "1.5.1" 1 "amd64")) (parseDebName "pg-textsearch-postgresql-17_1.5.1-1_amd64.deb")+            , testCase "and written back" $+                assertEqual "" "pg-textsearch-postgresql-18_1.4.0-1_arm64.deb" (renderDebName (DebName 18 "1.4.0" 1 "arm64"))+            , testCase "names with a path, a pattern or a leading zero are not package names" $+                forM_+                    [ "../pg-textsearch-postgresql-17_1.5.1-1_amd64.deb"+                    , "x/pg-textsearch-postgresql-17_1.5.1-1_amd64.deb"+                    , "pg-textsearch-postgresql-17_1.5.1-1_*.deb"+                    , "pg-textsearch-postgresql-017_1.5.1-1_amd64.deb"+                    , "pg-textsearch-postgresql-17_1.5.1-1_amd64.deb "+                    , "pg-textsearch-postgresql-17_1.5.1_amd64.deb"+                    , "pg-textsearch-postgresql-17_1..1-1_amd64.deb"+                    , "pg-textsearch-postgresql-17_1.5.1-1_amd64.zip"+                    , "postgresql-17-pgvector_0.8.0-1_amd64.deb"+                    ]+                    (\n -> assertEqual (Text.unpack n) Nothing (parseDebName n))+            , testCase "the zip's one package is selected" $+                assertEqual "" (Right (DebName 17 "1.5.1" 1 "amd64")) (selectDeb 17 "1.5.1" "amd64" zipListing)+            , testCase "a zip for another major, version or architecture is refused, saying which" $ do+                assertEqual "" (Left "the zip holds a package for PostgreSQL 17, the declaration is for 18") (selectDeb 18 "1.5.1" "amd64" zipListing)+                assertEqual "" (Left "the zip holds version 1.5.1, the declaration is for 1.4.0") (selectDeb 17 "1.4.0" "amd64" zipListing)+                assertEqual "" (Left "the zip holds a package for amd64, this machine is arm64") (selectDeb 17 "1.5.1" "arm64" zipListing)+            , testCase "a zip with anything else in it is refused, not searched" $ do+                assertEqual "" (Left "the zip holds 2 entries, expected one Debian package") (selectDeb 17 "1.5.1" "amd64" (zipListing <> ["README"]))+                assertEqual "" (Left "the zip holds 0 entries, expected one Debian package") (selectDeb 17 "1.5.1" "amd64" [""])+                assertBool "" (either (const True) (const False) (selectDeb 17 "1.5.1" "amd64" ["evil.sh"]))+            , testCase "a declaration that cannot be right is refused before anything is read" $ do+                assertEqual "" [] (releaseProblems 17 release)+                assertEqual "" ["pg_textsearch supports PostgreSQL 17 and 18 only, the declaration is for 16"] (releaseProblems 16 release)+                assertEqual "" ["the pinned sha256 is not 64 hex digits"] (releaseProblems 17 release{releaseSha256 = "abc"})+                assertEqual "" ["the release version \"v1.5.1\" is not digits and dots"] (releaseProblems 17 release{releaseVersion = "v1.5.1"})+            ]+        , testGroup+            "install plan"+            [ testCase "the library goes first, the control file last, each under the server's own directory" $+                assertEqual+                    ""+                    ( Right+                        [ Install "usr/lib/postgresql/17/lib/pg_textsearch.so" "/L/pg_textsearch.so" True+                        , Install "usr/share/postgresql/17/extension/pg_textsearch--0.5.0.sql" "/E/pg_textsearch--0.5.0.sql" False+                        , Install "usr/share/postgresql/17/extension/pg_textsearch--1.4.0--1.5.0.sql" "/E/pg_textsearch--1.4.0--1.5.0.sql" False+                        , Install "usr/share/postgresql/17/extension/pg_textsearch--1.5.0--1.5.1.sql" "/E/pg_textsearch--1.5.0--1.5.1.sql" False+                        , Install "usr/share/postgresql/17/extension/pg_textsearch--1.5.1.sql" "/E/pg_textsearch--1.5.1.sql" False+                        , Install "usr/share/postgresql/17/extension/pg_textsearch.control" "/E/pg_textsearch.control" False+                        ]+                    )+                    (planInstall 17 "1.5.1" (InstallDirs "/L" "/E") packageListing)+            , testCase "a package built for another major is refused, saying so" $+                assertEqual+                    ""+                    (Left "the package has no usr/lib/postgresql/18/lib/pg_textsearch.so: it is not built for PostgreSQL 18")+                    (planInstall 18 "1.5.1" dirs packageListing)+            , testCase "a package of another version is refused" $+                assertEqual+                    ""+                    (Left "the package has no usr/share/postgresql/17/extension/pg_textsearch--1.6.0.sql: it is not version 1.6.0")+                    (planInstall 17 "1.6.0" dirs packageListing)+            , testCase "a file with no place is refused, and named" $ do+                assertEqual+                    ""+                    (Left "the package holds files this node has no place for: etc/cron.d/x, usr/share/postgresql/17/extension/other.control")+                    (planInstall 17 "1.5.1" dirs (packageListing <> ["usr/share/postgresql/17/extension/other.control", "etc/cron.d/x"]))+                assertBool "a script in a sub-directory" (either (const True) (const False) (planInstall 17 "1.5.1" dirs (packageListing <> ["usr/share/postgresql/17/extension/x/pg_textsearch--9.sql"])))+            , testCase "the names this module owns in the extension directory" $ do+                assertBool "" (all isExtensionFile ["pg_textsearch.control", "pg_textsearch--1.5.1.sql", "pg_textsearch--1.5.0--1.5.1.sql"])+                assertBool "" (not (any isExtensionFile ["pg_textsearch.so", "pg_textsearch_other.control", "vector.control", "pg_textsearch--1.sql.bak", "plpgsql--1.0.sql"]))+            , testCase "the control file's version is read" $ do+                assertEqual "" (Just "1.5.1") (parseControlVersion controlFile)+                assertEqual "" (Just "1.5.1") (parseControlVersion "default_version=1.5.1 # unquoted\n")+                assertEqual "" Nothing (parseControlVersion "comment = 'default_version = 9'\n# default_version = '2'\n")+            , testCase "dpkg's own record of the package, when it was installed the Debian way" $+                assertEqual "" "/var/lib/dpkg/info/pg-textsearch-postgresql-17.list" (dpkgListFile 17)+            ]+        , testGroup+            "loaded library"+            [ testCase "the declared version loaded is satisfied" $+                assertEqual "" Success (interpretLoaded "1.5.1" "1.5.1\n")+            , testCase "nothing loaded waits for a restart" $+                assertEqual "" (Failure "pg_textsearch is not loaded in the running server: the cluster has to be restarted") (interpretLoaded "1.5.1" "\n")+            , testCase "another version loaded waits for a restart, naming both" $+                assertEqual+                    ""+                    (Failure "the running server has pg_textsearch 1.4.0 loaded, the declared release is 1.5.1: the cluster has to be restarted")+                    (interpretLoaded "1.5.1" "1.4.0")+            ]+        , testGroup+            "settings"+            [ testCase "nothing declared renders nothing" $ do+                assertEqual "" [] (settings defaultSettings)+                assertEqual "" [] (problems defaultSettings)+            , testCase "every setting has the name upstream registers, in the README's order" $+                assertEqual "" upstreamSettings (fmap settingName (settings everySetting))+            , testCase "sizes are written with their unit and compared without it" $+                assertEqual+                    ""+                    [ Setting "pg_textsearch.max_segment_size" "512" "512MB"+                    , Setting "pg_textsearch.allow_rls" "off" "off"+                    , Setting "pg_textsearch.memory_limit" "1048576" "1048576kB"+                    ]+                    (settings someSettings)+            , testCase "the batch sets each one and reloads" $+                assertEqual+                    ""+                    "ALTER SYSTEM SET pg_textsearch.max_segment_size = '512MB';\nALTER SYSTEM SET pg_textsearch.allow_rls = 'off';\nALTER SYSTEM SET pg_textsearch.memory_limit = '1048576kB';\nSELECT pg_reload_conf();\n"+                    (applySettingsSql (settings someSettings))+            , testCase "a schedule with a quote in it stays inside its literal" $+                assertBool+                    ""+                    ("= '*/5 * * * * ''; DROP';" `Text.isInfixOf` applySettingsSql (settings defaultSettings{setBackgroundCompactionSchedule = Just "*/5 * * * * '; DROP"}))+            , testCase "going down resets the declared ones and reloads" $+                assertEqual+                    ""+                    "ALTER SYSTEM RESET pg_textsearch.allow_rls;\nSELECT pg_reload_conf();\n"+                    (resetSettingsSql ["pg_textsearch.allow_rls"])+            , testCase "the question reads value, context and pending_restart" $+                assertEqual+                    ""+                    "SELECT coalesce(json_object_agg(name, json_build_object('setting', setting, 'context', context, 'pending_restart', pending_restart)), '{}') FROM pg_settings WHERE name IN ('pg_textsearch.allow_rls', 'pg_textsearch.memory_limit')"+                    (inspectSettingsSql ["pg_textsearch.allow_rls", "pg_textsearch.memory_limit"])+            , testCase "values out of the ranges upstream registers are refused, all of them" $+                assertEqual+                    ""+                    [ "default_limit is 0, outside 1..100000"+                    , "segments_per_level is 65, outside 2..64"+                    , "max_segment_size is 4096, outside 1..4095"+                    , "memory_limit is -1, outside 0..2147483647"+                    , "background_compaction_schedule is empty or has a line break in it"+                    ]+                    ( problems+                        defaultSettings+                            { setDefaultLimit = Just 0+                            , setSegmentsPerLevel = Just 65+                            , setMaxSegmentSizeMb = Just 4096+                            , setMemoryLimitKb = Just (-1)+                            , setBackgroundCompactionSchedule = Just "a\nb"+                            }+                    )+            , testCase "the server agreeing is satisfied" $+                assertEqual "" Success (interpretSettings (settings someSettings) (live [("max_segment_size", "512", "superuser", False), ("allow_rls", "off", "superuser", False), ("memory_limit", "1048576", "sighup", False)]))+            , testCase "a value that differs is named with both sides" $+                assertEqual+                    ""+                    (Failure "pg_textsearch.memory_limit is \"2097152\", declared \"1048576\"")+                    (interpretSettings (settings someSettings) (live [("max_segment_size", "512", "superuser", False), ("allow_rls", "off", "superuser", False), ("memory_limit", "2097152", "sighup", False)]))+            , testCase "a server without the library has no such setting" $+                assertEqual+                    ""+                    (Failure "pg_textsearch.allow_rls is not a setting of the running server (is pg_textsearch loaded, and is it this release?)")+                    (interpretSettings (settings defaultSettings{setAllowRls = Just False}) "{}")+            , testCase "a setting PostgreSQL says waits for a restart is said to" $ do+                let one = settings defaultSettings{setAllowRls = Just False}+                assertEqual+                    "by context"+                    (Failure "pg_textsearch.allow_rls is \"on\", declared \"off\": it only changes at a restart (context postmaster), which this node does not do")+                    (interpretSettings one (live [("allow_rls", "on", "postmaster", False)]))+                assertEqual+                    "by flag"+                    (Failure "pg_textsearch.allow_rls is \"on\", declared \"off\": it only changes at a restart (context sighup), which this node does not do")+                    (interpretSettings one (live [("allow_rls", "on", "sighup", True)]))+            , testCase "output that is not the expected JSON is not an answer" $ do+                assertEqual "" Unknown (interpretSettings (settings someSettings) "")+                assertEqual "" Unknown (interpretSettings (settings someSettings) "psql: error")+            ]+        , testGroup+            "shared_preload_libraries"+            [ testCase "an empty list gets the one entry" $+                assertEqual "" ["pg_textsearch"] (Postgres.addLibrary "pg_textsearch" (Postgres.parseLibraryList ""))+            , testCase "a library already listed is left where it is" $ do+                let libs = Postgres.parseLibraryList "pg_textsearch, pg_stat_statements"+                assertEqual "" libs (Postgres.addLibrary "pg_textsearch" libs)+            , testCase "other libraries are kept, in their order, with ours last" $+                assertEqual+                    ""+                    ["pg_stat_statements", "auto_explain", "pg_textsearch"]+                    (Postgres.addLibrary "pg_textsearch" (Postgres.parseLibraryList "pg_stat_statements,\"auto_explain\""))+            , testCase "a library whose name starts with ours is another library" $+                assertEqual "" ["pg_textsearch_x", "pg_textsearch"] (Postgres.addLibrary "pg_textsearch" ["pg_textsearch_x"])+            , testCase "the list is written back as one value, then reloaded" $+                assertEqual+                    ""+                    "ALTER SYSTEM SET shared_preload_libraries = 'pg_stat_statements', 'pg_textsearch';\nSELECT pg_reload_conf();\n"+                    (Postgres.alterPreloadSql ["pg_stat_statements", "pg_textsearch"])+            , testCase "the last library taken out is a reset: = '' is written as '\"\"', and a server does not start on that" $ do+                assertEqual "" "ALTER SYSTEM RESET shared_preload_libraries;\nSELECT pg_reload_conf();\n" (Postgres.alterPreloadSql (Postgres.removeLibrary "pg_textsearch" ["pg_textsearch"]))+                assertEqual "what the old statement left in the files reads as no library" [] (Postgres.parseLibraryList "\"\"")+            , testCase "the node is the one pg_turret declared before it moved: same ref, help and notes" $ do+                let act = only (Postgres.preloadLibrary ignoreTrack 5432 "pg_turret")+                assertEqual "ref" (mkRef "pg-preload-library" (5432 :: Int, "pg_turret" :: Text)) act.extension.ref+                assertEqual "help" "preload pg_turret in the cluster on port 5432" act.extension.help+                assertEqual "notes" ["takes effect at the next restart of the cluster", "keeps the other entries of shared_preload_libraries"] act.extension.notes+            ]+        , testGroup+            "graph"+            [ testCase "the nodes of a full declaration" $+                assertEqual+                    ""+                    (sort ["pg-textsearch", "pg-textsearch-version-gate", "pg-textsearch-files", "pg-preload-library", "pg-cluster-ctl", "pg-textsearch-loaded", "pg-textsearch-settings", "pg-extension", "pg-extension"])+                    (sort (shorthands (graph config)))+            , testCase "each step waits for the one before it" $ do+                let d = graph config+                assertEqual "files after the gate" [gateRef] (deps' d filesRef)+                assertEqual "preload after the files" [filesRef] (deps' d preloadRef)+                assertEqual "restart after the preload" [preloadRef] (deps' d restartRef)+                assertEqual "loaded after the restart" [restartRef] (deps' d loadedRef)+                assertEqual "settings after loaded" [loadedRef] (deps' d settingsRef)+                assertEqual "extension after loaded" [loadedRef] (deps' d (extRef "app"))+                assertEqual "the root" (sort [settingsRef, extRef "app", extRef "other"]) (sort (deps' d rootRef))+            , testCase "a restart left to the operator declares no restart node" $ do+                let d = graph config{ptsRestart = RestartLeftToOperator}+                assertBool "no cluster node" ("pg-cluster-ctl" `notElem` shorthands d)+                assertEqual "loaded straight after the preload" [preloadRef] (deps' d loadedRef)+            , testCase "no setting declared, no settings node" $ do+                let d = graph config{ptsSettings = defaultSettings, ptsDatabases = []}+                assertBool "" ("pg-textsearch-settings" `notElem` shorthands d)+                assertEqual "the root waits for the library all the same" [loadedRef] (deps' d rootRef)+            , testCase "the extension is created with the 17 floor, and upgraded only when asked" $ do+                let ext upgrade = (nodeAt (graph config{ptsUpgrade = upgrade}) (extRef "app")).extension+                assertEqual "" ["needs server_version_num >= 170000"] (ext False).notes+                assertEqual "" ["upgrades the extension when the package is newer", "needs server_version_num >= 170000"] (ext True).notes+            , testCase "what the files node says in public" $ do+                let e = (only (releaseFiles 17 (debianPgConfig 17) release)).extension+                assertEqual "help" "pg_textsearch 1.5.1 files for postgresql 17" e.help+                assertEqual+                    "notes"+                    [ "from /srv/artifacts/pg-textsearch-v1.5.1-pg17-amd64.zip"+                    , "pinned sha256 of the release zip (a public artifact, not a secret): ed4410e8b6879a8971cd87fd03853f182e47b85b199c9bfcd9b34f159caba9fb"+                    , "directories from /usr/lib/postgresql/17/bin/pg_config"+                    , "a replaced library is loaded at the next restart, which this node does not force"+                    ]+                    e.notes+            , testCase "a declaration for an unsupported major fails its files node without touching anything" $ do+                r <- (only (releaseFiles 16 "/nonexistent/pg_config" release)).extension.check+                assertEqual "" (Failure "pg_textsearch supports PostgreSQL 17 and 18 only, the declaration is for 16") r+                ok <- runUp (releaseFiles 16 "/nonexistent/pg_config" release)+                assertBool "up refuses" (not ok)+            ]+        , testGroup+            "properties"+            [ testProperty "the gate passes exactly a supported server of the declared major, and names the version it refuses" prop_gate+            , testProperty "a package name renders and parses back" prop_debRoundTrip+            , testProperty "whatever parses as a package name renders back to the same text, and is only [a-z0-9._-]" prop_debStrict+            , testProperty "a zip's listing selects its package only when major, version and architecture all agree" prop_selectDeb+            , testProperty "the plan of a well-formed package: library first, control last, one target per file, all under the two directories" prop_plan+            , testProperty "one file more than the release's shape and nothing is planned" prop_planStranger+            , testProperty "a control file's version is read back" prop_control+            , testProperty "adding a library is idempotent, keeps the others in order, and survives the value PostgreSQL writes" prop_preload+            , testProperty "declared settings: upstream's names, once each, as many as were declared" prop_settingNames+            , testProperty "a declaration within upstream's ranges has no problem, and one outside has" prop_ranges+            , testProperty "a server reporting the declared values is satisfied, and one differing value is a failure naming it" prop_settingsVerdict+            , testProperty "the batch is one statement per setting and a reload, with every value inside a literal" prop_batch+            , testProperty "refs are effect sites: the release, the settings and the databases do not move them" prop_refs+            ]+        , testGroup+            "Layer 1: the files, from a package built here"+            [ testCase "installs, is satisfied, replaces on a new release, refuses a wrong digest, and removes only its own" installs+            ]+        ]+  where+    everySetting =+        Settings (Just 500) (Just True) (Just 4) (Just 1024) (Just "*/10 * * * *") (Just 0) (Just 32) (Just False) (Just True) (Just 0)+    live :: [(Text, Text, Text, Bool)] -> Text+    live rows =+        "{"+            <> Text.intercalate+                ", "+                [ Text.pack (show ("pg_textsearch." <> k)) <> ": {\"setting\": " <> Text.pack (show v) <> ", \"context\": " <> Text.pack (show c) <> ", \"pending_restart\": " <> (if p then "true" else "false") <> "}"+                | (k, v, c, p) <- rows+                ]+            <> "}\n"++-------------------------------------------------------------------------------+-- graph helpers++graph :: PgTextSearch -> Dag.Dag Extension+graph cfg = Dag.foldDag Dag.sameRepresentative (evalDeps (pgTextSearch silent ignoreTrack ignoreTrack ignoreTrack cfg))++only :: Op -> Act Extension+only o = case Map.elems (Dag.dagNodes (Dag.foldDag Dag.sameRepresentative (evalDeps o))) of+    [act] -> act+    acts -> error ("expected one node, got " <> show (length acts))++shorthands :: Dag.Dag Extension -> [Text]+shorthands d = fmap (.shorthand) (Map.elems (Dag.dagNodes d))++nodeAt :: Dag.Dag Extension -> Ref -> Act Extension+nodeAt d r = maybe (error ("no node " <> show r)) id (Map.lookup r (Dag.dagNodes d))++deps' :: Dag.Dag Extension -> Ref -> [Ref]+deps' d r = Map.findWithDefault [] r (Dag.dagDependencies d)++rootRef, gateRef, filesRef, preloadRef, restartRef, loadedRef, settingsRef :: Ref+rootRef = mkRef "pg-textsearch" (5432 :: Int)+gateRef = mkRef "pg-textsearch-version-gate" (5432 :: Int)+filesRef = mkRef "pg-textsearch-files" ("/usr/lib/postgresql/17/bin/pg_config" :: FilePath)+preloadRef = mkRef "pg-preload-library" (5432 :: Int, "pg_textsearch" :: Text)+restartRef = mkRef "pg-cluster-ctl" ("main" :: Text, "restart" :: Text)+loadedRef = mkRef "pg-textsearch-loaded" (5432 :: Int)+settingsRef = mkRef "pg-textsearch-settings" (5432 :: Int)++extRef :: Text -> Ref+extRef db = mkRef "pg-extension" (5432 :: Int, db, "pg_textsearch" :: Text)++-------------------------------------------------------------------------------+-- generators++genVersion :: Gen Text+genVersion = do+    n <- chooseInt (1, 4)+    parts <- vectorOf n (chooseInt (0, 120))+    pure (Text.intercalate "." (fmap (Text.pack . show) parts))++genArch :: Gen Text+genArch = oneof [elements ["amd64", "arm64", "riscv64", "s390x"], Text.pack <$> listOf1 (elements (['a' .. 'z'] <> ['0' .. '9']))]++genDeb :: Gen DebName+genDeb = DebName <$> chooseInt (0, 99) <*> genVersion <*> chooseInt (0, 30) <*> genArch++-- | Texts near a package name: real ones, and real ones damaged one character at a time.+genNearName :: Gen Text+genNearName = do+    base <- renderDebName <$> genDeb+    frequency+        [ (2, pure base)+        , (6, damage base)+        , (1, Text.pack <$> listOf (elements alphabet))+        ]+  where+    alphabet = "pg-textsearch-postgresql_.deb0123456789/* ?[A\n"+    damage t = do+        i <- chooseInt (0, Text.length t)+        c <- elements alphabet+        op' <- chooseInt (0, 2 :: Int)+        let (a, b) = Text.splitAt i t+        pure $ case op' of+            0 -> a <> Text.singleton c <> b+            1 -> a <> Text.drop 1 b+            _ -> a <> Text.singleton c <> Text.drop 1 b++genLibs :: Gen [Text]+genLibs = nub <$> listOf (elements ["pg_stat_statements", "auto_explain", "pg_textsearch", "pg_turret", "timescaledb", "pg_textsearch_x", "a"])++genSettings :: Gen Settings+genSettings =+    Settings+        <$> opt (chooseInt (1, 100000))+        <*> opt bool+        <*> opt (chooseInt (2, 64))+        <*> opt (chooseInt (1, 4095))+        <*> opt (elements ["*/5 * * * *", "0 3 * * *", "@daily", "it's 5"])+        <*> opt (chooseInt (0, 2147483647))+        <*> opt (chooseInt (0, 2147483647))+        <*> opt bool+        <*> opt bool+        <*> opt (chooseInt (0, 2147483647))+  where+    bool = elements [True, False]+    opt g = frequency [(1, pure Nothing), (2, Just <$> g)]++declaredCount :: Settings -> Int+declaredCount s =+    length (filter id [isJust s.setDefaultLimit, isJust s.setCompressSegments, isJust s.setSegmentsPerLevel, isJust s.setMaxSegmentSizeMb, isJust s.setBackgroundCompactionSchedule, isJust s.setBulkLoadThreshold, isJust s.setMemtablePagesThreshold, isJust s.setAllowRls, isJust s.setMemtableCacheEnabled, isJust s.setMemoryLimitKb])++-- | The JSON a server would answer 'inspectSettingsSql' with, were its values these.+liveJson :: [(Text, Text)] -> Text+liveJson rows =+    "{" <> Text.intercalate ", " [Text.pack (show k) <> ": {\"setting\": " <> Text.pack (show v) <> ", \"context\": \"superuser\", \"pending_restart\": false}" | (k, v) <- rows] <> "}"++-------------------------------------------------------------------------------+-- properties++prop_gate :: Property+prop_gate =+    forAll (chooseInt (9, 25)) $ \major ->+        forAll (chooseInt (0, 99)) $ \minor ->+            forAll (elements [17, 18, 16, 19]) $ \declared ->+                let num = major * 10000 + minor+                    verdict = gateVerdict declared (Text.pack (show num) <> "\n")+                    expected = major `elem` [17, 18] && major == declared+                 in counterexample (show verdict) $ case verdict of+                        Right () -> expected+                        Left why -> not expected && Text.pack (show num) `Text.isInfixOf` why && Text.pack (show major) `Text.isInfixOf` why++prop_debRoundTrip :: Property+prop_debRoundTrip = forAll genDeb $ \d -> parseDebName (renderDebName d) === Just d++prop_debStrict :: Property+prop_debStrict =+    forAll genNearName $ \t ->+        case parseDebName t of+            Nothing -> counterexample "" True+            Just d ->+                counterexample (show (t, d)) $+                    renderDebName d == t && Text.all (`elem` (['a' .. 'z'] <> ['0' .. '9'] <> "._-")) t++prop_selectDeb :: Property+prop_selectDeb =+    forAll genDeb $ \d ->+        forAll genDeb $ \want ->+            let agree = d.debMajor == want.debMajor && d.debVersion == want.debVersion && d.debArch == want.debArch+                verdict = selectDeb want.debMajor want.debVersion want.debArch [renderDebName d]+             in counterexample (show verdict) $+                    (if agree then verdict == Right d else either (const True) (const False) verdict)+                        && selectDeb d.debMajor d.debVersion d.debArch [renderDebName d] == Right d+                        && either (const True) (const False) (selectDeb d.debMajor d.debVersion d.debArch [renderDebName d, renderDebName d])++-- | A package of the release's shape: the library, the control file, the version's script and some upgrade scripts.+genPackage :: Gen (Int, Text, [FilePath])+genPackage = do+    major <- elements [17, 18]+    version <- genVersion+    olds <- nub <$> listOf genVersion+    let ext = "usr/share/postgresql" </> show major </> "extension"+        scripts = [ext </> ("pg_textsearch--" <> Text.unpack o <> "--" <> Text.unpack version <> ".sql") | o <- olds, o /= version]+    files <-+        shuffle+            ( ["usr/lib/postgresql" </> show major </> "lib/pg_textsearch.so", ext </> "pg_textsearch.control", ext </> ("pg_textsearch--" <> Text.unpack version <> ".sql")]+                <> scripts+            )+    pure (major, version, files)++prop_plan :: Property+prop_plan =+    forAll genPackage $ \(major, version, files) ->+        let other = if major == 17 then 18 else 17+         in case planInstall major version (InstallDirs "/L" "/E") files of+                Left why -> counterexample (Text.unpack why) False+                Right plan ->+                    counterexample (show plan) $+                        sort (fmap installFrom plan) == sort files+                            && fmap (\i -> (i.installTo, i.installExecutable)) (take 1 plan) == [("/L/pg_textsearch.so", True)]+                            && fmap installTo (take 1 (reverse plan)) == ["/E/pg_textsearch.control"]+                            && all (\i -> "/E/" `isPrefixOf'` i.installTo && not i.installExecutable) (drop 1 plan)+                            && length (nub (fmap installTo plan)) == length plan+                            && all (\i -> not (".." `isInfixOf` i.installTo)) plan+                            && either (const True) (const False) (planInstall other version (InstallDirs "/L" "/E") files)+  where+    isPrefixOf' p s = take (length p) s == p++prop_planStranger :: Property+prop_planStranger =+    forAll genPackage $ \(major, version, files) ->+        forAll (elements ["etc/passwd", "usr/lib/postgresql/" <> show major <> "/lib/other.so", "usr/share/postgresql/" <> show major <> "/extension/plpgsql.control", "usr/share/postgresql/" <> show major <> "/extension/sub/pg_textsearch--1.sql", "usr/share/doc/x/README"]) $ \stranger ->+            either (const True) (const False) (planInstall major version (InstallDirs "/L" "/E") (stranger : files))++prop_control :: Property+prop_control =+    forAll genVersion $ \v ->+        forAll (elements ["default_version = '" <> v <> "'", "default_version='" <> v <> "'  # c", "  default_version = " <> v, "default_version\t=\t" <> v <> " # c"]) $ \line ->+            parseControlVersion (Text.unlines ["# pg_textsearch extension", "comment = 'x'", line, "relocatable = false"]) === Just v++prop_preload :: Property+prop_preload =+    forAll genLibs $ \libs ->+        forAll (elements ["pg_textsearch", "pg_turret", "new"]) $ \lib ->+            let added = Postgres.addLibrary lib libs+                -- the value as PostgreSQL shows it back: comma-separated, quoted or not+                shown quoted = Text.intercalate ", " (fmap (\l -> if quoted then "\"" <> l <> "\"" else l) added)+             in counterexample (show added) $+                    Postgres.addLibrary lib added == added+                        && lib `elem` added+                        && Postgres.removeLibrary lib added == Postgres.removeLibrary lib libs+                        && take (length libs) added == libs+                        && Postgres.parseLibraryList (shown False) == added+                        && Postgres.parseLibraryList (shown True) == added+                        && Postgres.removeLibrary lib (Postgres.removeLibrary lib added) == Postgres.removeLibrary lib added++prop_settingNames :: Property+prop_settingNames =+    forAll genSettings $ \s ->+        let names = fmap settingName (settings s)+         in counterexample (show names) $+                all (`elem` upstreamSettings) names+                    && nub names == names+                    && length names == declaredCount s+                    && length (settingNotes s) == length names+                    && filter (`elem` names) upstreamSettings == names++prop_ranges :: Property+prop_ranges =+    forAll genSettings $ \s ->+        forAll (elements [(-1), 0, 1, 65, 4096, 100001]) $ \n ->+            counterexample (show (problems s)) $+                null (problems s)+                    && (not (n < 1 || n > 100000) || not (null (problems s{setDefaultLimit = Just n})))+                    && (not (n < 2 || n > 64) || not (null (problems s{setSegmentsPerLevel = Just n})))+                    && (not (n < 1 || n > 4095) || not (null (problems s{setMaxSegmentSizeMb = Just n})))+                    && (not (n < 0) || not (null (problems s{setMemoryLimitKb = Just n})))++prop_settingsVerdict :: Property+prop_settingsVerdict =+    forAll genSettings $ \s ->+        let declared = settings s+            rows = [(d.settingName, d.settingValue) | d <- declared]+         in not (null declared) ==>+                forAll (chooseInt (0, length declared - 1)) $ \i ->+                    let victim = fst (rows !! i)+                        changed = [(k, if k == victim then v <> "x" else v) | (k, v) <- rows]+                        dropped = [r | r@(k, _) <- rows, k /= victim]+                        names r = case r of+                            Failure why -> victim `Text.isInfixOf` why+                            _ -> False+                     in counterexample (show (declared, victim)) $+                            interpretSettings declared (liveJson rows) == Success+                                && names (interpretSettings declared (liveJson changed))+                                && names (interpretSettings declared (liveJson dropped))++prop_batch :: Property+prop_batch =+    forAll genSettings $ \s ->+        let declared = settings s+            ls = Text.lines (applySettingsSql declared)+         in counterexample (show ls) $+                length ls == length declared + 1+                    && last ls == "SELECT pg_reload_conf();"+                    && and+                        [ l == "ALTER SYSTEM SET " <> d.settingName <> " = " <> Postgres.quoteLiteral d.settingSql <> ";"+                            -- an odd number of quotes would leave the literal open+                            && even (Text.count "'" l)+                        | (l, d) <- zip ls declared+                        ]+                    && Text.lines (resetSettingsSql (fmap settingName declared)) == ["ALTER SYSTEM RESET " <> d.settingName <> ";" | d <- declared] <> ["SELECT pg_reload_conf();"]++prop_refs :: Property+prop_refs =+    forAll genSettings $ \s ->+        forAll genVersion $ \v ->+            forAll (sublistOf ["app", "other", "third"]) $ \dbs ->+                let cfg = config{ptsSettings = s, ptsDatabases = dbs, ptsRelease = release{releaseVersion = v, releaseSha256 = Text.replicate 64 "a"}}+                    d = graph cfg+                    refs = Map.keys (Dag.dagNodes d)+                    fixed = [rootRef, gateRef, filesRef, preloadRef, restartRef, loadedRef] <> [settingsRef | not (null (settings s))] <> fmap extRef dbs+                 in counterexample (show (length refs, length fixed)) $+                        sort refs == sort fixed+                            -- and the same declaration is the same graph+                            && Map.keys (Dag.dagNodes (graph cfg)) == refs+                            && isNothing (Map.lookup settingsRef (Dag.dagNodes (graph cfg{ptsSettings = defaultSettings})))++-------------------------------------------------------------------------------+-- Layer 1++{- | Builds a package of the release's shape with @dpkg-deb@, zips it, and+runs 'releaseFiles' on it with a stand-in @pg_config@ that names two+directories under a temporary one. Skipped, loudly, without the tools.+-}+installs :: IO ()+installs = requireAll ["dpkg-deb", "dpkg", "zip", "unzip", "sh"] $ withTempDir $ \tmp -> do+    arch <- trim <$> run "dpkg" ["--print-architecture"] ""+    let lib = tmp </> "lib"+        ext = tmp </> "share" </> "extension"+        pgConfig = tmp </> "pg_config"+    createDirectoryIfMissing True lib+    createDirectoryIfMissing True ext+    standInPgConfig pgConfig lib (tmp </> "share")+    zipA <- buildRelease tmp arch "9.9.9" "library, first build"+    zipB <- buildRelease tmp arch "9.9.10" "library, second build"+    shaA <- sha256File zipA+    shaB <- sha256File zipB+    let node zipPath v sha = releaseFiles 17 pgConfig (Release v zipPath sha)+        checkOf o = (only o).extension.check+        a = node zipA "9.9.9" shaA++    before <- checkOf a+    assertEqual "nothing installed yet" (Failure (Text.pack (ext </> "pg_textsearch.control") <> " is missing")) before++    -- a zip that is not the pinned one is refused before anything is taken out of it+    refused <- runUp (node zipA "9.9.9" shaB)+    assertBool "wrong digest refused" (not refused)+    listDirectory lib >>= assertEqual "nothing was copied" []+    listDirectory ext >>= assertEqual "nothing was copied" []++    -- so is the right zip declared as another version+    refused' <- runUp (node zipA "9.9.8" shaA)+    assertBool "wrong version refused" (not refused')+    listDirectory ext >>= assertEqual "nothing was copied" []++    ok <- runUp a+    assertBool "installs" ok+    readFile (lib </> "pg_textsearch.so") >>= assertEqual "the library" "library, first build\n"+    (executable <$> getPermissions (lib </> "pg_textsearch.so")) >>= assertBool "the library is executable"+    (sort <$> listDirectory ext) >>= assertEqual "the extension files" ["pg_textsearch--9.9.8--9.9.9.sql", "pg_textsearch--9.9.9.sql", "pg_textsearch.control"]+    checkOf a >>= assertEqual "satisfied" Success+    again <- runUp a+    assertBool "a second pass succeeds" again++    -- the zip is not needed once the files are there+    checkOf (node (tmp </> "gone.zip") "9.9.9" shaA) >>= assertEqual "satisfied without the zip" Success++    -- a newer release declared: seen, and replaced+    let b = node zipB "9.9.10" shaB+    checkOf b >>= assertEqual "the older release is seen" (Failure (Text.pack (ext </> "pg_textsearch.control") <> " is version 9.9.9, declared 9.9.10"))+    upgraded <- runUp b+    assertBool "upgrades" upgraded+    readFile (lib </> "pg_textsearch.so") >>= assertEqual "the library was replaced" "library, second build\n"+    checkOf b >>= assertEqual "satisfied" Success+    leftovers <- filter (".salmon-part" `isInfixOf`) <$> ((<>) <$> listDirectory lib <*> listDirectory ext)+    assertEqual "no partial file left" [] leftovers++    -- down removes its own names and nothing else+    writeFile (ext </> "plpgsql.control") "somebody else's\n"+    writeFile (lib </> "plpgsql.so") "somebody else's\n"+    down' <- runDown b+    assertBool "goes down" down'+    listDirectory lib >>= assertEqual "the other library stays" ["plpgsql.so"]+    listDirectory ext >>= assertEqual "the other extension stays" ["plpgsql.control"]+    downAgain <- runDown b+    assertBool "down twice" downAgain+  where+    trim = reverse . dropWhile (`elem` ("\n " :: String)) . reverse+    run cmd args input = do+        (code, out, err) <- readProcessWithExitCode cmd args input+        unless (code == ExitSuccess) $ assertFailure (cmd <> " " <> show args <> " failed:\n" <> out <> err)+        pure out+    buildRelease tmp arch version content = do+        let root = tmp </> ("pkg-" <> version)+            extDir = root </> "usr/share/postgresql/17/extension"+            deb = tmp </> ("pg-textsearch-postgresql-17_" <> version <> "-1_" <> arch <> ".deb")+            zipPath = tmp </> ("pg-textsearch-v" <> version <> "-pg17-" <> arch <> ".zip")+        createDirectoryIfMissing True (root </> "DEBIAN")+        createDirectoryIfMissing True (root </> "usr/lib/postgresql/17/lib")+        createDirectoryIfMissing True extDir+        writeFile+            (root </> "DEBIAN/control")+            (unlines ["Package: pg-textsearch-postgresql-17", "Version: " <> version <> "-1", "Architecture: " <> arch, "Maintainer: a test <test@example.invalid>", "Description: a stand-in built by Test.PgTextSearchSpec"])+        writeFile (root </> "usr/lib/postgresql/17/lib/pg_textsearch.so") (content <> "\n")+        writeFile (extDir </> "pg_textsearch.control") ("# stand-in\ndefault_version = '" <> version <> "'\n")+        writeFile (extDir </> ("pg_textsearch--" <> version <> ".sql")) "-- stand-in\n"+        writeFile (extDir </> ("pg_textsearch--9.9.8--" <> version <> ".sql")) "-- stand-in\n"+        _ <- run "dpkg-deb" ["--root-owner-group", "-b", root, deb] ""+        _ <- run "zip" ["-q", "-j", zipPath, deb] ""+        there <- doesFileExist zipPath+        assertBool "the zip was built" there+        pure zipPath++-------------------------------------------------------------------------------+-- Layer 2++{- | The upstream release, on a real PostgreSQL 17, in a disposable+container. It needs the release zip, which this repository does not hold+and no test downloads: point @SALMON_TEST_PG_TEXTSEARCH_ZIP@ at+@pg-textsearch-v1.5.1-pg17-amd64.zip@ (it must be that file: the digest+below is the one upstream publishes for it).+-}+sandboxTests :: TestTree+sandboxTests =+    testGroup+        "Salmon.Builtin.Nodes.PgTextSearch (Layer 2, the upstream release on PostgreSQL 17 in a podman sandbox)"+        [ testCase "preloads, restarts once, sets, creates the extension, ranks with a bm25 index, and goes down" sandbox+        ]++upstreamZipSha256 :: Text+upstreamZipSha256 = "ed4410e8b6879a8971cd87fd03853f182e47b85b199c9bfcd9b34f159caba9fb"++{- | Where the run happens, since the node's file copies are this process's+and its @psql@ and @pg_ctlcluster@ are shimmed into the container:++* 'releaseFiles' runs here, on the real zip, into a temporary directory a+  stand-in @pg_config@ names; what it wrote is then copied into the+  container's own directories with @podman cp@. So the zip, the package+  and the plan are the node's work, and the last copy is the test's.+* 'pgTextSearch' then runs with @sudo@ and @bash@ shimmed: the version+  gate, the preload, the restart, the loaded gate, the settings and the+  extension are the node's, against the container's server.+-}+sandbox :: IO ()+sandbox = requireExecutable "podman" $ requireAll ["dpkg-deb", "dpkg", "unzip", "sh"] $ do+    declared <- lookupEnv "SALMON_TEST_PG_TEXTSEARCH_ZIP"+    case declared of+        Nothing -> hPutStrLn stderr "SKIPPED: SALMON_TEST_PG_TEXTSEARCH_ZIP is not set; this Layer 2 test needs upstream's pg-textsearch-v1.5.1-pg17-amd64.zip"+        Just zipPath -> withTempDir $ \tmp ->+            withContainer (Podman.Image "debian:trixie") (Podman.PortMapping "15441" "5432" Podman.TCPPort) $ \cid -> do+                podmanExec_ cid ["apt-get", "update", "-qq"]+                podmanExec_ cid ["bash", "-c", "DEBIAN_FRONTEND=noninteractive apt-get install -y -qq sudo postgresql"]+                version <- trim <$> sh cid "ls /usr/lib/postgresql | sort -n | tail -n1"+                assertEqual "debian:trixie is expected to ship PostgreSQL 17" "17" version+                sh_ cid "pg_ctlcluster 17 main status >/dev/null || pg_ctlcluster 17 main start"+                waitFor cid "SELECT 1" "1"+                sh_ cid "sudo -u postgres createdb app"++                let lib = tmp </> "lib"+                    ext = tmp </> "share" </> "extension"+                    pgConfig = tmp </> "pg_config"+                    rel = Release "1.5.1" zipPath upstreamZipSha256+                    cfg =+                        (pgTextSearchOn 17 5432 (RestartWhenPending "main") rel)+                            { ptsPgConfig = pgConfig+                            , ptsDatabases = ["app"]+                            , ptsSettings = defaultSettings{setMemoryLimitKb = Just 1048576, setSegmentsPerLevel = Just 4, setAllowRls = Just False, setDefaultLimit = Just 500}+                            }+                    whole = pgTextSearch silent ignoreTrack ignoreTrack ignoreTrack cfg+                createDirectoryIfMissing True lib+                createDirectoryIfMissing True ext+                standInPgConfig pgConfig lib (tmp </> "share")++                -- the files: the node's own work on the real zip+                filesOk <- runUp (releaseFiles 17 pgConfig rel)+                assertBool "the release's files are installed from the upstream zip" filesOk+                names <- sort <$> listDirectory ext+                assertBool ("control and 1.5.1 script present: " <> show names) (all (`elem` names) ["pg_textsearch.control", "pg_textsearch--1.5.1.sql"])+                copyInto cid (lib </> "pg_textsearch.so") "/usr/lib/postgresql/17/lib/pg_textsearch.so"+                forM_ names $ \n -> copyInto cid (ext </> n) ("/usr/share/postgresql/17/extension/" <> n)++                started <- psql cid "postgres" "SELECT pg_postmaster_start_time()"+                withShimmedPath cid ["sudo", "bash"] $ do+                    ok <- runUp whole+                    assertBool "the whole declaration converges" ok+                restarted <- psql cid "postgres" "SELECT pg_postmaster_start_time()"+                assertBool "the cluster was restarted" (started /= restarted)+                psql cid "postgres" "SHOW shared_preload_libraries" >>= assertEqual "preloaded" "pg_textsearch"+                psql cid "postgres" "SHOW pg_textsearch.library_version" >>= assertEqual "the loaded library's version" "1.5.1"+                psql cid "app" "SELECT extversion FROM pg_extension WHERE extname = 'pg_textsearch'" >>= assertEqual "the extension" "1.5.1"+                psql cid "postgres" "SELECT string_agg(name || '=' || setting || '/' || context, ' ' ORDER BY name) FROM pg_settings WHERE name IN ('pg_textsearch.memory_limit','pg_textsearch.segments_per_level','pg_textsearch.allow_rls','pg_textsearch.default_limit')"+                    >>= assertEqual+                        "the settings, and the contexts upstream registers them with"+                        "pg_textsearch.allow_rls=off/superuser pg_textsearch.default_limit=500/user pg_textsearch.memory_limit=1048576/sighup pg_textsearch.segments_per_level=4/superuser"+                psql cid "postgres" ("SELECT string_agg(name, ',' ORDER BY name) FROM pg_settings WHERE name = ANY (string_to_array('" <> Text.unpack (Text.intercalate "," upstreamSettings) <> "', ','))")+                    >>= assertEqual "every setting this module knows is one the server has" (Text.unpack (Text.intercalate "," (sort upstreamSettings)))++                -- what all of it is for+                _ <-+                    psql+                        cid+                        "app"+                        "CREATE TABLE docs (id int PRIMARY KEY, content text); INSERT INTO docs VALUES (1, 'the quick brown fox'), (2, 'postgres is a database system, a database for systems'), (3, 'a database'), (4, 'nothing relevant here at all'); CREATE INDEX docs_idx ON docs USING bm25(content) WITH (text_config='english')"+                psql cid "app" "SELECT string_agg(id::text, ',') FROM (SELECT id FROM docs ORDER BY content <@> 'database system' LIMIT 2) t"+                    >>= assertEqual "ranked: the document with both terms first, then the one with one" "2,3"++                -- a second pass changes nothing, and restarts nothing+                withShimmedPath cid ["sudo", "bash"] $ do+                    again <- runUp whole+                    assertBool "a second pass succeeds" again+                    forM_ (Map.elems (Dag.dagNodes (graph cfg))) $ \act -> do+                        r <- act.extension.check+                        assertBool (Text.unpack act.shorthand <> " is satisfied or has no check: " <> show r) (r `elem` [Success, Immaterial])+                psql cid "postgres" "SELECT pg_postmaster_start_time()" >>= assertEqual "no second restart" restarted++                -- down: refused while an index needs the extension, then clean+                withShimmedPath cid ["sudo", "bash"] $ do+                    refused <- runDown whole+                    assertBool "a database with a bm25 index refuses the drop" (not refused)+                psql cid "postgres" "SHOW shared_preload_libraries" >>= assertEqual "and the refusal stopped the rest" "pg_textsearch"+                _ <- psql cid "app" "DROP TABLE docs"+                withShimmedPath cid ["sudo", "bash"] $ do+                    down' <- runDown whole+                    assertBool "goes down" down'+                psql cid "app" "SELECT count(*) FROM pg_extension WHERE extname = 'pg_textsearch'" >>= assertEqual "extension dropped" "0"+                psql cid "postgres" "SELECT coalesce((SELECT setting FROM pg_file_settings WHERE name = 'shared_preload_libraries' ORDER BY seqno DESC LIMIT 1), '')"+                    >>= assertEqual "out of the files' list" ""+                psql cid "postgres" "SELECT count(*) FROM pg_file_settings WHERE name LIKE 'pg\\_textsearch.%'" >>= assertEqual "settings reset" "0"+                psql cid "postgres" "SELECT pg_postmaster_start_time()" >>= assertEqual "down restarted nothing" restarted+                psql cid "postgres" "SHOW pg_textsearch.library_version" >>= assertEqual "the library stays in the running server" "1.5.1"++                -- and the cluster starts again on what down left in the files:+                -- an emptied list written as '' would not (see 'Postgres.alterPreloadSql')+                sh_ cid "pg_ctlcluster 17 main restart"+                waitFor cid "SHOW shared_preload_libraries" ""+                psql cid "postgres" "SELECT coalesce(current_setting('pg_textsearch.library_version', true), 'none')" >>= assertEqual "gone after the restart" "none"+  where+    trim = reverse . dropWhile (`elem` ("\n " :: String)) . reverse+    exec cid user args = readProcessWithExitCode "podman" (["exec", "-i", "--user", user, cid] <> args) ""+    sh cid script = do+        (code, out, err) <- exec cid "root" ["bash", "-c", script]+        unless (code == ExitSuccess) $ assertFailure (script <> " failed:\n" <> out <> "\n" <> err)+        pure out+    sh_ cid script = () <$ sh cid script+    psql cid db q = do+        (code, out, err) <- exec cid "postgres" ["psql", "-X", "-d", db, "-tAc", q]+        unless (code == ExitSuccess) $ assertFailure (q <> " failed:\n" <> err)+        pure (concat (take 1 (reverse (filter (not . null) (lines out)))))+    waitFor cid q expected = go (60 :: Int) ""+      where+        go 0 lastSeen = assertFailure (q <> " never answered " <> expected <> "; last: " <> lastSeen)+        go n _ = do+            (code, out, err) <- exec cid "postgres" ["psql", "-X", "-tAc", q]+            if code == ExitSuccess && concat (take 1 (lines out)) == expected+                then pure ()+                else threadDelay 1000000 >> go (n - 1 :: Int) (out <> err)+    copyInto cid src dst = do+        (code, out, err) <- readProcessWithExitCode "podman" ["cp", src, cid <> ":" <> dst] ""+        unless (code == ExitSuccess) $ assertFailure ("podman cp " <> src <> " failed:\n" <> out <> err)+        sh_ cid ("chown root:root '" <> dst <> "' && chmod a+rX '" <> dst <> "'")++-- | A @pg_config@ that names two directories, written by a child (see 'writeExecutable').+standInPgConfig :: FilePath -> FilePath -> FilePath -> IO ()+standInPgConfig path lib share =+    writeExecutable path (unlines ["#!/bin/sh", "case \"$1\" in", "  --pkglibdir) echo '" <> lib <> "' ;;", "  --sharedir) echo '" <> share <> "' ;;", "  *) exit 2 ;;", "esac"])++requireAll :: [String] -> IO () -> IO ()+requireAll names act = do+    missing <- fmap concat . mapM (\n -> maybe [n] (const []) <$> findExecutable n) $ names+    case missing of+        [] -> act+        ms -> hPutStrLn stderr ("SKIPPED: " <> unwords ms <> " not found on PATH; the pg_textsearch Layer 1 install test needs them")
+ test/Test/PgVectorDemoSpec.hs view
@@ -0,0 +1,227 @@+{-# LANGUAGE OverloadedStrings #-}++{- | Coverage for "SreBox.PgVectorDemo".++Layer 0 ('tests'): the walk (which files, in what order, what is skipped and+why), the SQL the schema, index and search build, the verdicts drawn from+what @psql@ answers, and the graph's shape.++Layer 2 ('sandboxTests'): the schema, the index and the search for real, on+PostgreSQL 17 with Debian's @postgresql-17-pgvector@ in a podman container+(@sudo@ shimmed into it), with the sha256 stand-in embedder so no model and+no network beyond the image's packages are needed. Skipped loudly without+podman.+-}+module Test.PgVectorDemoSpec (tests, sandboxTests) where++import Control.Concurrent (threadDelay)+import Control.Monad (unless)+import Data.Functor.Identity (runIdentity)+import Data.IORef (modifyIORef', newIORef, readIORef, writeIORef)+import qualified Data.ByteString as ByteString+import qualified Data.Map.Strict as Map+import qualified Data.Text as Text+import GHC.IO.Exception (ExitCode (..))+import System.Directory (createDirectoryIfMissing, createFileLink)+import System.FilePath ((</>))+import System.Process (readProcessWithExitCode)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, assertEqual, assertFailure, testCase)++import Salmon.Actions.Query (pathedNodes)+import Salmon.Actions.UpDown (CheckResult (..))+import Salmon.Builtin.Extension+import qualified Salmon.Builtin.Nodes.Podman as Podman+import Salmon.Builtin.Nodes.Postgres (PgExtension (..))+import qualified Salmon.Builtin.Nodes.Postgres as Postgres+import Salmon.Op.Actions (Act (..), Actions (..))+import Salmon.Op.Eval (expand)+import Salmon.Op.OpGraph (OpGraph (..), inject)+import Salmon.Reporter (ReporterM (..), silent)+import SreBox.PgVectorDemo+import Test.Harness (freeTcpPort, podmanExec_, requireExecutable, runDown, runUp, withContainer, withShimmedPath, withTempDir)++tests :: TestTree+tests =+    testGroup+        "SreBox.PgVectorDemo"+        [ testGroup+            "classify"+            [ testCase "kinds by extension, case-insensitively" $ do+                assertEqual "" (Right TextKind) (classify defaultWalkPolicy both "notes/a.md" 10)+                assertEqual "" (Right ImageKind) (classify defaultWalkPolicy both "IMG.JPG" 10)+                assertEqual "" (Left UnknownExtension) (classify defaultWalkPolicy both "a.pdf" 10)+            , testCase "hidden files and anything under a hidden directory" $ do+                assertEqual "" (Left Hidden) (classify defaultWalkPolicy both ".env.txt" 10)+                assertEqual "" (Left Hidden) (classify defaultWalkPolicy both ".git/description.txt" 10)+            , testCase "a kind the embedder does not take, before the size" $+                assertEqual "" (Left (NotEmbedded ImageKind)) (classify defaultWalkPolicy [TextKind] "a.png" (10 ^ (9 :: Int)))+            , testCase "empty and too large" $ do+                assertEqual "" (Left Empty) (classify defaultWalkPolicy both "a.txt" 0)+                assertEqual "" (Left (TooLarge 65537)) (classify defaultWalkPolicy both "a.txt" 65537)+                assertEqual "" (Right TextKind) (classify defaultWalkPolicy both "a.txt" 65536)+                assertEqual "" (Right ImageKind) (classify defaultWalkPolicy both "a.png" 65537)+            ]+        , testCase "the walk: recursive, sorted, links and hidden directories not entered, every skip said" $ withTempDir $ \dir -> do+            createDirectoryIfMissing True (dir </> "sub")+            createDirectoryIfMissing True (dir </> ".hidden")+            writeFile (dir </> "b.txt") "b"+            writeFile (dir </> "a.md") "a"+            writeFile (dir </> "sub" </> "c.png") "png"+            writeFile (dir </> "sub" </> "empty.txt") ""+            writeFile (dir </> ".hidden" </> "x.txt") "x"+            writeFile (dir </> "doc.pdf") "pdf"+            createFileLink (dir </> "a.md") (dir </> "link.md")+            (files, skipped) <- walk defaultWalkPolicy [TextKind] dir+            assertEqual "files" [("a.md", TextKind), ("b.txt", TextKind)] files+            assertEqual+                "skipped"+                [(".hidden", Hidden), ("doc.pdf", UnknownExtension), ("link.md", SymbolicLink), ("sub/c.png", NotEmbedded ImageKind), ("sub/empty.txt", Empty)]+                skipped+        , testGroup+            "schema"+            [ testCase "the width is the embedder's, with an hnsw cosine index" $ do+                let sql = schemaSql "items" 384+                assertBool "column" ("embedding vector(384) NOT NULL" `Text.isInfixOf` sql)+                assertBool "index" ("CREATE INDEX IF NOT EXISTS \"items_embedding_hnsw\" ON \"items\" USING hnsw (embedding vector_cosine_ops);" `Text.isInfixOf` sql)+                assertBool "another width is refused first" ("found <> 'vector(384)'" `Text.isInfixOf` fst (Text.breakOn "CREATE TABLE" sql))+            , testCase "no index above 2000 dimensions" $+                assertBool "" (not ("CREATE INDEX" `Text.isInfixOf` schemaSql "items" 3072))+            , testCase "a hostile table name is quoted" $+                assertBool "" ("CREATE TABLE IF NOT EXISTS \"a\"\"b\"" `Text.isInfixOf` schemaSql "a\"b" 3)+            , testCase "verdicts" $ do+                assertEqual "absent" (Failure "no table items") (interpretSchemaRow "items" 384 "\n")+                assertEqual "present" Success (interpretSchemaRow "items" 384 "vector(384)|true\n")+                assertEqual "no index" (Failure "no hnsw index on items") (interpretSchemaRow "items" 384 "vector(384)|false")+                assertEqual "no index wanted" Success (interpretSchemaRow "items" 3072 "vector(3072)|false")+                assertEqual "another width" (Failure "items.embedding is vector(512), the embedder produces vector(384)") (interpretSchemaRow "items" 384 "vector(512)|true")+            ]+        , testGroup+            "index"+            [ testCase "what is pending: no row, other content, another embedder; and what is stale" $ do+                let cands = [Candidate "a.txt" TextKind "h1", Candidate "b|c.txt" TextKind "h2", Candidate "d.txt" TextKind "h3", Candidate "e.png" ImageKind "h4"]+                    rows = parseIndexed "a.txt|h1|emb\nb|c.txt|old|emb\nd.txt|h3|other\ngone.txt|h9|emb\n"+                assertEqual "a path holding the separator" (Just ("old", "emb")) (Map.lookup "b|c.txt" rows)+                assertEqual "pending" ["b|c.txt", "d.txt", "e.png"] (candPath <$> pendingFiles "emb" cands rows)+                assertEqual "stale" 1 (staleCount cands rows)+            , testCase "the upsert quotes every value and replaces on the path" $ do+                let sql = upsertSql "items" "emb" (Candidate "it's.txt" TextKind "h") [0.5, -1]+                assertBool "path" ("'it''s.txt'" `Text.isInfixOf` sql)+                assertBool "vector" ("'[0.5,-1.0]'" `Text.isInfixOf` sql)+                assertBool "upsert" ("ON CONFLICT (path) DO UPDATE SET" `Text.isInfixOf` sql)+            , testCase "the content hash is the file's sha256" $+                assertEqual "" "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" (contentHash "hello")+            ]+        , testGroup+            "search"+            [ testCase "cosine distance, among this embedder's rows" $ do+                let sql = searchSql "items" "emb" [1, 0] 5+                assertBool "order" ("ORDER BY embedding <=> '[1.0,0.0]'::vector LIMIT 5;" `Text.isInfixOf` sql)+                assertBool "embedder" ("WHERE embedder = 'emb'" `Text.isInfixOf` sql)+            , testCase "rows, a path holding the separator included" $+                assertEqual "" [Hit "a|b.txt" "text" 0, Hit "c.png" "image" 0.25] (parseHits "a|b.txt|text|0\nc.png|image|0.25\n")+            ]+        , testGroup+            "embedders"+            [ testCase "the stand-in is deterministic, of the width, and never all zero" $ do+                let v = hashVector 100 "salmon"+                assertEqual "width" 100 (length v)+                assertEqual "deterministic" v (hashVector 100 "salmon")+                assertBool "different input, different vector" (v /= hashVector 100 "trout")+                assertBool "in range, not all zero" (all (\x -> x >= -1 && x <= 1) v && any (/= 0) v)+            ]+        , testCase "the graph holds the schema of the embedder's width, the embedder and pgvector" $ do+            let emb = hashEmbedder 8+                demo = defaultDemo "/srv/docs" 17 5432 "app"+                whole = pgVectorDemo silent silent silent ignoreTrack ignoreTrack ignoreTrack demo emb+                paths = [(p, h) | (p, _, h) <- pathedNodes (runIdentity (expand whole))]+                shorthands = snd <$> paths+            assertBool ("the schema names the width: " <> show shorthands) (any ("vector(8)" `Text.isInfixOf`) shorthands)+            assertBool "the stand-in embedder" (any ("stand-in embedder" `Text.isInfixOf`) shorthands)+            assertBool "pgvector" (any ("pgvector for postgresql 17" `Text.isInfixOf`) shorthands)+        ]+  where+    both = [TextKind, ImageKind]++-------------------------------------------------------------------------------+-- Layer 2++sandboxTests :: TestTree+sandboxTests =+    testGroup+        "SreBox.PgVectorDemo (Layer 2, PostgreSQL 17 and pgvector in a podman sandbox)"+        [ testCase "indexes, indexes nothing twice, re-embeds a change, finds a file by its own content, leaves the table on down" sandbox+        ]++sandbox :: IO ()+sandbox = requireExecutable "podman" $ withTempDir $ \tmp -> do+    hostPort <- freeTcpPort+    withContainer (Podman.Image "debian:trixie") (Podman.PortMapping hostPort "5432" Podman.TCPPort) $ \cid -> do+        podmanExec_ cid ["apt-get", "update", "-qq"]+        podmanExec_ cid ["bash", "-c", "DEBIAN_FRONTEND=noninteractive apt-get install -y -qq sudo postgresql postgresql-17-pgvector"]+        podmanExec_ cid ["bash", "-c", "pg_ctlcluster 17 main status >/dev/null || pg_ctlcluster 17 main start"]+        waitForServer cid+        podmanExec_ cid ["sudo", "-u", "postgres", "createdb", "app"]++        let dir = tmp </> "docs"+        createDirectoryIfMissing True (dir </> "pictures")+        writeFile (dir </> "salmon.txt") "salmon swim upstream to spawn"+        writeFile (dir </> "trout.md") "trout live in rivers and lakes"+        ByteString.writeFile (dir </> "pictures" </> "fish.png") "\x89PNG not really an image, but the stand-in reads bytes"+        writeFile (dir </> "notes.pdf") "skipped"++        events <- newIORef []+        let emb = hashEmbedder 16+            demo = defaultDemo dir 17 5432 "app"+            ext = PgExtension{extName = "vector", extDatabase = "app", extMinServerVersion = Just 130000, extUpgrade = False}+            reporter = ReporterM (\e -> modifyIORef' events (e :))+            whole =+                indexDirectory reporter demo emb+                    `inject` (schema demo emb.embDimension `inject` Postgres.extension silent ignoreTrack 5432 ignoreTrack ext)+                    `inject` emb.embNode+            indexedNow = do+                es <- readIORef events+                writeIORef events []+                pure [p | Indexed p _ <- reverse es]++        withShimmedPath cid ["sudo"] $ do+            ok <- runUp whole+            assertBool "the first pass converges" ok+            indexedNow >>= assertEqual "three files indexed" ["pictures/fish.png", "salmon.txt", "trout.md"]+            checkOf (indexDirectory silent demo emb) >>= assertEqual "the index is satisfied" Success+            checkOf (schema demo 16) >>= assertEqual "the schema is satisfied" Success+            checkOf (schema demo 32)+                >>= assertEqual "another width is seen" (Failure "items.embedding is vector(16), the embedder produces vector(32)")++            again <- runUp whole+            assertBool "a second pass converges" again+            indexedNow >>= assertEqual "and embeds nothing" []++            writeFile (dir </> "trout.md") "trout, now with a changed description"+            checkOf (indexDirectory silent demo emb) >>= assertEqual "a change is noticed" (Failure "1 file(s) without a current row")+            _ <- runUp whole+            indexedNow >>= assertEqual "only the changed file is re-embedded" ["trout.md"]++            hits <- search demo emb (InputText "salmon swim upstream to spawn") 2+            case hits of+                Right (Hit "salmon.txt" "text" d : _) | d < 1.0e-6 -> pure ()+                other -> assertFailure ("the nearest neighbour of a file's content is that file, got " <> show other)+            imageHits <- search demo emb (InputImage (dir </> "pictures" </> "fish.png")) 1+            assertEqual "and of an image, itself" (Right ["pictures/fish.png"]) (fmap (fmap hitPath) imageHits)++            down <- runDown (indexDirectory silent demo emb `inject` schema demo emb.embDimension)+            assertBool "down succeeds" down+        (code, out, _) <- readProcessWithExitCode "podman" ["exec", "-i", "--user", "postgres", cid, "psql", "-X", "-d", "app", "-tAc", "SELECT count(*) FROM items"] ""+        assertEqual "down leaves the rows" (ExitSuccess, "3") (code, takeWhile (/= '\n') out)+  where+    waitForServer cid = go (60 :: Int)+      where+        go 0 = assertFailure "the cluster never answered"+        go n = do+            (code, _, _) <- readProcessWithExitCode "podman" ["exec", "-i", "--user", "postgres", cid, "psql", "-X", "-tAc", "SELECT 1"] ""+            unless (code == ExitSuccess) (threadDelay 1000000 >> go (n - 1))++checkOf :: Op -> IO CheckResult+checkOf o = case o.node of+    Actions act -> act.extension.check+    Actionless -> pure Immaterial
test/Test/PlakarSpec.hs view
@@ -41,6 +41,9 @@ job :: PlakarJob job = PlakarJob "nightly" store "/srv/data" (dailyAt "3" "17") "root" (keepDays 30) (26 * 3600) "/opt/salmon/plakar/nightly.sh" +gcs :: GcsStore+gcs = GcsStore "backups" "bkt" "/pre/fix" "/etc/sa.json" "application_credentials" Nothing+ tests :: TestTree tests =     testGroup@@ -93,6 +96,29 @@                 assertBool "" ("'/srv/it'\\''s'" `Text.isInfixOf` renderBackupScript job{jobSource = "/srv/it's"} ["-days", "1"])             , testCase "the digest is lower-case hex" $                 assertEqual "" "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" (sha256Hex "")+            ]+        , testGroup+            "integrations and the GCS store"+            [ testCase "pinned and latest render as pkg add arguments" $ do+                assertEqual "" "gcs@1.0.2" (pkgAddArg (Integration "gcs" (Pinned "1.0.2") Nothing))+                assertEqual "" "gcs" (pkgAddArg (Integration "gcs" Latest Nothing))+            , testCase "a pinned integration is found at its version" $+                assertEqual "" Success (interpretPkgList (Integration "gcs" (Pinned "1.0.2") Nothing) ExitSuccess "gcs@v1.0.2\n")+            , testCase "another version is not installed" $+                assertBool "" (interpretPkgList (Integration "gcs" (Pinned "1.0.2") Nothing) ExitSuccess "gcs@v1.0.1\n" /= Success)+            , testCase "latest accepts any version, but not another name" $ do+                assertEqual "" Success (interpretPkgList (Integration "gcs" Latest Nothing) ExitSuccess "gcs v0.3\n")+                assertBool "" (interpretPkgList (Integration "gcs" Latest Nothing) ExitSuccess "s3 v0.3\n" /= Success)+            , testCase "a failing listing is cannot tell" $+                assertEqual "" Unknown (interpretPkgList (Integration "gcs" Latest Nothing) (ExitFailure 1) "")+            , testCase "the store's arguments name only the credentials path" $+                assertEqual "" ["backups", "gcs://bkt/pre/fix", "application_credentials=/etc/sa.json"] (gcsStoreAddArgs gcs)+            , testCase "no prefix, no trailing slash" $+                assertEqual "" "gcs://bkt" (gcsStoreAddArgs gcs{gcsPrefix = ""} !! 1)+            , testCase "the store is found when shown with its location" $+                assertEqual "" Success (interpretStoreShow gcs ExitSuccess "backups  location=gcs://bkt/pre/fix\n")+            , testCase "an absent store is a failure" $+                assertBool "" (interpretStoreShow gcs ExitSuccess "other location=gcs://x\n" /= Success)             ]         , testCase "a job with no retention rule is refused rather than run" $             assertBool "" (either (const True) (const False) (pruneArgs (jobKeep job{jobKeep = KeepPolicy Nothing Nothing Nothing})))
test/Test/PodmanCommandSpec.hs view
@@ -4,12 +4,30 @@ -} module Test.PodmanCommandSpec (tests) where -import System.Process (CmdSpec (..), cmdspec)+import Control.Exception (SomeException, throwIO, try)+import Data.IORef (IORef, modifyIORef, newIORef, readIORef, writeIORef)+import Data.Text (Text)+import qualified Data.Text as Text+import Data.Time.Clock (addUTCTime, getCurrentTime)+import Data.Time.Clock.POSIX (posixSecondsToUTCTime)+import System.Directory (createDirectoryIfMissing, doesFileExist)+import System.FilePath (takeDirectory, (</>))+import System.Process (CmdSpec (..), cmdspec, cwd) import Test.Tasty (TestTree, testGroup)-import Test.Tasty.HUnit (assertEqual, testCase)+import Test.Tasty.HUnit (assertBool, assertEqual, assertFailure, testCase)+import Test.Tasty.QuickCheck (Gen, elements, forAll, oneof, testProperty, (===))+import qualified Test.Tasty.QuickCheck as QC +import Salmon.Op.Ref (mkRef)+import System.Exit (ExitCode (..))++import Salmon.Actions.UpDown (CheckResult (..))+import Salmon.Builtin.Extension import Salmon.Builtin.Nodes.Binary (Command (..)) import qualified Salmon.Builtin.Nodes.Podman as Podman+import Salmon.Op.Actions (Act (..))+import Salmon.Reporter (silent)+import Test.Harness (withTempDir)  tests :: TestTree tests =@@ -19,8 +37,177 @@         , testCase "push with an authfile passes --authfile to push, not to podman itself" pushRendersTagWithAuth         , testCase "logout targets the same authfile login wrote to" logoutRendersAuthFile         , testCase "rmi tolerates an image that is already gone" rmiIgnoresMissing+        , testCase "build with no options runs in the Containerfile's directory, as it always did" buildDefault+        , testCase "the options-less Build constructor renders the same as BuildWith defaultBuildOptions" buildConstructorsAgree+        , testCase "build with a target adds --target and still runs in the Containerfile's directory" buildTargetOnly+        , testCase "build with a context passes it as the positional argument and leaves the working directory alone" buildContextOnly+        , testCase "build with a context and a target" buildContextAndTarget+        , testGroup "login renewal" loginTests+        , testGroup "runContainer's check" runCheckTests         ] +{- | 'Podman.loginExpiring' and 'Podman.pushLoggingIn', as far as they go+without a registry: the stamp's reading, and the node's check\/up\/down+around an authentication that is a stand-in ('Podman.expiring' takes it as+an argument for this). No @podman login@ runs here.+-}+loginTests :: [TestTree]+loginTests =+    [ testCase "no stamp is not logged in" $+        assertBool "" (isFailure (Podman.interpretLoginStamp now Nothing))+    , testCase "a credential with life left is left alone" $+        assertEqual "" Success (Podman.interpretLoginStamp now (Just "1700003000\n"))+    , testCase "a credential inside the margin, or past it, is renewed" $ do+        assertBool "" (isFailure (Podman.interpretLoginStamp now (Just "1700000120")))+        assertBool "" (isFailure (Podman.interpretLoginStamp now (Just "1699990000")))+        assertEqual "" Success (Podman.interpretLoginStamp now (Just "1700000121"))+    , testCase "an hour-long credential is renewed within the hour, not after it" $ do+        let stamp = Just (Text.pack (Podman.renderLoginStamp (addUTCTime 3600 now)))+        assertEqual "" Success (Podman.interpretLoginStamp (addUTCTime 3400 now) stamp)+        assertBool "" (isFailure (Podman.interpretLoginStamp (addUTCTime 3500 now) stamp))+    , testCase "an unreadable stamp is renewed, not trusted" $+        assertBool "" (isFailure (Podman.interpretLoginStamp now (Just "soon")))+    , testCase "the node is not satisfied until it logged in, then is, with the expiry recorded" $+        withLogin (Podman.Credential "tok-1" 3600) $ \authfile said ext -> do+            before <- ext.check+            assertBool "nothing is logged in yet" (isFailure before)+            started <- getCurrentTime+            ext.up+            readIORef said >>= assertEqual "one login, with the credential's secret" ["tok-1"]+            recorded <- readFile (Podman.loginStampPath authfile)+            assertEqual "the expiry is an hour on" Success (Podman.interpretLoginStamp (addUTCTime 3400 started) (Just (Text.pack recorded)))+            assertBool "and no later" (isFailure (Podman.interpretLoginStamp (addUTCTime 3500 started) (Just (Text.pack recorded))))+            after <- ext.check+            assertEqual "a fresh login is left alone" Success after+    , testCase "a recorded expiry that has come is a login again" $+        withLogin (Podman.Credential "tok-2" 3600) $ \authfile said ext -> do+            ext.up+            -- an hour later, as far as the stamp can tell+            writeFile (Podman.loginStampPath authfile) "1700000000\n"+            lapsed <- ext.check+            assertBool "the lapsed credential is noticed" (isFailure lapsed)+            ext.up+            readIORef said >>= assertEqual "logged in a second time" ["tok-2", "tok-2"]+            renewed <- ext.check+            assertEqual "" Success renewed+    , testCase "a stamp with no auth file beside it vouches for nothing" $+        withLogin (Podman.Credential "tok" 3600) $ \authfile _ ext -> do+            createDirectoryIfMissing True (takeDirectory (Podman.getAuthFile authfile))+            writeFile (Podman.loginStampPath authfile) "9999999999\n"+            verdict <- ext.check+            assertBool "" (isFailure verdict)+    , testCase "a login that is refused throws and records nothing" $+        withTempDir $ \tmp -> do+            let authfile = Podman.AuthFile (tmp </> "auth.json")+                refused _ = throwIO (userError "unauthorized")+                ext = Podman.expiring authfile (pure (Podman.Credential "tok" 3600)) refused (loginExtension authfile)+            outcome <- try ext.up :: IO (Either SomeException ())+            assertBool "the failure is not swallowed" (either (const True) (const False) outcome)+            doesFileExist (Podman.loginStampPath authfile) >>= assertEqual "no stamp vouches for a login that failed" False+    , testCase "a credential the margin already covers is refused before it is used" $+        withLogin (Podman.Credential "tok" Podman.loginRefreshMargin) $ \authfile said ext -> do+            outcome <- try ext.up :: IO (Either SomeException ())+            assertBool "" (either (const True) (const False) outcome)+            readIORef said >>= assertEqual "it was not logged in with" []+            doesFileExist (Podman.loginStampPath authfile) >>= assertEqual "" False+    , testCase "going down takes the stamp with the credentials" $+        withLogin (Podman.Credential "tok" 3600) $ \authfile _ ext -> do+            ext.up+            ext.down+            doesFileExist (Podman.loginStampPath authfile) >>= assertEqual "the stamp" False+            doesFileExist (Podman.getAuthFile authfile) >>= assertEqual "the auth file" False+    , testCase "an expiring login is the same effect site as a plain one, described differently" $ do+        let authfile = Podman.AuthFile "/etc/app/auth.json"+            plain = Podman.login silent ignoreTrack authfile registry user (pure "tok")+            tended = Podman.loginExpiring silent ignoreTrack authfile registry user (pure (Podman.Credential "tok" 3600))+        assertEqual "" (refOf plain) (refOf tended)+        assertBool "" (notesOf plain /= notesOf tended)+        plainCheck <- maybe (assertFailure "no node") (\act -> act.extension.check) (opAct plain)+        assertEqual "the plain login still has nothing to ask" Immaterial plainCheck+    , testCase "a push that logs in is the same effect site as a push on that auth file" $ do+        let authfile = Podman.AuthFile "/etc/app/auth.json"+            plain = Podman.push silent ignoreTrack (Just authfile) "europe-docker.pkg.dev/p/r/img:1"+            renewing = Podman.pushLoggingIn silent ignoreTrack authfile registry user (pure "tok") "europe-docker.pkg.dev/p/r/img:1"+        assertEqual "" (refOf plain) (refOf renewing)+        assertBool "" (notesOf plain /= notesOf renewing)+    , testCase "the login comes before the push, and a refused login is not followed by one" $ do+        order <- newIORef ([] :: [String])+        Podman.loggingInThen (modifyIORef order (<> ["login"])) (modifyIORef order (<> ["push"]))+        readIORef order >>= assertEqual "" ["login", "push"]+        writeIORef order []+        outcome <- try (Podman.loggingInThen (throwIO (userError "unauthorized")) (modifyIORef order (<> ["push"]))) :: IO (Either SomeException ())+        assertBool "" (either (const True) (const False) outcome)+        readIORef order >>= assertEqual "nothing was pushed on the old credentials" []+    ]+  where+    now = posixSecondsToUTCTime 1700000000+    registry = Podman.Registry "europe-docker.pkg.dev"+    user = Podman.Username "oauth2accesstoken"++    isFailure (Failure _) = True+    isFailure _ = False++    refOf o = fmap (\act -> act.extension.ref) (opAct o)+    notesOf o = fmap (\act -> act.extension.notes) (opAct o)++    -- the plain login node's own extension: its @down@ is the one under test+    loginExtension :: Podman.AuthFile -> Extension+    loginExtension authfile =+        case opAct (Podman.login silent ignoreTrack authfile registry user (pure "unused")) of+            Just act -> act.extension+            Nothing -> error "Podman.login is not a node"++    -- an expiring login whose authentication writes the auth file and+    -- remembers the password it was given, in place of @podman login@+    withLogin :: Podman.Credential -> (Podman.AuthFile -> IORef [Text] -> Extension -> IO ()) -> IO ()+    withLogin credential body =+        withTempDir $ \tmp -> do+            said <- newIORef []+            let authfile = Podman.AuthFile (tmp </> "auth" </> "auth.json")+                authenticate pw = do+                    createDirectoryIfMissing True (takeDirectory (Podman.getAuthFile authfile))+                    writeFile (Podman.getAuthFile authfile) "{\"auths\":{}}"+                    modifyIORef said (<> [pw])+            body authfile said (Podman.expiring authfile (pure credential) authenticate (loginExtension authfile))++build :: Podman.BuildOptions -> (CmdSpec, Maybe FilePath)+build opts =+    let p = prepare Podman.podmanCommand (Podman.BuildWith opts "/repo/deploy/Containerfile" "img:1")+     in (cmdspec p, cwd p)++buildDefault :: IO ()+buildDefault =+    assertEqual+        ""+        (RawCommand "podman" ["build", "-t", "img:1", "-f", "Containerfile"], Just "/repo/deploy")+        (build Podman.defaultBuildOptions)++buildConstructorsAgree :: IO ()+buildConstructorsAgree =+    let p = prepare Podman.podmanCommand (Podman.Build "/repo/deploy/Containerfile" "img:1")+     in assertEqual "" (build Podman.defaultBuildOptions) (cmdspec p, cwd p)++buildTargetOnly :: IO ()+buildTargetOnly =+    assertEqual+        ""+        (RawCommand "podman" ["build", "-t", "img:1", "-f", "Containerfile", "--target", "api"], Just "/repo/deploy")+        (build Podman.defaultBuildOptions{Podman.buildTarget = Just "api"})++buildContextOnly :: IO ()+buildContextOnly =+    assertEqual+        ""+        (RawCommand "podman" ["build", "-t", "img:1", "-f", "/repo/deploy/Containerfile", "/repo"], Nothing)+        (build Podman.defaultBuildOptions{Podman.buildContext = Just "/repo"})++buildContextAndTarget :: IO ()+buildContextAndTarget =+    assertEqual+        ""+        (RawCommand "podman" ["build", "-t", "img:1", "-f", "/repo/deploy/Containerfile", "--target", "api", "/repo"], Nothing)+        (build Podman.defaultBuildOptions{Podman.buildContext = Just "/repo", Podman.buildTarget = Just "api"})+ pushRendersTagNoAuth :: IO () pushRendersTagNoAuth =     assertEqual@@ -48,3 +235,77 @@         ""         (RawCommand "podman" ["rmi", "--ignore", "us-docker.pkg.dev/p/r/img:1"])         (cmdspec (prepare Podman.podmanCommand (Podman.RmiTag "us-docker.pkg.dev/p/r/img:1")))++{- | 'Podman.runContainer''s check and its leftover removal, without podman:+the two commands' argv, what their answers read as, and the verdict on each+state. Running them is "Test.PodmanSpec" (Layer 2).+-}+runCheckTests :: [TestTree]+runCheckTests =+    [ testCase "the probes are `podman container exists` and an inspect of .State.Status" $ do+        assertEqual "" ["container", "exists", "web"] (Podman.containerExistsArgs cname)+        assertEqual "" ["container", "inspect", "--format", "{{.State.Status}}", "web"] (Podman.containerStatusArgs cname)+    , testCase "a leftover is removed with `podman rm` and no -f; down keeps -f" $ do+        assertEqual "" (RawCommand "podman" ["rm", "web"]) (cmdspec (prepare Podman.podmanCommand (Podman.RmStopped cname)))+        assertEqual "" (RawCommand "podman" ["rm", "-f", "web"]) (cmdspec (prepare Podman.podmanCommand (Podman.Rm cname)))+    , testCase "the node's ref, help and notes are what they were" $ do+        let o = Podman.runContainer silent ignoreTrack Podman.dockerRegistry Podman.ubuntuLatest cname Podman.noRunOptions+        assertEqual "" (Just (mkRef "podman-run" ("web" :: Text))) (fmap (\act -> act.extension.ref) (opAct o))+        assertEqual "" (Just "runs a podman container") (fmap (\act -> act.extension.help) (opAct o))+        assertEqual "" (Just []) (fmap (\act -> act.extension.notes) (opAct o))+    , testCase "reading the answers" $ do+        assertEqual "exists says no" Podman.ContainerAbsent (Podman.containerStateFrom (ExitFailure 1) Nothing)+        assertEqual "exists cannot tell" Podman.ContainerUnanswered (Podman.containerStateFrom (ExitFailure 125) Nothing)+        assertEqual "running" (Podman.ContainerStatus "running") (Podman.containerStateFrom ExitSuccess (Just (ExitSuccess, "running\n")))+        assertEqual "inspect failed (removed in between)" Podman.ContainerUnanswered (Podman.containerStateFrom ExitSuccess (Just (ExitFailure 125, "")))+        assertEqual "empty output" Podman.ContainerUnanswered (Podman.containerStateFrom ExitSuccess (Just (ExitSuccess, "  \n")))+    , testCase "verdicts" $ do+        assertEqual "running is in place" Success (verdict (Podman.ContainerStatus "running"))+        assertBool "absent is to apply" (isFailure (verdict Podman.ContainerAbsent))+        assertBool "exited is to apply" (isFailure (verdict (Podman.ContainerStatus "exited")))+        assertBool "created (a failed start) is to apply" (isFailure (verdict (Podman.ContainerStatus "created")))+        assertEqual "paused is not decided here" Unknown (verdict (Podman.ContainerStatus "paused"))+        assertEqual "stopping is transitional" Unknown (verdict (Podman.ContainerStatus "stopping"))+        assertEqual "no answer" Unknown (verdict Podman.ContainerUnanswered)+    , testCase "only a container that is there and not running is removed" $ do+        assertBool "" (Podman.isRemovableLeftover (Podman.ContainerStatus "exited"))+        assertBool "" (not (Podman.isRemovableLeftover (Podman.ContainerStatus "running")))+        assertBool "" (not (Podman.isRemovableLeftover (Podman.ContainerStatus "paused")))+        assertBool "" (not (Podman.isRemovableLeftover Podman.ContainerAbsent))+        assertBool "" (not (Podman.isRemovableLeftover Podman.ContainerUnanswered))+    , testProperty "a leftover up removes is never one the check calls in place" $+        forAll genState $ \st ->+            QC.classify (Podman.isRemovableLeftover st) "leftover" $+                not (Podman.isRemovableLeftover st) || isFailure (verdict st)+    , testProperty "the check calls a container in place exactly when it is running" $+        forAll genState $ \st -> (verdict st == Success) === (st == Podman.ContainerStatus "running")+    , testProperty "a podman that cannot answer `exists` is never read as absent or present" $+        forAll (elements [2, 125, 126, 127 :: Int]) $ \code ->+            forAll genAnswer $ \answer ->+                verdict (Podman.containerStateFrom (ExitFailure code) answer) === Unknown+    , testProperty "the status is read whatever its case and surrounding space" $+        forAll (elements statuses) $ \s ->+            forAll (QC.chooseInt (0, 3)) $ \pad ->+                Podman.containerStateFrom ExitSuccess (Just (ExitSuccess, Text.replicate pad " " <> Text.toUpper s <> "\n"))+                    === Podman.ContainerStatus s+    , testProperty "an exists that says no is absent, whatever inspect would have said" $+        forAll genAnswer $ \answer -> Podman.containerStateFrom (ExitFailure 1) answer === Podman.ContainerAbsent+    ]+  where+    cname = Podman.ContainerName "web"+    verdict = Podman.interpretContainerState cname+    isFailure (Failure _) = True+    isFailure _ = False++    -- podman's own status words, plus ones it does not use+    statuses :: [Text]+    statuses = ["configured", "created", "initialized", "running", "paused", "stopping", "stopped", "exited", "removing", "unknown", "frobnicated"]++    genState :: Gen Podman.ContainerState+    genState = oneof [pure Podman.ContainerAbsent, pure Podman.ContainerUnanswered, Podman.ContainerStatus <$> elements statuses]++    genExit :: Gen ExitCode+    genExit = oneof [pure ExitSuccess, ExitFailure <$> elements [1, 2, 125]]++    genAnswer :: Gen (Maybe (ExitCode, Text))+    genAnswer = oneof [pure Nothing, (\c s -> Just (c, s)) <$> genExit <*> elements ("" : statuses)]
test/Test/PodmanSpec.hs view
@@ -11,23 +11,126 @@ -- accompanying teardown story yet. module Test.PodmanSpec (tests) where +import Control.Exception (finally)+import Control.Monad (void)+import Data.Char (toLower) import Data.List (isInfixOf)+import qualified Data.Text as Text+import qualified Salmon.Builtin.Nodes.Filesystem as FS+import qualified Salmon.Builtin.Nodes.Binary as Binary+import qualified Salmon.Builtin.Nodes.Podman as Podman+import System.Directory (createDirectory) import System.Exit (ExitCode (..))+import System.FilePath (takeFileName, (</>)) import System.Process (readProcessWithExitCode) import Test.Harness-import qualified Salmon.Builtin.Nodes.Podman as Podman import Test.Tasty (TestTree, testGroup)-import Test.Tasty.HUnit (assertBool, testCase)+import Test.Tasty.HUnit (assertBool, assertEqual, testCase)  tests :: TestTree tests =     testGroup         "Salmon.Builtin.Nodes.Podman (Layer 2, dogfooded)"         [ testCase "pullImage + runContainer bring up a real, reachable container" pullAndRun+        , testCase "runContainer: a second run up skips a running container, and a stopped one is replaced" runTwiceThenStopped+        , testCase "buildImageWith builds one stage of a multi-stage file against a context that is not the file's directory" buildContextAndTarget         ] +{- | A Containerfile in @deploy\/@ whose @COPY@ sources are relative to the+directory /above/ it, and which has two stages. Built with the default+options this cannot work (the marker is not in @deploy\/@), so a passing+build is the context being honoured; the image holding the first stage's+file and not the second's is the target being honoured.+-}+buildContextAndTarget :: IO ()+buildContextAndTarget = requireExecutable "podman" $+    withTempDir $ \root -> do+        -- the temp directory's name is already unique to this run+        let tag = Text.pack ("localhost/salmon-test-build-" <> map toLower (takeFileName root) <> ":first")+            containerfile = root </> "deploy" </> "Containerfile"+        createDirectory (root </> "deploy")+        writeFile (root </> "marker.txt") "from the context root\n"+        writeFile containerfile $+            unlines+                [ "FROM docker.io/library/alpine:latest AS first"+                , "COPY marker.txt /first.txt"+                , "FROM docker.io/library/alpine:latest AS second"+                , "COPY marker.txt /second.txt"+                ]+        (reporter, _) <- capture+        let opts = Podman.defaultBuildOptions{Podman.buildContext = Just root, Podman.buildTarget = Just "first"}+            built = Podman.buildImageWith reporter podmanTrack opts (FS.PreExisting containerfile) tag+            imageExists = (\(code, _, _) -> code) <$> readProcessWithExitCode "podman" ["image", "exists", Text.unpack tag] ""+        ( do+            ok <- runUp built+            assertBool "the build succeeds" ok+            (code, out, _) <- readProcessWithExitCode "podman" ["run", "--rm", Text.unpack tag, "ls", "/"] ""+            assertEqual "the image runs" ExitSuccess code+            assertBool "the first stage's file, copied from the context root, is there" ("first.txt" `elem` lines out)+            assertBool "the second stage was not built into this image" ("second.txt" `notElem` lines out)+            )+            `finally` void (runDown built)+        gone <- imageExists+        assertBool "down removed the image" (gone /= ExitSuccess)+ pullAndRun :: IO ()-pullAndRun = requireExecutable "podman" $-    withContainer (Podman.Image "alpine:latest") (Podman.PortMapping "18080" "80" Podman.TCPPort) $ \cid -> do+pullAndRun = requireExecutable "podman" $ do+    -- Not a fixed host port: it used to be 18080, and two runs of the suite+    -- on one host (or a container a killed run left up) then made this fail+    -- at `podman run` with "address already in use".+    hostPort <- freeTcpPort+    withContainer (Podman.Image "alpine:latest") (Podman.PortMapping hostPort "80" Podman.TCPPort) $ \cid -> do         (code, out, _) <- readProcessWithExitCode "podman" ["inspect", "--format", "{{.State.Running}}", cid] ""         assertBool "podman reports the container as Running" (code == ExitSuccess && "true" `isInfixOf` out)++{- | The acceptance the customer stated: @run up@ twice, the second pass+skips @podman run@ and succeeds; then @podman stop@, and @run up@ brings the+container back (the stopped leftover is removed and run again).+-}+runTwiceThenStopped :: IO ()+runTwiceThenStopped = requireExecutable "podman" $ do+    cname <- freshContainerName+    (reporter, readBack) <- capture+    let reg = Podman.dockerRegistry+        img = Podman.Image "alpine:latest"+        name = Text.unpack (Podman.getContainerName cname)+        pullOp = Podman.pullImage reporter podmanTrack reg img+        runOp = Podman.runContainer reporter podmanTrack reg img cname Podman.noRunOptions+        runs = length . filter isRunStopped <$> readBack+        removals = length . filter isRemoveStopped <$> readBack+        running = do+            (code, out, _) <- readProcessWithExitCode "podman" ["container", "inspect", "--format", "{{.State.Status}}", name] ""+            pure (code == ExitSuccess && "running" `isInfixOf` out)+    ( do+        pulled <- runUp pullOp+        assertBool "the image is pulled" pulled+        first <- runUp runOp+        assertBool "the first run up succeeds" first+        assertEqual "the first run up ran podman run" 1 =<< runs+        assertBool "the container runs" =<< running+        second <- runUp runOp+        assertBool "the second run up succeeds" second+        assertEqual "the second run up did not run podman run" 1 =<< runs+        (stopCode, _, _) <- readProcessWithExitCode "podman" ["stop", "-t", "0", name] ""+        assertEqual "podman stop" ExitSuccess stopCode+        assertBool "the container is stopped" . not =<< running+        third <- runUp runOp+        assertBool "run up after a stop succeeds" third+        assertEqual "the stopped leftover was removed" 1 =<< removals+        assertEqual "and podman run ran again" 2 =<< runs+        assertBool "the container runs again" =<< running+        )+        `finally` void (runDown runOp)+    (code, _, _) <- readProcessWithExitCode "podman" ["container", "exists", name] ""+    assertBool "down removed the container" (code /= ExitSuccess)+  where+    isRunStopped r = case r of+        Podman.RunContainer _ _ _ _ b -> stopped b+        _ -> False+    isRemoveStopped r = case r of+        Podman.RemoveContainer _ b -> stopped b+        _ -> False+    stopped b = case b of+        Binary.CommandStopped{} -> True+        Binary.Requested _ inner -> stopped inner+        _ -> False
+ test/Test/PortMappingSpec.hs view
@@ -0,0 +1,136 @@+{-# LANGUAGE OverloadedStrings #-}++{- | Layer 0 coverage for "Salmon.Builtin.Nodes.PortMapping": the parsers over+captured @upnpc@ output, and the plan/verdict each listing leads to. No+gateway, no network.++Two fixtures are real captures (miniupnpc 2.2.6, an owner's LAN, UUID+redacted): "nothing answered" and "a WPS device answered but it is not an+IGD". The listings of a real IGD are written from @upnpc.c@'s format and have+not been captured from a live router.+-}+module Test.PortMappingSpec (tests) where++import Data.Text (Text)+import qualified Data.Text as Text+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, assertEqual, testCase)++import Salmon.Actions.UpDown (CheckResult (..))+import Salmon.Builtin.Nodes.PortMapping++notIgdOutput :: Text+notIgdOutput =+    Text.unlines+        [ "No valid UPNP Internet Gateway Device found."+        , "upnpc : miniupnpc library test client, version 2.2.6."+        , " (c) 2005-2024 Thomas Bernard."+        , "List of UPNP devices found on the network :"+        , " desc: http://192.168.1.1:1990/<uuid>/WFADevice.xml"+        , " st: upnp:rootdevice"+        , ""+        , "UPnP device found. Is it an IGD ? : http://192.168.1.1:1990/"+        ]++noDeviceOutput :: Text+noDeviceOutput =+    Text.unlines+        [ "No valid UPNP Internet Gateway Device found."+        , "upnpc : miniupnpc library test client, version 2.2.6."+        ]++igdListing :: Text -> [Text] -> Text+igdListing ext rows =+    Text.unlines $+        [ "upnpc : miniupnpc library test client, version 2.2.6."+        , "List of UPNP devices found on the network :"+        , "Found valid IGD : http://192.168.1.1:5000/ctl/IPConn"+        , "Local LAN ip address : 192.168.1.5"+        , "Connection Type : IP_Routed"+        , "Status : Connected, uptime=100s, LastConnectionError : ERROR_NONE"+        , "ExternalIPAddress = " <> ext+        , " i protocol exPort->inAddr:inPort description remoteHost leaseTime"+        ]+            <> rows++pm :: PortMap+pm = PortMap "wg0" "192.168.1.5" 51820 51820 UDP 3600++ours :: Text+ours = " 0 UDP 51820->192.168.1.5:51820 'salmon:wg0' '' 3600"++tests :: TestTree+tests =+    testGroup+        "Salmon.Builtin.Nodes.PortMapping"+        [ testCase "gateway: nothing answered" $ assertEqual "" NoDevice (parseGateway noDeviceOutput)+        , testCase "gateway: a device answered but is not an IGD (real capture)" $ assertEqual "" NotIgd (parseGateway notIgdOutput)+        , testCase "gateway: an IGD with its external address" $+            assertEqual "" (Igd (Just "203.0.113.7") (Just "192.168.1.5")) (parseGateway (igdListing "203.0.113.7" []))+        , testCase "mappings: a listing is parsed, including a description with spaces" $+            assertEqual+                ""+                [ Mapping UDP 51820 "192.168.1.5" 51820 "salmon:wg0" "" 3600+                , Mapping TCP 80 "192.168.1.9" 8080 "my web server" "" 0+                ]+                (parseMappings (igdListing "203.0.113.7" [ours, " 1 TCP    80->192.168.1.9:8080  'my web server' '' 0"]))+        , testCase "mappings: header and garbage lines are skipped" $+            assertEqual "" [] (parseMappings (igdListing "203.0.113.7" ["GetGenericPortMappingEntry() returned 713 (SpecifiedArrayIndexInvalid)"]))+        , testCase "address classes" $+            assertEqual+                ""+                [Public, Private, Private, Private, CGNAT, CGNAT, Public, Public, Unparseable]+                (map classifyAddress ["203.0.113.7", "10.1.2.3", "172.16.0.1", "192.168.1.1", "100.64.0.1", "100.127.255.1", "100.128.0.1", "172.32.0.1", "not an address"])+        , testCase "plan: present with our address is in place, check Success" $ do+            let out = igdListing "203.0.113.7" [ours]+            assertEqual "" InPlace (planFor pm out)+            assertBool "success" (isSuccess (checkOf (planFor pm out)))+        , testCase "plan: absent needs an add, check Failure" $ do+            let out = igdListing "203.0.113.7" []+            assertBool "needs add" (isNeedsAdd (planFor pm out))+            assertBool "failure" (isFailure (checkOf (planFor pm out)))+        , testCase "plan: a hand-configured map pointing at us is satisfied (not ours to delete)" $ do+            let out = igdListing "203.0.113.7" [" 0 UDP 51820->192.168.1.5:51820 'by hand' '' 0"]+            assertEqual "" InPlace (planFor pm out)+            assertEqual "" (NotOurs "refusing to delete mapping 'by hand' on external port 51820: it does not carry our marker salmon:wg0") (teardownFor pm out)+        , testCase "plan: somebody else's mapping is Taken, never taken over" $ do+            let out = igdListing "203.0.113.7" [" 0 UDP 51820->192.168.1.77:51820 'laptop' '' 0"]+            assertBool "taken" (isTaken (planFor pm out))+            assertBool "failure" (isFailure (checkOf (planFor pm out)))+        , testCase "plan: our own stale mapping (pointing elsewhere) is re-added" $ do+            let out = igdListing "203.0.113.7" [" 0 UDP 51820->192.168.1.77:51820 'salmon:wg0' '' 3600"]+            assertBool "needs add" (isNeedsAdd (planFor pm out))+        , testCase "plan: a mapping near expiry is re-added" $ do+            let out = igdListing "203.0.113.7" [" 0 UDP 51820->192.168.1.5:51820 'salmon:wg0' '' 600"]+            assertBool "needs add" (isNeedsAdd (planFor pm out))+        , testCase "plan: the same external port under the other protocol is not ours" $ do+            let out = igdListing "203.0.113.7" [" 0 TCP 51820->192.168.1.77:51820 'laptop' '' 0"]+            assertBool "needs add" (isNeedsAdd (planFor pm out))+        , testCase "plan: a private external address is double NAT, Failure with a reason" $ do+            let out = igdListing "192.168.0.2" [ours]+            case checkOf (planFor pm out) of+                Failure r -> assertBool "names the problem" ("cannot be reached" `Text.isInfixOf` r)+                other -> assertBool ("expected Failure, got " <> show other) False+        , testCase "plan: a CGNAT external address is double NAT too" $+            assertBool "double nat" (isDoubleNat (planFor pm (igdListing "100.64.3.2" [])))+        , testCase "plan: no device and not-an-IGD are Unknown, not Failure (real captures)" $ do+            assertBool "unknown" (isUnknown (checkOf (planFor pm noDeviceOutput)))+            assertBool "unknown" (isUnknown (checkOf (planFor pm notIgdOutput)))+        , testCase "teardown: absent is already gone, ours is deleted, no gateway cannot tell" $ do+            assertEqual "" AlreadyGone (teardownFor pm (igdListing "203.0.113.7" []))+            assertEqual "" DeleteIt (teardownFor pm (igdListing "203.0.113.7" [ours]))+            assertBool "cannot tell" (case teardownFor pm notIgdOutput of CannotTell _ -> True; _ -> False)+        ]+  where+    isSuccess Success = True+    isSuccess _ = False+    isFailure (Failure _) = True+    isFailure _ = False+    isUnknown Unknown = True+    isUnknown _ = False+    isNeedsAdd (NeedsAdd _) = True+    isNeedsAdd _ = False+    isTaken (Taken _) = True+    isTaken _ = False+    isDoubleNat (DoubleNat _) = True+    isDoubleNat _ = False
test/Test/PostgresInitSpec.hs view
@@ -54,16 +54,23 @@ -- redirected into the sandbox exactly like the `up` it guards. Unshimmed, it -- answers about the host: this machine has postgresql installed, so the -- container never got it and the recipe failed one step later.-shimmedCommands = ["apt-get", "dpkg-query", "sudo", "bash", "chmod"]+-- "apt-cache" is here for the same reason: it is the check of the apt index+-- node 'deb' depends on, and it must ask the container's index, not the host's.+shimmedCommands = ["apt-get", "apt-cache", "dpkg-query", "sudo", "bash", "chmod"]  setupNakedPGAgainstSandbox :: IO () setupNakedPGAgainstSandbox = requireExecutable "podman" $     withContainer (Podman.Image "debian:bookworm") (Podman.PortMapping "15432" "5432" Podman.TCPPort) $ \cid -> do         -- sandbox prep, not part of the recipe under test: a fresh base-        -- image has no apt cache and no sudo, both of which a real target-        -- server is assumed to already have.+        -- image has no sudo, which a real target server is assumed to+        -- already have.         podmanExec_ cid ["apt-get", "update", "-qq"]         podmanExec_ cid ["bash", "-c", "DEBIAN_FRONTEND=noninteractive apt-get install -y -qq sudo"]+        -- ... and then the index is thrown away again, so the recipe starts+        -- where a fresh cloud image does: `apt-get install postgresql` has+        -- no candidate until something refreshes the index, and that+        -- something has to be the recipe's own `deb` node.+        podmanExec_ cid ["bash", "-c", "rm -rf /var/lib/apt/lists/* /var/cache/apt/*.bin"]          withShimmedPath cid shimmedCommands $ do             let op = PostgresInit.setupNakedPG silent "appdb"
+ test/Test/PostgresPairPrereqsSpec.hs view
@@ -0,0 +1,385 @@+{-# LANGUAGE OverloadedStrings #-}++{- | Layer 0 coverage for "SreBox.PostgresPairPrereqs": what a pair assumed+was done before it ran.++Three kinds of test. The declaration is checked as data ('validate', the+@pg_hba.conf@ lines, the shape of the graph). The scripts are checked as+text and through @bash -n@. And the two parts where a mistake is quiet are+/run/, here, against a temporary directory and stand-ins on @PATH@ for the+commands that need a machine: the auth file that must restart pgbouncer when+it changed and only then, and the password that must reach @psql@ without+passing through an argument or a report.++Nothing here reaches a machine over ssh; that is the Layer 3 specs' job.+-}+module Test.PostgresPairPrereqsSpec (tests) where++import Data.List (isInfixOf)+import Data.Text (Text)+import qualified Data.Text as Text+import System.Directory (createDirectoryIfMissing, doesFileExist)+import System.Environment (getEnvironment)+import System.Exit (ExitCode (..))+import System.FilePath ((</>))+import System.IO.Temp (withSystemTempDirectory)+import System.Posix.Files (fileMode, getFileStatus, intersectFileModes, accessModes)+import System.Process (CreateProcess (env), proc, readCreateProcessWithExitCode, readProcess)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, assertEqual, testCase)++import Test.Harness (writeExecutable)++-- the field selectors are named so that GHC can solve the HasField+-- constraints `Dag.sameRepresentative` carries.+import Salmon.Builtin.Extension (Extension, Op, dynamics, evalDeps, help, notes, ref)+import qualified Salmon.Op.Dag as Dag+import Salmon.Op.Ref (mkRef)+import Salmon.Reporter (silent)++import qualified SreBox.PostgresPair as Pair+import qualified SreBox.PostgresPairPrereqs as Prereqs++tests :: TestTree+tests =+    testGroup+        "SreBox.PostgresPairPrereqs"+        [ testGroup "validate" validateTests+        , testGroup "pg_hba lines" hbaTests+        , testGroup "the scripts, as text" scriptTests+        , testGroup "the auth file and the passfiles, run" installTests+        , testGroup "an application's role, run against stand-ins" applicationTests+        , testGroup "the graph" graphTests+        ]++-------------------------------------------------------------------------------++validateTests :: [TestTree]+validateTests =+    [ testCase "the defaults are fine" $+        assertEqual "" [] (Prereqs.validate pair Prereqs.defaultPrereqs)+    , testCase "and so is a full declaration" $+        assertEqual "" [] (Prereqs.validate pair full)+    , testCase "every refusal is reported, not the first" $ do+        let bad =+                full+                    { Prereqs.prereq_member_packages = ["postgresql; rm -rf /"]+                    , Prereqs.prereq_userlist = (Prereqs.postgresOwned "relative/userlist.txt"){Prereqs.secret_mode = "rw"}+                    , Prereqs.prereq_applications =+                        [ app+                            { Prereqs.app_role = "app'; DROP"+                            , Prereqs.app_clients = ["10.0.0.3 trust"]+                            , Prereqs.app_hba_method = "trust"+                            }+                        ]+                    }+            refusals = Prereqs.validate pair bad+        assertEqual (show refusals) 6 (length refusals)+    , testCase "an application may not be the pair's own role" $ do+        let refusals = Prereqs.validate pair full{Prereqs.prereq_applications = [app{Prereqs.app_role = "replicator"}]}+        assertBool (show refusals) (any ("the pair's own" `Text.isInfixOf`) refusals)+    ]++hbaTests :: [TestTree]+hbaTests =+    [ testCase "a bare address is one host, v4 or v6, and a network is taken as written" $+        assertEqual+            ""+            [ "host app app 10.0.0.3/32 md5"+            , "host app app fd00::3/128 md5"+            , "host app app 10.0.1.0/24 md5"+            ]+            (Prereqs.hbaLines full{Prereqs.prereq_applications = [app{Prereqs.app_clients = ["10.0.0.3", "fd00::3", "10.0.1.0/24"]}]})+    ]++scriptTests :: [TestTree]+scriptTests =+    [ testCase "every script parses" $+        mapM_+            bashParses+            [ Prereqs.memberPrereqsScript pair full Pair.A+            , Prereqs.bouncerPrereqsScript full bouncer+            , Prereqs.applicationsScript pair full Pair.A+            , Prereqs.applicationsScript pair Prereqs.defaultPrereqs Pair.A+            ]+    , testCase "packages are installed only if missing" $ do+        let s = Prereqs.memberPrereqsScript pair full Pair.A+        assertBool s ("dpkg-query" `isInfixOf` s)+        assertBool s (at "dpkg-query" s < at "apt-get install" s)+    , testCase "and a machine whose packages are somebody else's is not asked" $ do+        let s = Prereqs.memberPrereqsScript pair full{Prereqs.prereq_member_packages = []} Pair.A+        assertBool s (not ("apt-get" `isInfixOf` s))+    , testCase "a member's passfiles land where the pair reads them, as postgres, 0600" $ do+        let s = Prereqs.memberPrereqsScript pair full Pair.A+        assertBool s ("install -m '0600' -o 'postgres' -g 'postgres' '/run/secrets/repl.pgpass' '/etc/postgresql/repl.pgpass'" `isInfixOf` s)+        assertBool s ("'/run/secrets/rewind.pgpass' '/etc/postgresql/rewind.pass'" `isInfixOf` s)+    , testCase "a secret left in place is not copied, only held" $ do+        let s = Prereqs.memberPrereqsScript pair Prereqs.defaultPrereqs Pair.A+        assertBool s (not ("install -m" `isInfixOf` s))+        assertBool s ("chmod '0600' '/etc/postgresql/repl.pgpass'" `isInfixOf` s)+    , testCase "pgbouncer is restarted through try-restart, behind the changed flag" $ do+        let s = Prereqs.bouncerPrereqsScript full bouncer+        assertBool s ("if [ \"$salmon_changed\" = 1 ]; then systemctl try-restart pgbouncer; fi" `isInfixOf` s)+        -- the console passfile is installed before the flag is reset, so a+        -- changed console password alone restarts nothing+        assertBool s (at "console.pgpass" s < at "salmon_changed=0" s)+        assertBool s (at "salmon_changed=0" s < at "userlist.txt" s)+    , testCase "roles and databases are made on a primary only, hba lines on either" $ do+        let s = Prereqs.applicationsScript pair full Pair.A+        assertBool s (at "pg_hba.conf" s < at "pg_is_in_recovery" s)+        assertBool s (at "host app app 10.0.0.3/32 md5" s < at "pg_is_in_recovery" s)+        assertBool s (at "pg_is_in_recovery" s < at "CREATE ROLE app" s)+        assertBool s (at "pg_is_in_recovery" s < at "CREATE DATABASE app OWNER app" s)+    , testCase "the script names the passfile and holds no password" $ do+        let s = Prereqs.applicationsScript pair full Pair.A+        assertBool s ("/run/secrets/app.pgpass" `isInfixOf` s)+        assertBool s ("PASSWORD '$salmon_pw'" `isInfixOf` s)+    ]++-------------------------------------------------------------------------------+-- Run: the auth file.++installTests :: [TestTree]+installTests =+    [ testCase "the auth file restarts pgbouncer when it changed, and only then" $+        withBouncerDir $ \dir run restarts -> do+            writeFile (dir </> "src/userlist.txt") "\"app\" \"md5-first\"\n"+            writeFile (dir </> "src/console.pgpass") "*:*:*:router:console-secret\n"+            ok =<< run+            assertEqual "first install" 1 =<< restarts+            assertEqual "" "\"app\" \"md5-first\"\n" =<< readFile (dir </> "etc/userlist.txt")+            assertEqual "auth file mode" 0o640 =<< modeOf (dir </> "etc/userlist.txt")+            assertEqual "passfile mode" 0o600 =<< modeOf (dir </> "etc/console.pgpass")++            ok =<< run+            assertEqual "an unchanged file is not a restart" 1 =<< restarts++            -- a console password alone is not pgbouncer's business+            writeFile (dir </> "src/console.pgpass") "*:*:*:router:rotated\n"+            ok =<< run+            assertEqual "nor is a changed console passfile" 1 =<< restarts+            assertEqual "though it is installed" "*:*:*:router:rotated\n" =<< readFile (dir </> "etc/console.pgpass")++            writeFile (dir </> "src/userlist.txt") "\"app\" \"md5-second\"\n"+            ok =<< run+            assertEqual "a changed one is" 2 =<< restarts+    , testCase "a missing file is named, and nothing else is said" $+        withBouncerDir $ \dir run restarts -> do+            writeFile (dir </> "src/console.pgpass") "*:*:*:router:console-secret\n"+            (code, out, err) <- run+            assertBool "fails" (code /= ExitSuccess)+            assertBool err ((dir </> "src/userlist.txt") `isInfixOf` err)+            assertBool (out <> err) (not ("console-secret" `isInfixOf` (out <> err)))+            assertEqual "and nothing was restarted" 0 =<< restarts+    ]+  where+    ok (code, out, err) = assertEqual (out <> err) ExitSuccess code++{- | A bouncer whose configuration directory is a temporary one, whose files+belong to whoever runs the tests, and whose @systemctl@ writes down what it+was asked.+-}+withBouncerDir :: (FilePath -> IO (ExitCode, String, String) -> IO Int -> IO a) -> IO a+withBouncerDir k =+    withSystemTempDirectory "salmon-pair-prereqs" $ \dir -> do+        mapM_ (createDirectoryIfMissing True . (dir </>)) ["src", "etc", "bin"]+        user <- trim <$> readProcess "id" ["-un"] ""+        group <- trim <$> readProcess "id" ["-gn"] ""+        let secret from mode = Prereqs.SecretFile (Just (dir </> "src" </> from)) (Text.pack user) (Text.pack group) mode+            pre =+                Prereqs.defaultPrereqs+                    { Prereqs.prereq_bouncer_packages = []+                    , Prereqs.prereq_console_passfile = secret "console.pgpass" "0600"+                    , Prereqs.prereq_userlist = secret "userlist.txt" "0640"+                    }+            b =+                bouncer+                    { Pair.bouncer_config_dir = dir </> "etc"+                    , Pair.bouncer_console_passfile = dir </> "etc/console.pgpass"+                    }+            logFile = dir </> "systemctl.log"+        standIn (dir </> "bin/systemctl") ["echo \"$@\" >> " <> logFile]+        let restarts = do+                there <- doesFileExist logFile+                if there then length . filter (== "try-restart pgbouncer") . lines <$> readFile logFile else pure 0+        k dir (runWith (dir </> "bin") (Prereqs.bouncerPrereqsScript pre b)) restarts++-------------------------------------------------------------------------------+-- Run: the application's password.++applicationTests :: [TestTree]+applicationTests =+    [ testCase "the password reaches psql on standard input, quoted, and nowhere else" $+        withMember "f" False $ \dir run -> do+            (code, out, err) <- run+            assertEqual (out <> err) ExitSuccess code+            args <- readFile (dir </> "args.log")+            stdin' <- readFile (dir </> "stdin.log")+            assertBool stdin' ("ALTER ROLE app LOGIN PASSWORD 'it''s-a-secret';" `isInfixOf` stdin')+            assertBool args (not ("secret" `isInfixOf` args))+            assertBool (out <> err) (not ("secret" `isInfixOf` (out <> err)))+            assertBool args ("CREATE ROLE app LOGIN" `isInfixOf` args)+            assertBool args ("CREATE DATABASE app OWNER app" `isInfixOf` args)+    , testCase "a standby makes no role and no database" $+        withMember "t" False $ \dir run -> do+            (code, out, err) <- run+            assertEqual (out <> err) ExitSuccess code+            args <- readFile (dir </> "args.log")+            assertBool args (not ("CREATE" `isInfixOf` args))+            assertBool "nothing went to standard input" . not =<< doesFileExist (dir </> "stdin.log")+    , testCase "a psql that fails on the password statement, and quotes it, is not relayed" $+        withMember "f" True $ \_ run -> do+            (code, out, err) <- run+            assertBool "fails" (code /= ExitSuccess)+            assertBool err ("could not set the password of role app" `isInfixOf` err)+            assertBool (out <> err) (not ("secret" `isInfixOf` (out <> err)))+    ]++{- | A member whose @pg_lsclusters@ and @sudo@ are stand-ins: the first+names a cluster, the second writes down the arguments it was given and+whatever came on standard input, and answers the two questions the script+asks. Given @failing@, it fails the statement that arrives on standard input+the way psql does, by quoting it.+-}+withMember :: String -> Bool -> (FilePath -> IO (ExitCode, String, String) -> IO a) -> IO a+withMember inRecovery failing k =+    withSystemTempDirectory "salmon-pair-prereqs" $ \dir -> do+        createDirectoryIfMissing True (dir </> "bin")+        writeFile (dir </> "app.pgpass") "*:*:*:app:it's-a-secret\n"+        standIn (dir </> "bin/pg_lsclusters") ["echo '15 main 5432 online postgres /var/lib/postgresql/15/main /var/log/postgresql/postgresql-15-main.log'"]+        standIn+            (dir </> "bin/sudo")+            [ "echo \"$@\" >> " <> (dir </> "args.log")+            , "case \"$*\" in"+            , "  *pg_is_in_recovery*) echo " <> inRecovery <> " ;;"+            , "  *pg_reload_conf*|*pg_database*|*' -c '*) : ;;"+            , "  *)"+            , "    statement=$(cat)"+            , "    printf '%s\\n' \"$statement\" >> " <> (dir </> "stdin.log")+            , if failing then "    echo \"ERROR: LINE 1: $statement\" >&2; exit 3" else "    :"+            , "    ;;"+            , "esac"+            ]+        let pre = full{Prereqs.prereq_applications = [app{Prereqs.app_passfile = dir </> "app.pgpass", Prereqs.app_clients = []}]}+        k dir (runWith (dir </> "bin") (Prereqs.applicationsScript pair pre Pair.A))++-------------------------------------------------------------------------------++graphTests :: [TestTree]+graphTests =+    [ testCase "a machine's prerequisites come before the pair's node for it" $ do+        assertBool "member A" (refOf "pg-pair-member-prereqs" "app@10.0.0.1" `elem` Dag.dependenciesOf dag (refOf "pg-pair-member" "app@10.0.0.1"))+        assertBool "member B" (refOf "pg-pair-member-prereqs" "app@10.0.0.2" `elem` Dag.dependenciesOf dag (refOf "pg-pair-member" "app@10.0.0.2"))+        assertBool "bouncer" (refOf "pg-pair-bouncer-prereqs" "app@10.0.0.3" `elem` Dag.dependenciesOf dag (refOf "pg-pair-bouncer" "app@10.0.0.3"))+    , testCase "the applications come after the role node, on both members" $ do+        assertBool "A" (refOf "pg-pair-role" "app" `elem` Dag.dependenciesOf dag (refOf "pg-pair-applications" "app@10.0.0.1"))+        assertBool "B" (refOf "pg-pair-role" "app" `elem` Dag.dependenciesOf dag (refOf "pg-pair-applications" "app@10.0.0.2"))+    , testCase "naming the pair's nodes again is a merge, not a conflict" $+        assertEqual "" 0 (length (Dag.dagConflicts dag))+    , testCase "the role node still stands on the pair's own nodes" $+        assertBool "" (refOf "pg-pair-member" "app@10.0.0.1" `elem` Dag.dependenciesOf dag (refOf "pg-pair-role" "app"))+    , testCase "no application, no application node" $+        assertBool "" (refOf "pg-pair-applications" "app@10.0.0.1" `notElem` Dag.dagOrder (foldOf (Prereqs.pairWithPrereqs silent pair Prereqs.defaultPrereqs)))+    ]+  where+    dag = foldOf (Prereqs.pairWithPrereqs silent pair full)+    refOf kind key = mkRef kind (key :: Text)++foldOf :: Op -> Dag.Dag Extension+foldOf = Dag.foldDag Dag.sameRepresentative . evalDeps++-------------------------------------------------------------------------------++bashParses :: String -> IO ()+bashParses script = do+    (code, _, err) <- readCreateProcessWithExitCode (proc "bash" ["-n", "-c", script]) ""+    assertEqual (err <> "\n" <> script) ExitSuccess code++-- | Runs a script with a directory of stand-ins ahead of everything on @PATH@.+runWith :: FilePath -> String -> IO (ExitCode, String, String)+runWith bin script = do+    environment <- getEnvironment+    let path = maybe bin (\p -> bin <> ":" <> p) (lookup "PATH" environment)+    readCreateProcessWithExitCode+        (proc "bash" ["-c", script]){env = Just (("PATH", path) : filter ((/= "PATH") . fst) environment)}+        ""++-- | Written by a child, see 'writeExecutable': these are exec'd through @PATH@.+standIn :: FilePath -> [String] -> IO ()+standIn path body = writeExecutable path (unlines ("#!/bin/bash" : body))++modeOf :: FilePath -> IO Int+modeOf path = fromIntegral . intersectFileModes accessModes . fileMode <$> getFileStatus path++trim :: String -> String+trim = reverse . dropWhile (`elem` ("\n " :: String)) . reverse++at :: String -> String -> Int+at needle hay = go 0 hay+  where+    go n rest+        | needle `isInfixOf` take (length needle) rest = n+        | null rest = maxBound+        | otherwise = go (n + 1) (drop 1 rest)++-------------------------------------------------------------------------------++full :: Prereqs.Prereqs+full =+    Prereqs.defaultPrereqs+        { Prereqs.prereq_repl_passfile = Prereqs.postgresOwned "/run/secrets/repl.pgpass"+        , Prereqs.prereq_rewind_passfile = Prereqs.postgresOwned "/run/secrets/rewind.pgpass"+        , Prereqs.prereq_console_passfile = Prereqs.postgresOwned "/run/secrets/console.pgpass"+        , Prereqs.prereq_userlist = (Prereqs.postgresOwned "/run/secrets/userlist.txt"){Prereqs.secret_mode = "0640"}+        , Prereqs.prereq_applications = [app]+        }++app :: Prereqs.Application+app =+    Prereqs.Application+        { Prereqs.app_role = "app"+        , Prereqs.app_database = "app"+        , Prereqs.app_passfile = "/run/secrets/app.pgpass"+        , Prereqs.app_clients = ["10.0.0.3"]+        , Prereqs.app_hba_method = "md5"+        }++pair :: Pair.Pair+pair =+    Pair.Pair+        { Pair.pair_name = "app"+        , Pair.pair_a = member "10.0.0.1"+        , Pair.pair_b = member "10.0.0.2"+        , Pair.pair_primary = Pair.A+        , Pair.pair_seed = Just Pair.B+        , Pair.pair_bouncers = [bouncer]+        , Pair.pair_may_discard = Nothing+        , Pair.pair_reseed = Nothing+        , Pair.pair_conn_security = Nothing+        , Pair.pair_repl_role = "replicator"+        , Pair.pair_repl_passfile = "/etc/postgresql/repl.pgpass"+        , Pair.pair_rewind_role = "rewinder"+        , Pair.pair_rewind_passfile = "/etc/postgresql/rewind.pass"+        , Pair.pair_ssh_known_hosts = Nothing+        , Pair.pair_catch_up_seconds = 60+        }+  where+    member host = Pair.Member "root" host "main" 5432 Nothing Nothing++bouncer :: Pair.Bouncer+bouncer =+    Pair.Bouncer+        { Pair.bouncer_name = "bouncer-1"+        , Pair.bouncer_ssh_user = "root"+        , Pair.bouncer_ssh_host = "10.0.0.3"+        , Pair.bouncer_ssh_identity = Nothing+        , Pair.bouncer_console_user = "router"+        , Pair.bouncer_console_port = 6432+        , Pair.bouncer_console_passfile = "/etc/pgbouncer/console.pgpass"+        , Pair.bouncer_alias = "app"+        , Pair.bouncer_dbname = "app"+        , Pair.bouncer_more_databases = Nothing+        , Pair.bouncer_routing_path = "/etc/pgbouncer/routing.ini"+        , Pair.bouncer_config_dir = "/etc/pgbouncer"+        , Pair.bouncer_listen_port = 6432+        }
test/Test/PostgresPairSpec.hs view
@@ -17,7 +17,13 @@ -} module Test.PostgresPairSpec (tests) where +import qualified Data.Aeson as Aeson+import qualified Data.Aeson.KeyMap as KeyMap import Data.List (isInfixOf, isPrefixOf)+import System.Exit (ExitCode (..))+import System.FilePath ((</>))+import System.IO.Temp (withSystemTempDirectory)+import System.Process (readProcessWithExitCode) import Data.Text (Text) import qualified Data.Text as Text import Test.Tasty (TestTree, testGroup)@@ -32,6 +38,7 @@         [ testGroup "what a member needs" memberTests         , testGroup "slot names" slotNameTests         , testGroup "what a bouncer is doing" bouncerTests+        , testGroup "one bouncer routing several databases (bouncer_more_databases)" severalDatabasesTests         , testGroup "parseLsn" lsnTests         , testGroup "parseObserved" observedTests         , testGroup "the probe script" probeTests@@ -39,6 +46,9 @@         , testGroup "nextStep, with the primary declared on A" mirrorTests         , testGroup "stepCommand" commandTests         , testGroup "re-seeding a standby whose slot is lost (pair_reseed)" reseedTests+        , testGroup "a member reached over ssh on another address (member_ssh_host)" sshHostTests+        , testGroup "a machine reached as a login that is not root (member_ssh_user)" sshUserTests+        , testGroup "how the two members talk to each other (pair_conn_security)" securityTests         ]  {- | What a step actually does to a machine. Pure, so the destructive half of@@ -180,6 +190,124 @@             | needle `isPrefixOf` rest = (reverse acc, rest)             | otherwise = go (c : acc) cs +{- | A member has two addresses when the controller is outside the network+the pair talks over. The ssh one is the controller's route and nothing more:+no script, on any machine, may contain it, or a peer would be told to reach+its primary on an address it cannot route to.+-}+sshUserTests :: [TestTree]+sshUserTests =+    [ testCase "root is sent the script as it always was, with no sudo in front" $ do+        assertEqual "" ["bash", "-c"] (take 2 (Pair.remoteCommand (Pair.OnMember (Pair.memberOn pair Pair.A)) "true"))+        assertEqual "" ["bash", "-c"] (take 2 (Pair.remoteCommand (Pair.OnBouncer bouncer) "true"))+    , testCase "any other login has the whole script under one sudo that never prompts" $ do+        assertEqual "" ["sudo", "-n", "bash", "-c"] (take 4 (Pair.remoteCommand (Pair.OnMember (Pair.memberOn unprivileged Pair.A)) "true"))+        assertEqual "" ["sudo", "-n", "bash", "-c"] (take 4 (Pair.remoteCommand (Pair.OnMember (Pair.memberOn unprivileged Pair.B)) "true"))+        assertEqual "" ["sudo", "-n", "bash", "-c"] (take 4 (Pair.remoteCommand (Pair.OnBouncer unprivilegedBouncer) "true"))+    , testCase "the script is one word either way, and the same word" $ do+        let asRoot = Pair.remoteCommand (Pair.OnMember (Pair.memberOn pair Pair.A)) "echo 'it''s'; id -u"+            asOther = Pair.remoteCommand (Pair.OnMember (Pair.memberOn unprivileged Pair.A)) "echo 'it''s'; id -u"+        assertEqual "" 3 (length asRoot)+        assertEqual "" 5 (length asOther)+        assertEqual "" (drop 2 asRoot) (drop 4 asOther)+    , testCase "the locale is set inside the script, where sudo cannot reset it" $+        assertBool "" ("export LANG=C LC_ALL=C" `isInfixOf` last (Pair.remoteCommand (Pair.OnMember (Pair.memberOn unprivileged Pair.A)) "true"))+    , testCase "the login is that user's" $ do+        assertEqual "" "ops@10.0.0.1" (Pair.sshLogin (Pair.OnMember (Pair.memberOn unprivileged Pair.A)))+        assertEqual "" "ops@10.0.0.3" (Pair.sshLogin (Pair.OnBouncer unprivilegedBouncer))+    , testCase "who logs in changes no script: the privilege is the wrapper's" $ do+        assertBool "expected scripts to compare" (length (scriptsOf pair) > 15)+        assertEqual "" (scriptsOf pair) (scriptsOf unprivileged)+    ]+  where+    unprivilegedBouncer = bouncer{Pair.bouncer_ssh_user = "ops"}+    unprivileged =+        pair+            { Pair.pair_a = (Pair.pair_a pair){Pair.member_ssh_user = "ops"}+            , Pair.pair_b = (Pair.pair_b pair){Pair.member_ssh_user = "ops"}+            , Pair.pair_bouncers = [unprivilegedBouncer]+            }++-- every script this recipe can send to any machine, named for the+-- failure text+scriptsOf :: Pair.Pair -> [(String, String)]+scriptsOf p0 =+    concat+        [ [("probe " <> show side, Pair.probeScript (Pair.memberOn p side)) | side <- sides]+        , [("member " <> show side, Pair.memberScript p side) | side <- sides]+        , [("seed " <> show side, Pair.seedScript p side) | side <- sides]+        , [("bouncer setup", Pair.bouncerSetupScript p b) | b <- Pair.pair_bouncers p]+        , [("bouncer probe", Pair.bouncerProbeScript b) | b <- Pair.pair_bouncers p]+        , [ (show st, sc)+          | st <- steps+          , Right cs <- [Pair.stepCommand p st]+          , (_, sc) <- cs+          ]+        ]+  where+    p = p0{Pair.pair_seed = Just Pair.A, Pair.pair_reseed = Just Pair.A}+    sides = [Pair.A, Pair.B]+    steps =+        Pair.PauseBouncers+            : concat+                [ [Pair.StopMember s, Pair.Promote s, Pair.Rejoin s, Pair.StartMember s, Pair.Reseed s, Pair.RepointBouncers s]+                | s <- sides+                ]++sshHostTests :: [TestTree]+sshHostTests =+    [ testCase "ssh goes to the ssh address" $ do+        assertEqual "" "root@203.0.113.1" (Pair.sshLogin (Pair.OnMember (Pair.memberOn split Pair.A)))+        assertEqual "" "root@203.0.113.2" (Pair.sshLogin (Pair.OnMember (Pair.memberOn split Pair.B)))+    , testCase "and to member_host when none is declared" $ do+        assertEqual "" "10.0.0.1" (Pair.memberSshHost (Pair.memberOn pair Pair.A))+        assertEqual "" "root@10.0.0.1" (Pair.sshLogin (Pair.OnMember (Pair.memberOn pair Pair.A)))+    , testCase "a bouncer's login is unchanged" $+        assertEqual "" "root@10.0.0.3" (Pair.sshLogin (Pair.OnBouncer bouncer))+    , testCase "no rendered script contains an ssh address" $+        mapM_+            ( \(name, s) ->+                mapM_+                    (\addr -> assertBool (name <> " contains " <> addr) (not (addr `isInfixOf` s)))+                    ["203.0.113.1", "203.0.113.2"]+            )+            (scriptsOf split)+    , testCase "and every one of them is what the pair without ssh addresses renders" $ do+        assertBool "expected scripts to compare" (length (scriptsOf pair) > 15)+        assertEqual "" (scriptsOf pair) (scriptsOf split)+    , testCase "the scripts still name the peer by member_host" $ do+        assertBool "rejoin" (named "Rejoin A" "host=10.0.0.2")+        assertBool "hba" (named "member A" "10.0.0.2/32")+        assertBool "routing" (named "RepointBouncers B" "host=10.0.0.2")+    , testCase "a step addresses the member by the same name, wherever ssh goes" $+        case Pair.stepCommand split (Pair.StopMember Pair.A) of+            Right ((Pair.OnMember m, _) : _) -> do+                assertEqual "" "10.0.0.1" (Pair.member_host m)+                assertEqual "" "203.0.113.1" (Pair.memberSshHost m)+            _ -> assertFailure "expected a command on a member"+    , testCase "what a standby reports as its upstream is compared with member_host" $ do+        let decideFor p = Pair.nextStep p (streamingFrom "10.0.0.2" "0/5") (primaryAt "0/5") settled+        assertEqual "" Pair.Done (decideFor split)+        -- streaming from the ssh address would be somebody else's standby+        assertEqual+            ""+            (Pair.Rejoin Pair.A)+            (Pair.nextStep split (streamingFrom "203.0.113.2" "0/5") (primaryAt "0/5") settled)+    , testCase "a directive written before the field existed still parses" $+        case Aeson.fromJSON (dropSshHost (Aeson.toJSON (Pair.memberOn pair Pair.A))) of+            Aeson.Success m -> assertEqual "" (Pair.memberOn pair Pair.A) m+            Aeson.Error why -> assertFailure why+    ]+  where+    split =+        pair+            { Pair.pair_a = (Pair.pair_a pair){Pair.member_ssh_host = Just "203.0.113.1"}+            , Pair.pair_b = (Pair.pair_b pair){Pair.member_ssh_host = Just "203.0.113.2"}+            }+    named name needle = any (\(n, s) -> n == name && needle `isInfixOf` s) (scriptsOf split)+    dropSshHost (Aeson.Object o) = Aeson.Object (KeyMap.delete "member_ssh_host" o)+    dropSshHost v = v+ -------------------------------------------------------------------------------  pair :: Pair.Pair@@ -193,6 +321,7 @@         , Pair.pair_bouncers = [bouncer]         , Pair.pair_may_discard = Nothing         , Pair.pair_reseed = Nothing+        , Pair.pair_conn_security = Nothing         , Pair.pair_repl_role = "replicator"         , Pair.pair_repl_passfile = "/etc/postgresql/repl.pgpass"         , Pair.pair_rewind_role = "rewinder"@@ -201,7 +330,7 @@         , Pair.pair_catch_up_seconds = 60         }   where-    member host = Pair.Member "root" host "main" 5432 Nothing+    member host = Pair.Member "root" host "main" 5432 Nothing Nothing  bouncer :: Pair.Bouncer bouncer =@@ -215,12 +344,118 @@         , Pair.bouncer_console_passfile = "/etc/pgbouncer/console.pgpass"         , Pair.bouncer_alias = "app"         , Pair.bouncer_dbname = "app"+        , Pair.bouncer_more_databases = Nothing         , Pair.bouncer_routing_path = "/etc/pgbouncer/routing.ini"+        , Pair.bouncer_config_dir = "/etc/pgbouncer"+        , Pair.bouncer_listen_port = 6432         }  lsn :: Text -> Pair.Lsn lsn t = maybe (error ("bad lsn in test: " <> Text.unpack t)) id (Pair.parseLsn t) +{- | Several databases of the pair behind one bouncer: one routing file, one+pause, one reload, and a probe that does not call the pair arrived until+every alias agrees.+-}+severalDatabasesTests :: [TestTree]+severalDatabasesTests =+    [ testCase "a directive written before the field existed still parses, as the one database it named" $+        case Aeson.fromJSON (dropMore (Aeson.toJSON bouncer)) of+            Aeson.Success b -> do+                assertEqual "" bouncer b+                assertEqual "" [Pair.RoutedDatabase "app" "app"] (Pair.routedDatabases b)+            Aeson.Error why -> assertFailure why+    , testCase "and one naming several round-trips" $+        case Aeson.fromJSON (Aeson.toJSON two) of+            Aeson.Success b -> assertEqual "" two b+            Aeson.Error why -> assertFailure why+    , testCase "the first database is the one the bouncer always had" $+        assertEqual+            ""+            [Pair.RoutedDatabase "app" "app", Pair.RoutedDatabase "jobs" "jobs_db"]+            (Pair.routedDatabases two)+    , testCase "the probe answers for every alias, in the order they are declared" $+        assertEqual+            ""+            [Pair.BouncerState (Just "10.0.0.2") False, Pair.BouncerState (Just "10.0.0.1") True]+            (Pair.parseBouncerStates two "name|host|port|database|paused\njobs|10.0.0.1|5432|jobs_db|1\napp|10.0.0.2|5432|app|0\npgbouncer|||pgbouncer|0\n")+    , -- which is what a database added to a bouncer already standing looks+      -- like: the routing file is the role node's, so it is the role node+      -- that has to notice+      testCase "an alias the bouncer does not know is going nowhere, whatever the others say" $+        assertEqual+            ""+            [Pair.BouncerState (Just "10.0.0.2") False, Pair.BouncerState Nothing False]+            (Pair.parseBouncerStates two "name|host|paused\napp|10.0.0.2|0\n")+    , testCase "arrived only once every alias is at the primary and none is held" $+        assertEqual "" Pair.Done (arrived [Pair.BouncerState (Just "10.0.0.2") False, Pair.BouncerState (Just "10.0.0.2") False])+    , testCase "one alias left at the old primary is a repoint, not an arrival" $+        assertEqual "" (Pair.RepointBouncers Pair.B) (arrived [Pair.BouncerState (Just "10.0.0.2") False, Pair.BouncerState (Just "10.0.0.1") False])+    , testCase "nor is one alias the bouncer has never heard of" $+        assertEqual "" (Pair.RepointBouncers Pair.B) (arrived [Pair.BouncerState (Just "10.0.0.2") False, Pair.BouncerState Nothing False])+    , testCase "nor one alias still held" $+        assertEqual "" (Pair.RepointBouncers Pair.B) (arrived [Pair.BouncerState (Just "10.0.0.2") False, Pair.BouncerState (Just "10.0.0.2") True])+    , -- the old primary is not stopped while any database still passes+      -- writes to it+      testCase "a switchover pauses until every alias is held" $ do+        assertEqual "" Pair.PauseBouncers (moving [Pair.BouncerState (Just "10.0.0.1") True, Pair.BouncerState (Just "10.0.0.1") False])+        assertEqual "" (Pair.StopMember Pair.A) (moving [Pair.BouncerState (Just "10.0.0.1") True, Pair.BouncerState (Just "10.0.0.1") True])+    , testCase "pausing holds every database, and checks each" $ do+        let s' = script Pair.PauseBouncers+        assertEqual "one command for the one bouncer" 1 (length (scripts Pair.PauseBouncers))+        assertBool s' ("PAUSE app" `isInfixOf` s')+        assertBool s' ("PAUSE jobs" `isInfixOf` s')+        assertBool s' ("-v want='app'" `isInfixOf` s')+        assertBool s' ("-v want='jobs'" `isInfixOf` s')+    , testCase "repointing writes every database into one file, reloads once, and resumes each" $ do+        let s' = script (Pair.RepointBouncers Pair.B)+        assertEqual "one command for the one bouncer" 1 (length (scripts (Pair.RepointBouncers Pair.B)))+        assertEqual "" 1 (count "[databases]" s')+        assertBool s' ("app = host=10.0.0.2 port=5432 dbname=app\njobs = host=10.0.0.2 port=5432 dbname=jobs_db\nSALMON_ROUTING" `isInfixOf` s')+        assertEqual "" 1 (count "'RELOAD'" s')+        assertBool s' ("RESUME app" `isInfixOf` s')+        assertBool s' ("RESUME jobs" `isInfixOf` s')+        -- nobody is let go before the bouncer has read where to send them+        assertBool s' (at "'RELOAD'" s' < at "RESUME app" s')+        assertBool s' (at "'RELOAD'" s' < at "RESUME jobs" s')+    , testCase "standing the bouncer up writes the same file, for the declared primary" $ do+        let s' = Pair.bouncerSetupScript several two+        assertBool s' ("[databases]\napp = host=10.0.0.2 port=5432 dbname=app\njobs = host=10.0.0.2 port=5432 dbname=jobs_db\nSALMON_ROUTING" `isInfixOf` s')+    , testCase "with one database, the scripts name nothing else" $ do+        let s' = concatMap snd (scriptsOf pair)+        assertBool s' (not ("jobs" `isInfixOf` s'))+    , testCase "a declaration with nothing wrong has no problems" $ do+        assertEqual "" [] (Pair.bouncerProblems pair)+        assertEqual "" [] (Pair.bouncerProblems several)+    , testCase "an alias routed twice is refused, since only one of the two could ever be observed" $+        assertEqual+            ""+            ["bouncer bouncer-1: the database app is routed more than once"]+            (Pair.bouncerProblems pair{Pair.pair_bouncers = [bouncer{Pair.bouncer_more_databases = Just [Pair.RoutedDatabase "app" "other"]}]})+    , testCase "and so is a name that a routing file or a shell would read as something else" $+        assertEqual+            ""+            2+            (length (Pair.bouncerProblems pair{Pair.pair_bouncers = [bouncer{Pair.bouncer_more_databases = Just [Pair.RoutedDatabase "jobs; RESUME" "x y"]}]}))+    ]+  where+    two = bouncer{Pair.bouncer_more_databases = Just [Pair.RoutedDatabase "jobs" "jobs_db"]}+    several = pair{Pair.pair_bouncers = [two]}+    -- the primary is where it is declared, and its peer streams from it+    arrived = Pair.nextStep several (streamingFrom "10.0.0.2" "0/5") (primaryAt "0/5")+    -- the primary is still on A, and B is declared+    moving = Pair.nextStep several (primaryAt "0/5") (streamingFrom "10.0.0.1" "0/5")+    scripts st = case Pair.stepCommand several st of+        Right cs -> map snd cs+        Left why -> error ("expected a command for " <> show st <> ": " <> Text.unpack why)+    script = concat . scripts+    dropMore (Aeson.Object o) = Aeson.Object (KeyMap.delete "bouncer_more_databases" o)+    dropMore v = v+    count needle hay = length (filter (isPrefixOf needle) (tails' hay))+    at needle hay = length (takeWhile (not . isPrefixOf needle) (tails' hay))+    tails' [] = [[]]+    tails' xs@(_ : rest) = xs : tails' rest+ -- | A standby streaming from the machine it should be streaming from. streamingFrom :: Text -> Text -> Pair.Observed streamingFrom host at = Pair.Standby "7000" 1 (Just host) (Just host) (lsn at) (lsn at)@@ -306,7 +541,19 @@         let s' = Pair.memberScript pair Pair.A <> Pair.memberScript pair Pair.B         mapM_             (\w -> assertBool (w <> " has no business in a member's script") (not (w `isInfixOf` s')))-            ["pg_promote", "standby.signal", "primary_conninfo", "pg_rewind", "primary", "standby"]+            ["pg_promote", "standby.signal", "pg_rewind", "standby"]+    , -- it does read primary_conninfo, on whichever machine is in recovery+      -- when it runs, to keep that connection as secure as the pair says:+      -- which is a question it asks the machine, not the declaration.+      testCase "nor does the declared primary change a word of it" $+        mapM_+            ( \side ->+                assertEqual+                    ""+                    (Pair.memberScript pair{Pair.pair_primary = Pair.A} side)+                    (Pair.memberScript pair{Pair.pair_primary = Pair.B} side)+            )+            [Pair.A, Pair.B]     ]   where     at needle hay = length (takeWhile (not . isPrefixOf needle) (tails' hay))@@ -679,6 +926,240 @@     reseedScriptOn side = case Pair.stepCommand pairReseed (Pair.Reseed side) of         Right [(_, sc)] -> sc         _ -> ""+    at needle hay = length (takeWhile (not . isPrefixOf needle) (tails' hay))+    tails' [] = [[]]+    tails' xs@(_ : rest) = xs : tails' rest++-------------------------------------------------------------------------------++{- | 'Pair.pair_conn_security': what the replication and rewind connections+between the two members must be.++Two things are being held here. A pair that says nothing is exactly the pair+it was before there was anything to say -- the same two @pg_hba.conf@ lines,+no @sslmode@ anywhere. And a pair that says something stronger gets it at+/both/ ends of /every/ connection: a @hostssl@ line the old @host@ line still+shadows, or one connection string left at libpq's default, is the option+doing nothing while reading as if it did.+-}+securityTests :: [TestTree]+securityTests =+    [ testCase "a pair that says nothing is host, md5: what every pair was" $ do+        assertEqual "" Pair.PlainMd5 (Pair.connSecurity pair)+        assertEqual+            ""+            ["host replication replicator 10.0.0.2/32 md5", "host all rewinder 10.0.0.2/32 md5"]+            (Pair.hbaLinesFor pair Pair.A Pair.PlainMd5)+        assertEqual "" (scriptsOf pair) (scriptsOf pair{Pair.pair_conn_security = Just Pair.PlainMd5})+    , testCase "and none of its scripts asks for TLS, or for a password stored another way" $+        mapM_+            ( \(name, sc) ->+                mapM_+                    (\w -> assertBool (name <> " contains " <> w) (not (w `isInfixOf` sc)))+                    ["sslmode", "PGSSLMODE", "password_encryption", "ssl_cert_file", "pg_hba_file_rules"]+            )+            (scriptsOf pair)+    , testCase "a directive written before the field existed still parses, as that pair" $+        case Aeson.fromJSON (dropSecurity (Aeson.toJSON pair)) of+            Aeson.Success p -> assertEqual "" pair p+            Aeson.Error why -> assertFailure why+    , testCase "every choice survives the directive" $+        mapM_+            ( \p -> case Aeson.fromJSON (Aeson.toJSON p) of+                Aeson.Success p' -> assertEqual "" p p'+                Aeson.Error why -> assertFailure why+            )+            [pair, scram, verified, certs]+    , testCase "tls-scram: the peer is let in over TLS only, proving a SCRAM password" $ do+        assertEqual+            ""+            ["hostssl replication replicator 10.0.0.2/32 scram-sha-256", "hostssl all rewinder 10.0.0.2/32 scram-sha-256"]+            (Pair.hbaLinesFor scram Pair.A (Pair.connSecurity scram))+        let s' = Pair.memberScript scram Pair.A+        assertBool s' ("echo 'hostssl replication replicator 10.0.0.2/32 scram-sha-256' >> \"$hba\"" `isInfixOf` s')+        assertBool s' ("SET password_encryption = 'scram-sha-256'; DO" `isInfixOf` s')+    , -- pg_hba.conf is first match wins, and `host` matches TLS too+      testCase "and the lines of the weaker choice are removed, not left above the new ones" $ do+        assertEqual+            ""+            [ "host replication replicator 10.0.0.2/32 md5"+            , "host all rewinder 10.0.0.2/32 md5"+            , "hostssl replication replicator 10.0.0.2/32 cert"+            , "hostssl all rewinder 10.0.0.2/32 cert"+            ]+            (Pair.hbaLinesRetired scram Pair.A (Pair.connSecurity scram))+        let s' = Pair.memberScript scram Pair.A+        assertBool s' (at "awk -v l='host replication replicator 10.0.0.2/32 md5'" s' < at "echo 'hostssl replication" s')+    , testCase "a choice never retires its own lines" $+        mapM_+            ( \p ->+                mapM_+                    (\l -> assertBool (show l) (l `notElem` Pair.hbaLinesRetired p Pair.A (Pair.connSecurity p)))+                    (Pair.hbaLinesFor p Pair.A (Pair.connSecurity p))+            )+            [pair, scram, verified, certs]+    , -- a hostssl line on a cluster with ssl off matches nothing: writing it+      -- first would cut replication in order to report that TLS is not set up+      testCase "pg_hba.conf is not touched until the cluster has said it serves TLS" $ do+        let s' = Pair.memberScript scram Pair.A+        assertBool s' ("SHOW ssl" `isInfixOf` s')+        assertBool s' (at "SHOW ssl" s' < at "\"$hba.salmon\"" s')+        assertBool s' (at "SHOW ssl" s' < at ">> \"$hba\"" s')+    , testCase "and afterwards the server is asked whether it could read the file" $+        assertBool "" ("pg_hba_file_rules WHERE error IS NOT NULL" `isInfixOf` Pair.memberScript scram Pair.A)+    , testCase "tls-scram: every connection one member makes to the other demands TLS" $ do+        let rejoin = scriptOf scram (Pair.Rejoin Pair.A)+        -- pg_rewind's source, the standby's own connection, and the slot+        assertBool rejoin ("user=rewinder dbname=postgres sslmode=require'" `isInfixOf` rejoin)+        assertBool rejoin ("passfile=/etc/postgresql/repl.pgpass sslmode=require" `isInfixOf` rejoin)+        assertBool rejoin ("replication=true sslmode=require'" `isInfixOf` rejoin)+        let reseed = scriptOf scram{Pair.pair_reseed = Just Pair.A} (Pair.Reseed Pair.A)+        assertBool reseed ("replication=true sslmode=require' -c 'IDENTIFY_SYSTEM'" `isInfixOf` reseed)+    , -- the clone builds its own connection from a host and a user+      testCase "and the first clone gets it from the environment, before it connects" $ do+        let s' = Pair.seedScript scram Pair.A+        assertBool s' ("export PGSSLMODE='require'" `isInfixOf` s')+        assertBool s' (at "export PGSSLMODE" s' < at "IDENTIFY_SYSTEM" s')+        assertBool s' (at "export PGSSLMODE" s' < at "pg_basebackup" s')+    , testCase "a CA makes the connecting side check who answered" $ do+        let rejoin = scriptOf verified (Pair.Rejoin Pair.A)+        assertBool rejoin ("sslmode=verify-full sslrootcert=/etc/postgresql/pair-ca.crt" `isInfixOf` rejoin)+        assertBool rejoin (not ("sslmode=require" `isInfixOf` rejoin))+        let seed = Pair.seedScript verified Pair.A+        assertBool seed ("export PGSSLROOTCERT='/etc/postgresql/pair-ca.crt'" `isInfixOf` seed)+    , testCase "server files are checked readable, then set, then TLS turned on" $ do+        let s' = Pair.memberScript verified Pair.A+        assertBool s' ("sudo -u postgres test -r '/etc/postgresql/server.key'" `isInfixOf` s')+        assertBool s' ("ALTER SYSTEM SET ssl_cert_file = '\\''/etc/postgresql/server.crt'\\''" `isInfixOf` s')+        assertBool s' (at "test -r" s' < at "ALTER SYSTEM SET ssl_cert_file" s')+        assertBool s' (at "ALTER SYSTEM SET ssl = " s' < at "SHOW ssl" s')+        -- no client CA was declared, so the cluster's own is left alone+        assertBool s' (not ("ssl_ca_file" `isInfixOf` s'))+    , testCase "tls-cert: the peer is let in on a certificate, and each role presents its own" $ do+        assertEqual+            ""+            ["hostssl replication replicator 10.0.0.2/32 cert", "hostssl all rewinder 10.0.0.2/32 cert"]+            (Pair.hbaLinesFor certs Pair.A (Pair.connSecurity certs))+        let rejoin = scriptOf certs (Pair.Rejoin Pair.A)+        assertBool rejoin ("user=rewinder dbname=postgres sslmode=verify-ca sslrootcert=/etc/postgresql/pair-ca.crt sslcert=/etc/postgresql/rewind.crt sslkey=/etc/postgresql/rewind.key'" `isInfixOf` rejoin)+        assertBool rejoin ("passfile=/etc/postgresql/repl.pgpass sslmode=verify-ca sslrootcert=/etc/postgresql/pair-ca.crt sslcert=/etc/postgresql/repl.crt sslkey=/etc/postgresql/repl.key" `isInfixOf` rejoin)+        let seed = Pair.seedScript certs Pair.A+        assertBool seed ("export PGSSLCERT='/etc/postgresql/repl.crt'" `isInfixOf` seed)+        assertBool seed ("export PGSSLKEY='/etc/postgresql/repl.key'" `isInfixOf` seed)+    , testCase "tls-cert: a cluster that trusts no CA is refused before the file is touched" $ do+        let s' = Pair.memberScript certs Pair.A+        assertBool s' ("ALTER SYSTEM SET ssl_ca_file = '\\''/etc/postgresql/pair-ca.crt'\\''" `isInfixOf` s')+        assertBool s' (at "SHOW ssl_ca_file" s' < at ">> \"$hba\"" s')+    , -- a choice changed under a running pair has to reach the connection+      -- the standby already has configured, or it is only true after the+      -- next rejoin -- and with certificates, broken at the next reconnect+      testCase "a standby pointed at its peer is given the connection the pair now declares" $ do+        let s' = Pair.memberScript certs Pair.A+        assertBool s' ("'host=10.0.0.2 '*)" `isInfixOf` s')+        assertBool s' ("ALTER SYSTEM SET primary_conninfo = '\\''host=10.0.0.2 port=5432 user=replicator passfile=/etc/postgresql/repl.pgpass sslmode=verify-ca" `isInfixOf` s')+    , testCase "and one whose connection already is that, is left alone" $ do+        let s' = Pair.memberScript pair Pair.A+        assertBool s' ("[ \"$conninfo\" = 'host=10.0.0.2 port=5432 user=replicator passfile=/etc/postgresql/repl.pgpass' ] || " `isInfixOf` s')+    , testCase "a path that is not a plain word is refused, all of them at once" $ do+        assertEqual "" [] (Pair.securityProblems pair)+        assertEqual "" [] (Pair.securityProblems certs)+        let bad =+                pair+                    { Pair.pair_conn_security =+                        Just+                            ( Pair.TlsClientCert+                                (Pair.Tls (Pair.VerifyCa "relative/ca.crt") Nothing)+                                (Pair.ClientCerts "/etc/a b.crt" "/etc/it's.key" "/etc/ok.crt" "/etc/$(id).key")+                            )+                    }+        assertEqual "" 4 (length (Pair.securityProblems bad))+    , testCase "every script of every choice is a script bash can read" $+        mapM_+            ( \p ->+                mapM_+                    ( \(name, sc) -> do+                        (code, _, err) <- readProcessWithExitCode "bash" ["-n"] sc+                        assertEqual (name <> ": " <> err) ExitSuccess code+                    )+                    (scriptsOf p)+            )+            [pair, scram, verified, certs]+    , -- the one part of the member script that needs no Postgres to run: what+      -- it does to the file itself+      testCase "tightening a deployed pair rewrites its own two lines and nothing else" $+        withSystemTempDirectory "salmon-pair-hba" $ \dir -> do+            let hba = dir </> "pg_hba.conf"+            writeFile hba (unlines (before <> deployed <> after))+            runHba scram hba+            got <- readFile hba+            assertEqual+                ""+                ( before+                    <> after+                    <> ["hostssl replication replicator 10.0.0.2/32 scram-sha-256", "hostssl all rewinder 10.0.0.2/32 scram-sha-256"]+                )+                (lines got)+            -- and a second pass changes nothing+            runHba scram hba+            again <- readFile hba+            assertEqual "" got again+            -- back to the weaker choice: the file is what it was, lines at the end+            runHba pair hba+            back <- readFile hba+            assertEqual "" (before <> after <> deployed) (lines back)+    , testCase "an already-deployed pair that says nothing has its file left byte for byte" $+        withSystemTempDirectory "salmon-pair-hba" $ \dir -> do+            let hba = dir </> "pg_hba.conf"+            writeFile hba (unlines (before <> deployed <> after))+            runHba pair hba+            got <- readFile hba+            assertEqual "" (unlines (before <> deployed <> after)) got+    ]+  where+    tls = Pair.Tls Pair.Encrypted Nothing+    scram = pair{Pair.pair_conn_security = Just (Pair.TlsScram tls)}+    verified =+        pair+            { Pair.pair_conn_security =+                Just+                    ( Pair.TlsScram+                        ( Pair.Tls+                            (Pair.VerifyFull "/etc/postgresql/pair-ca.crt")+                            (Just (Pair.ServerFiles "/etc/postgresql/server.crt" "/etc/postgresql/server.key" Nothing))+                        )+                    )+            }+    certs =+        pair+            { Pair.pair_conn_security =+                Just+                    ( Pair.TlsClientCert+                        ( Pair.Tls+                            (Pair.VerifyCa "/etc/postgresql/pair-ca.crt")+                            (Just (Pair.ServerFiles "/etc/postgresql/server.crt" "/etc/postgresql/server.key" (Just "/etc/postgresql/pair-ca.crt")))+                        )+                        (Pair.ClientCerts "/etc/postgresql/repl.crt" "/etc/postgresql/repl.key" "/etc/postgresql/rewind.crt" "/etc/postgresql/rewind.key")+                    )+            }++    scriptOf p st = case Pair.stepCommand p st of+        Right ((_, sc) : _) -> sc+        _ -> ""++    dropSecurity (Aeson.Object o) = Aeson.Object (KeyMap.delete "pair_conn_security" o)+    dropSecurity v = v++    before = ["# somebody else's", "local all postgres peer", "host all all 127.0.0.1/32 scram-sha-256"]+    after = ["host app app_owner 10.0.0.3/32 md5", "host replication replicator 10.0.0.9/32 md5"]+    deployed = ["host replication replicator 10.0.0.2/32 md5", "host all rewinder 10.0.0.2/32 md5"]++    -- the lines of the member script that edit the file, run on a file+    runHba p hba = do+        let edits = [l | l <- lines (Pair.memberScript p Pair.A), "\"$hba\"" `isInfixOf` l, not ("hba=" `isPrefixOf` l)]+        assertBool "expected the script to edit the file" (not (null edits))+        (code, _, err) <- readProcessWithExitCode "bash" ["-c", unlines (["set -e", "hba=\"$1\""] <> edits), "bash", hba] ""+        assertEqual err ExitSuccess code+     at needle hay = length (takeWhile (not . isPrefixOf needle) (tails' hay))     tails' [] = [[]]     tails' xs@(_ : rest) = xs : tails' rest
test/Test/PostgresSwitchoverSpec.hs view
@@ -620,9 +620,10 @@         , Pair.pair_seed = Nothing         , Pair.pair_may_discard = Nothing         , Pair.pair_reseed = Nothing+        , Pair.pair_conn_security = Nothing         }   where-    member host identity = Pair.Member "root" host "main" 5432 (Just identity)+    member host identity = Pair.Member "root" host "main" 5432 (Just identity) Nothing  ------------------------------------------------------------------------------- 
test/Test/PostgresTemplateSpec.hs view
@@ -174,7 +174,7 @@  -- | The binaries the recipe reaches along its chain, as in "Test.PostgresInitSpec". shimmedCommands :: [String]-shimmedCommands = ["apt-get", "dpkg-query", "sudo", "bash", "chmod"]+shimmedCommands = ["apt-get", "apt-cache", "dpkg-query", "sudo", "bash", "chmod"]  templateLifecycle :: IO () templateLifecycle = requireExecutable "podman" $
+ test/Test/PostgresUserSpec.hs view
@@ -0,0 +1,582 @@+{- | The check of the @pg-user@ node ('Postgres.user', 'Postgres.userPassFile'):+the password the cluster holds against the declared one.++Layer 0 is the pure reading of @pg_authid@ ('Postgres.interpretRoleAuthRow'),+against verifiers computed independently (Python's @hashlib@, quoted below)+and against the module's own encoders by property. Layer 2 runs the node+against a real PostgreSQL in a podman container: the verifiers there are+PostgreSQL's own, so the SCRAM and MD5 readings are checked against the+server that writes them.+-}+module Test.PostgresUserSpec (tests, sandboxTests) where++import Control.Exception (try)+import qualified Data.ByteString as ByteString+import Data.Char (chr)+import qualified Data.Map.Strict as Map+import Data.Text (Text)+import qualified Data.Text as Text+import qualified Data.Text.Encoding as Text+import System.Exit (ExitCode (..))+import System.Directory (createDirectoryIfMissing)+import System.FilePath ((</>))+import System.Process.ListLike (CmdSpec (..), CreateProcess (..))+import Test.QuickCheck (Gen, chooseInt, counterexample, elements, forAll, listOf, vectorOf, (.&&.), (===), (==>))+import Test.Tasty (DependencyType (..), TestTree, sequentialTestGroup, testGroup)+import Test.Tasty.HUnit (assertBool, assertEqual, testCase)+import Test.Tasty.QuickCheck (testProperty)++import Salmon.Actions.UpDown (CheckResult (..))+import Salmon.Builtin.Extension+import Salmon.Builtin.Nodes.Binary (Command (..), CommandFailed, untrackedExec)+import Salmon.Builtin.Nodes.Filesystem (File (..))+import qualified Salmon.Builtin.Nodes.Podman as Podman+import qualified Salmon.Builtin.Nodes.Postgres as Postgres+import Salmon.Op.Actions (Act (..))+import qualified Salmon.Op.Dag as Dag+import Salmon.Op.Ref (mkRef)+import Salmon.Reporter (silent)++import Test.Harness++tests :: TestTree+tests =+    testGroup+        "Salmon.Builtin.Nodes.Postgres pg-user check (Layer 0)"+        [ testGroup "verdicts" verdicts+        , testGroup "properties" properties+        , testGroup "the declaration" declaration+        , testGroup "the password is not in argv" argv+        , testGroup "pg-script: the password is not in argv" pgScript+        ]++sandboxTests :: TestTree+sandboxTests =+    -- sequential: both shim PATH, which is process-global, and 'heavy' in+    -- Main serializes its direct children only+    sequentialTestGroup+        "Salmon.Builtin.Nodes.Postgres pg-user check (Layer 2, podman sandbox)"+        AllFinish+        [ testCase "create, satisfied, drift, rotation, md5" userLifecycle+        , testCase "SQL on stdin: quotes, a failing up, the replication role" stdinLifecycle+        ]++pw :: Text -> Postgres.Password+pw = Postgres.Password++verdict :: Text -> Text -> Text -> CheckResult+verdict role pass = Postgres.interpretRoleAuthRow role (pw pass)++isFailure :: CheckResult -> Bool+isFailure (Failure _) = True+isFailure _ = False++failureText :: CheckResult -> Text+failureText (Failure t) = t+failureText _ = ""++-------------------------------------------------------------------------------++{- | Computed with Python's @hashlib.pbkdf2_hmac@ and @hmac@, not with this+module:++> s = hashlib.pbkdf2_hmac("sha256", p.encode(), salt, it)+> stored = sha256(hmac(s, b"Client Key")); server = hmac(s, b"Server Key")+-}+pencil4096 :: Text+pencil4096 = "SCRAM-SHA-256$4096:AAECAwQFBgcICQoLDA0ODw==$zHCdol2044/ZyWzPLi7oxApCkamKw9Z+E4U/QApd/5Y=:dd5peBOitVnLNFu7VmwP+HiDaaw4OUCv396eVCWhYiE="++-- | Password @"hunter2\\n"@ (a passfile's trailing newline is part of the password), salt @"salmon-salt-0123"@, one round.+hunterNewline1 :: Text+hunterNewline1 = "SCRAM-SHA-256$1:c2FsbW9uLXNhbHQtMDEyMw==$p7/g8OA9+/BZbfP7B5DA24bQX4ND5K/liyZbhqI1kGw=:lckKKrz5pkMfxq2rvcO1eSlKOCyQUcPzqMkU911fZlg="++-- | @"md5" + md5("pencil" + "app")@.+pencilAppMd5 :: Text+pencilAppMd5 = "md506d701b4dd9f7cbb00816251a451b978"++verdicts :: [TestTree]+verdicts =+    [ testCase "a SCRAM verifier of the declared password is satisfied" $+        assertEqual "" Success (verdict "app" "pencil" ("t|" <> pencil4096 <> "\n"))+    , testCase "the encoder agrees with an independent implementation" $ do+        assertEqual "4096 rounds" pencil4096 (Postgres.scramVerifier 4096 (ByteString.pack [0 .. 15]) (pw "pencil"))+        assertEqual "one round, trailing newline" hunterNewline1 (Postgres.scramVerifier 1 "salmon-salt-0123" (pw "hunter2\n"))+        assertEqual "md5" pencilAppMd5 (Postgres.md5Verifier "app" (pw "pencil"))+    , testCase "a passfile's trailing newline is part of the password" $ do+        assertEqual "" Success (verdict "app" "hunter2\n" ("t|" <> hunterNewline1))+        assertBool "" (isFailure (verdict "app" "hunter2" ("t|" <> hunterNewline1)))+    , testCase "another password is a failure naming the role and nothing else" $+        assertEqual "" (Failure "the password of role app is not the declared one") (verdict "app" "pencil2" ("t|" <> pencil4096))+    , testCase "an MD5 verifier of the declared password is satisfied, salted by the role name" $ do+        assertEqual "" Success (verdict "app" "pencil" ("t|" <> pencilAppMd5))+        assertBool "another role" (isFailure (verdict "other" "pencil" ("t|" <> pencilAppMd5)))+        assertBool "another password" (isFailure (verdict "app" "pen" ("t|" <> pencilAppMd5)))+    , testCase "no row: the role does not exist" $+        assertEqual "" (Failure "role app does not exist") (verdict "app" "pencil" "")+    , testCase "NOLOGIN is a failure, whatever the password" $+        assertEqual "" (Failure "role app cannot log in") (verdict "app" "pencil" ("f|" <> pencil4096))+    , testCase "no password is a failure" $+        assertEqual "" (Failure "role app has no password") (verdict "app" "pencil" "t|\n")+    , testCase "what cannot be recomputed here is Unknown, not a drift" $ do+        assertEqual "unknown scheme" Unknown (verdict "app" "pencil" "t|SCRAM-SHA-512$4096:AAAA$x:y")+        assertEqual "plaintext of a very old server" Unknown (verdict "app" "pencil" "t|pencil")+        assertEqual "garbled SCRAM" Unknown (verdict "app" "pencil" "t|SCRAM-SHA-256$abc")+        assertEqual "bad salt" Unknown (verdict "app" "pencil" "t|SCRAM-SHA-256$4096:!!!$x:y")+        assertEqual "absurd rounds" Unknown (verdict "app" "pencil" "t|SCRAM-SHA-256$2000000000:AAECAwQFBgcICQoLDA0ODw==$x:y")+        assertEqual "zero rounds" Unknown (verdict "app" "pencil" "t|SCRAM-SHA-256$0:AAECAwQFBgcICQoLDA0ODw==$x:y")+        assertEqual+            "non-ASCII password under SCRAM (SASLprep)"+            Unknown+            (verdict "app" "p\233ncil" ("t|" <> Postgres.scramVerifier 1 "salt" (pw "p\233ncil")))+    , testCase "a non-ASCII password is still compared under MD5, which has no normalization" $+        assertEqual "" Success (verdict "app" "p\233ncil" ("t|" <> Postgres.md5Verifier "app" (pw "p\233ncil")))+    , testCase "the query names the role, quoted, and reads pg_authid" $ do+        let sql = Postgres.roleAuthSql "o'brien"+        assertBool (Text.unpack sql) ("rolname = 'o''brien'" `Text.isInfixOf` sql)+        assertBool (Text.unpack sql) ("FROM pg_authid" `Text.isInfixOf` sql)+    , testCase "a passfile that cannot be read is Unknown, and the cluster is not asked" $ do+        r <- Postgres.checkUserPassword 1 "app" (Postgres.readPassword "/nonexistent/salmon-test/passfile")+        assertEqual "" Unknown r+    ]++-------------------------------------------------------------------------------++-- | Printable ASCII and the whitespace a file can end with: what a passfile holds.+genAsciiPassword :: Gen Text+genAsciiPassword = Text.pack <$> listOf (elements (['\t', '\n'] <> [' ' .. '~']))++-- | A password no role name, scheme name or base64 text can contain by accident.+genMarkedPassword :: Gen Text+genMarkedPassword = ("SECRET~" <>) <$> genAsciiPassword++genSalt :: Gen ByteString.ByteString+genSalt = ByteString.pack . fmap fromIntegral <$> (chooseInt (1, 32) >>= \n -> vectorOf n (chooseInt (0, 255)))++genRole :: Gen Text+genRole = Text.pack <$> (chooseInt (1, 12) >>= \n -> vectorOf n (elements (['a' .. 'z'] <> ['0' .. '9'] <> "_")))++genIters :: Gen Int+genIters = chooseInt (1, 40)++properties :: [TestTree]+properties =+    [ testProperty "a SCRAM verifier made from a password is satisfied by it" $+        forAll genRole $ \role -> forAll genAsciiPassword $ \p -> forAll genSalt $ \salt -> forAll genIters $ \it ->+            verdict role p ("t|" <> Postgres.scramVerifier it salt (pw p)) === Success+    , testProperty "a SCRAM verifier made from another password is a failure" $+        forAll genRole $ \role -> forAll genAsciiPassword $ \p -> forAll genAsciiPassword $ \q -> forAll genSalt $ \salt -> forAll genIters $ \it ->+            p /= q ==> isFailure (verdict role q ("t|" <> Postgres.scramVerifier it salt (pw p)))+    , testProperty "an MD5 verifier is satisfied by its password and role, and only those" $+        forAll genRole $ \role -> forAll genRole $ \role' -> forAll genAsciiPassword $ \p -> forAll genAsciiPassword $ \q ->+            let v = "t|" <> Postgres.md5Verifier role (pw p)+             in (verdict role p v === Success)+                    .&&. ((p /= q) ==> isFailure (verdict role q v))+                    .&&. ((role /= role') ==> isFailure (verdict role' p v))+    , testProperty "a verifier's parameters change it: salt and rounds" $+        forAll genAsciiPassword $ \p -> forAll genSalt $ \s1 -> forAll genSalt $ \s2 -> forAll genIters $ \it ->+            s1 /= s2 ==> Postgres.scramVerifier it s1 (pw p) /= Postgres.scramVerifier it s2 (pw p)+                && Postgres.scramVerifier it s1 (pw p) /= Postgres.scramVerifier (it + 1) s1 (pw p)+    , testProperty "no failure text holds the password, the verifier or either key" $+        forAll genRole $ \role -> forAll genMarkedPassword $ \p -> forAll genMarkedPassword $ \q -> forAll genSalt $ \salt -> forAll genIters $ \it ->+            forAll (elements ["t|", "f|"]) $ \login ->+                let scram = Postgres.scramVerifier it salt (pw p)+                    md5 = Postgres.md5Verifier role (pw p)+                    secrets = [p, q, scram, md5, Text.drop 3 md5] <> Text.splitOn ":" (last (Text.splitOn "$" scram))+                    texts = [failureText (verdict role q (login <> v)) | v <- [scram, md5]]+                 in counterexample (show texts) $+                        and [not (s `Text.isInfixOf` t) | s <- secrets, t <- texts]+    , testProperty "the verdict depends on the first row only, and on surrounding whitespace not at all" $+        forAll genRole $ \role -> forAll genAsciiPassword $ \p -> forAll genSalt $ \salt ->+            let row = "t|" <> Postgres.scramVerifier 2 salt (pw p)+             in verdict role p ("\n" <> row <> "\nt|junk\n") === verdict role p row+    , testProperty "a password with a character above ASCII is never judged under SCRAM" $+        forAll genAsciiPassword $ \p -> forAll (chooseInt (128, 0x2FFF)) $ \c ->+            let p' = p <> Text.singleton (chr c)+             in verdict "app" p' ("t|" <> Postgres.scramVerifier 1 "salt" (pw p')) === Unknown+    ]++-------------------------------------------------------------------------------++pgUserAct :: Op -> Act Extension+pgUserAct = actAt "pg-user" "app"++actAt :: Text -> Text -> Op -> Act Extension+actAt kind key o =+    maybe (error ("no " <> Text.unpack kind <> " node")) id $+        Map.lookup (mkRef kind key) (Dag.dagNodes (Dag.foldDag Dag.sameRepresentative (evalDeps o)))++{- | What the node renders is what it rendered before it had a check: a+changed representative is a restart of what stands on it.+-}+declaration :: [TestTree]+declaration =+    [ testCase "user: same ref and help, no notes" $ do+        let act = pgUserAct (Postgres.user silent ignoreTrack ignoreTrack 5432 (Postgres.User "app") (pw "pencil"))+        assertEqual "help" "create user app" act.extension.help+        assertEqual "notes" [] act.extension.notes+    , testCase "userPassFile: same ref and help, no notes, nothing of the password" $ do+        let act = pgUserAct (Postgres.userPassFile silent ignoreTrack ignoreTrack 5432 (PreExisting "/run/secrets/app.pass") (Postgres.User "app"))+        assertEqual "help" "set user password for app from file at /run/secrets/app.pass" act.extension.help+        assertEqual "notes" [] act.extension.notes+    , testCase "replicationUser: same ref and help, no notes" $ do+        let act = actAt "pg-replication-user" "repl" (Postgres.replicationUser silent ignoreTrack ignoreTrack 5432 (Postgres.User "repl") (pw "pencil"))+        assertEqual "help" "create replication user repl" act.extension.help+        assertEqual "notes" [] act.extension.notes+    , testProperty "user: nothing public holds the password" $+        forAll genMarkedPassword $ \p ->+            let act = pgUserAct (Postgres.user silent ignoreTrack ignoreTrack 5432 (Postgres.User "app") (pw p))+             in not (any (p `Text.isInfixOf`) (act.extension.help : act.extension.notes))+    ]++-------------------------------------------------------------------------------++-- | The two commands that carry a password, as the nodes build them.+roleCommands :: Text -> [Postgres.PsqlAdmin]+roleCommands p = [Postgres.CreateUser "app" (pw p), Postgres.CreateReplicationUser "repl" (pw p)]++argvOf :: Postgres.PsqlAdmin -> [String]+argvOf c = case cmdspec (prepare (Postgres.psqlAdminRun_Sudo 5432) c) of+    RawCommand exe args -> exe : args+    ShellCommand line -> [line]++{- | 'Postgres.psqlAdminRun_Sudo' with the executable swapped for a stand-in+that records what it was given, its arguments untouched. The swap is in the+'CreateProcess', not on @PATH@: the suite runs groups in parallel in one+process, and @PATH@ is the process's.+-}+recordingCommand :: FilePath -> Command "psql" Postgres.PsqlAdmin+recordingCommand standIn = Command $ \c ->+    let p = prepare (Postgres.psqlAdminRun_Sudo 5432) c+     in case cmdspec p of+            RawCommand _ args -> p{cmdspec = RawCommand standIn args}+            ShellCommand _ -> p++isInfixOfS :: String -> String -> Bool+isInfixOfS needle hay = Text.pack needle `Text.isInfixOf` Text.pack hay++{- | @pg-user@ and @pg-replication-user@ used to put @ALTER ROLE ... PASSWORD+'...'@ in @psql -c@, so in argv, which every local user reads in @/proc@+while psql runs and which 'Binary.CommandStart' reports. The SQL now goes on+stdin; argv, every report and the failure text are free of it.+-}+argv :: [TestTree]+argv =+    [ testProperty "the role commands' argv never holds the password" $+        forAll genMarkedPassword $ \p ->+            let argvs = fmap argvOf (roleCommands p)+             in counterexample (show argvs) $+                    not (any (any ("SECRET~" `isInfixOfS`)) argvs)+    , testProperty "their stdin holds verifiers of an ASCII password, never the password" $+        forAll genMarkedPassword $ \p -> forAll genSalt $ \salt ->+            and+                [ not (p `Text.isInfixOf` sql)+                    && (" PASSWORD " <> quoted (Postgres.scramVerifier 4096 salt (pw p)) <> ";\n") `Text.isInfixOf` sql+                    && (" PASSWORD " <> quoted (Postgres.md5Verifier role (pw p)) <> ";\n") `Text.isInfixOf` sql+                | (role, c) <- zip ["app", "repl"] (roleCommands p)+                , let sql = Text.decodeUtf8 (Postgres.psqlAdminStdinSalted salt c)+                ]+    , testProperty "the verifiers it sends are ones the check accepts" $+        forAll genRole $ \role -> forAll genAsciiPassword $ \p -> forAll genSalt $ \salt ->+            (verdict role p ("t|" <> Postgres.scramVerifier 4096 salt (pw p)) === Success)+                .&&. (verdict role p ("t|" <> Postgres.md5Verifier role (pw p)) === Success)+    , testCase "the scheme follows password_encryption, read with \\gset" $ do+        let sql = Text.decodeUtf8 (Postgres.psqlAdminStdinSalted "0123456789abcdef" (Postgres.CreateUser "app" (pw "pencil")))+        assertEqual+            "script"+            ( Text.unlines+                [ "DO $$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'app') THEN CREATE ROLE app WITH LOGIN; END IF; END $$;"+                , "SELECT current_setting('password_encryption') IN ('md5', 'on') AS salmon_md5 \\gset"+                , "\\if :salmon_md5"+                , "ALTER ROLE app WITH LOGIN PASSWORD " <> quoted (Postgres.md5Verifier "app" (pw "pencil")) <> ";"+                , "\\else"+                , "ALTER ROLE app WITH LOGIN PASSWORD " <> quoted (Postgres.scramVerifier 4096 "0123456789abcdef" (pw "pencil")) <> ";"+                , "\\endif"+                ]+            )+            sql+    , testCase "each up draws a fresh 16-byte salt" $ do+        let c = Postgres.CreateUser "app" (pw "pencil")+            saltOf dat =+                [ Text.takeWhile (/= '$') rest+                | l <- Text.lines (Text.decodeUtf8 dat)+                , Just rest <- [Text.stripPrefix "ALTER ROLE app WITH LOGIN PASSWORD 'SCRAM-SHA-256$4096:" l]+                ]+        a <- Postgres.psqlAdminStdin c+        b <- Postgres.psqlAdminStdin c+        assertBool "two runs, two salts" (saltOf a /= saltOf b)+        assertEqual "a 16-byte salt is 24 base64 characters" [24] (fmap Text.length (saltOf a))+    , testCase "psql reads the script on stdin: no psqlrc, terse errors, stops on the first one" $+        mapM_+            ( assertEqual+                "argv"+                ["sudo", "-u", "postgres", "psql", "-p", "5432", "-X", "-q", "-v", "ON_ERROR_STOP=1", "-v", "VERBOSITY=terse", "-f", "-"]+                . argvOf+            )+            (roleCommands "pencil")+    , testCase "a password above ASCII is still sent as it is, one quoted literal" $ do+        let sql = Text.decodeUtf8 (Postgres.psqlAdminStdinSalted "salt" (Postgres.CreateUser "app" (pw "it's \233t\233")))+        assertBool (Text.unpack sql) ("ALTER ROLE app WITH LOGIN PASSWORD 'it''s \233t\233';\n" `Text.isSuffixOf` sql)+        assertBool "no \\if" (not ("\\if" `Text.isInfixOf` sql))+    , testCase "the role attributes are what they were" $ do+        let sqlOf = Text.decodeUtf8 . Postgres.psqlAdminStdinSalted "salt"+            u = Postgres.CreateUser "app" (pw "pencil")+            repl = Postgres.CreateReplicationUser "repl" (pw "pencil")+        assertBool "user" ("ALTER ROLE app WITH LOGIN PASSWORD 'SCRAM-SHA-256$" `Text.isInfixOf` sqlOf u)+        assertBool "user, created" ("THEN CREATE ROLE app WITH LOGIN; END IF" `Text.isInfixOf` sqlOf u)+        assertBool "repl" ("ALTER ROLE repl WITH REPLICATION LOGIN PASSWORD 'md5" `Text.isInfixOf` sqlOf repl)+        assertBool "repl, created" ("THEN CREATE ROLE repl WITH REPLICATION LOGIN; END IF" `Text.isInfixOf` sqlOf repl)+    , testCase "no other command sends anything on stdin" $+        Postgres.psqlAdminStdin Postgres.ReloadConf >>= assertEqual "" ""+    , testCase "run for real: argv, stdin, every report and the failure text are free of the password" $+        withTempDir $ \tmp -> do+            let secret = "SECRET~run-for-real" :: Text+                ok = tmp </> "ok"+                failing = tmp </> "failing"+            writeExecutable ok $+                unlines+                    [ "#!/bin/bash"+                    , "printf '%s\\n' \"$@\" > " <> show (tmp </> "argv")+                    , "cat > " <> show (tmp </> "stdin")+                    ]+            writeExecutable failing $+                unlines ["#!/bin/bash", "cat > /dev/null", "echo 'psql:<stdin>:2: ERROR:  boom' >&2", "exit 3"]+            mapM_+                ( \c -> do+                    (r, readBack) <- capture+                    dat <- Postgres.psqlAdminStdin c+                    untrackedExec (recordingCommand ok) c dat r+                    recorded <- readFile (tmp </> "argv")+                    stdin <- ByteString.readFile (tmp </> "stdin")+                    reports <- readBack+                    assertBool recorded ("-f\n-\n" `isInfixOfS` recorded)+                    assertBool "argv as executed" (not (Text.unpack secret `isInfixOfS` recorded))+                    assertBool "a verifier went on stdin" ("PASSWORD 'SCRAM-SHA-256$4096:" `Text.isInfixOf` Text.decodeUtf8 stdin)+                    assertBool "the password did not" (not (secret `Text.isInfixOf` Text.decodeUtf8 stdin))+                    assertBool "reports" (not (any ((Text.unpack secret `isInfixOfS`) . show) reports))++                    (r', readBack') <- capture+                    failed <- try (untrackedExec (recordingCommand failing) c dat r') :: IO (Either CommandFailed ())+                    reports' <- readBack'+                    case failed of+                        Right () -> assertBool "a failing psql fails up" False+                        Left e -> assertBool (show e) (not (Text.unpack secret `isInfixOfS` show e))+                    assertBool "reports of the failure" (not (any ((Text.unpack secret `isInfixOfS`) . show) reports'))+                )+                (roleCommands secret)+    ]++quoted :: Text -> Text+quoted t = "'" <> Text.replace "'" "''" t <> "'"++-------------------------------------------------------------------------------++scriptConn :: Text -> Postgres.ConnString Postgres.Password+scriptConn p = Postgres.ConnString (Postgres.Server "localhost" 5432) (Postgres.User "app") (pw p) (Postgres.Database "appdb")++scriptArgv :: Postgres.ConnString a -> [String]+scriptArgv c = case cmdspec (prepare (Postgres.psqlUserRun c) (Postgres.UserScript "/srv/migrations/001.sql")) of+    RawCommand exe args -> exe : args+    ShellCommand line -> [line]++{- | @pg-script@ ('Postgres.userScript', 'Postgres.userScriptInMemoryPass')+used to give psql a @postgresql://user:password\@…@ URL in argv. The password+now goes on a bash's stdin, which puts it in @PGPASSWORD@ and @exec@s psql: it+is in no argv, no 'CreateProcess' (whose environment is reported too) and no+failure text.+-}+pgScript :: [TestTree]+pgScript =+    [ testProperty "argv never holds the password" $+        forAll genMarkedPassword $ \p ->+            let a = scriptArgv (scriptConn p)+             in counterexample (show a) (not (any ("SECRET~" `isInfixOfS`) a))+    , testCase "argv: a bash reading the password, then psql with a URL without one" $ do+        assertEqual+            "argv"+            [ "bash"+            , "-c"+            , "IFS= read -r -d '' PGPASSWORD || true; export PGPASSWORD; exec psql \"$@\" </dev/null"+            , "psql"+            , "postgresql://app@localhost:5432/appdb"+            , "-f"+            , "/srv/migrations/001.sql"+            ]+            (scriptArgv (scriptConn "pencil"))+        assertBool "no environment of its own" (null (env (prepare (Postgres.psqlUserRun (scriptConn "pencil")) (Postgres.UserScript "/x.sql"))))+    , testCase "stdin is the password's bytes, nothing else" $+        assertEqual "" (Text.encodeUtf8 "pencil\n") (Postgres.passwordStdin (pw "pencil\n"))+    , testCase "same ref and help, no notes" $ do+        let o = Postgres.userScriptInMemoryPass silent ignoreTrack ignoreTrack (scriptConn "SECRET~") (PreExisting "/srv/migrations/001.sql")+            -- keyed by the script's path, a FilePath+            act =+                maybe (error "no pg-script node") id $+                    Map.lookup (mkRef "pg-script" ("/srv/migrations/001.sql" :: FilePath)) (Dag.dagNodes (Dag.foldDag Dag.sameRepresentative (evalDeps o)))+        assertEqual "help" "runs pg script /srv/migrations/001.sql" act.extension.help+        assertEqual "notes" [] act.extension.notes+    , testCase "run for real: psql gets the password in PGPASSWORD only, newlines kept; reports and failure text are free of it" $+        withTempDir $ \tmp -> do+            let secret = "SECRET~it's a \\ back:slash\n" :: Text+                c = scriptConn secret+                bin = tmp </> "bin"+                withPath p = Command $ \u -> (prepare (Postgres.psqlUserRun c) u){env = Just [("PATH", p <> ":/usr/bin:/bin")]}+            createDirectoryIfMissing True (bin </> "ok")+            createDirectoryIfMissing True (bin </> "failing")+            writeExecutable (bin </> "ok" </> "psql") $+                unlines+                    [ "#!/bin/bash"+                    , "printf '%s\\n' \"$@\" > " <> show (tmp </> "argv")+                    , "printf '%s' \"$PGPASSWORD\" > " <> show (tmp </> "pgpassword")+                    , "cat > " <> show (tmp </> "stdin")+                    ]+            writeExecutable (bin </> "failing" </> "psql") $+                unlines ["#!/bin/bash", "echo 'psql:/srv/migrations/001.sql:2: ERROR:  boom' >&2", "exit 3"]+            (r, readBack) <- capture+            untrackedExec (withPath (bin </> "ok")) (Postgres.UserScript "/srv/migrations/001.sql") (Postgres.passwordStdin (pw secret)) r+            recorded <- readFile (tmp </> "argv")+            passed <- ByteString.readFile (tmp </> "pgpassword")+            stdin <- ByteString.readFile (tmp </> "stdin")+            reports <- readBack+            assertEqual "argv as executed" "postgresql://app@localhost:5432/appdb\n-f\n/srv/migrations/001.sql\n" recorded+            assertEqual "PGPASSWORD is the password, byte for byte" (Text.encodeUtf8 secret) passed+            assertEqual "psql's stdin is empty" "" stdin+            assertBool "reports" (not (any (("SECRET~" `isInfixOfS`) . show) reports))++            (r', readBack') <- capture+            failed <- try (untrackedExec (withPath (bin </> "failing")) (Postgres.UserScript "/srv/migrations/001.sql") (Postgres.passwordStdin (pw secret)) r') :: IO (Either CommandFailed ())+            reports' <- readBack'+            case failed of+                Right () -> assertBool "a failing psql fails up" False+                Left e -> assertBool (show e) (not ("SECRET~" `isInfixOfS` show e))+            assertBool "reports of the failure" (not (any (("SECRET~" `isInfixOfS`) . show) reports'))+    ]++-------------------------------------------------------------------------------++{- | Against PostgreSQL 15 (Debian bookworm): the role is created by @up@,+the check is satisfied after it, a password changed by hand is seen, a new+passfile is seen, @up@ puts it back, and an MD5 verifier (what an older+cluster or @password_encryption = md5@ writes) is read too.+-}+userLifecycle :: IO ()+userLifecycle = requireExecutable "podman" $+    withContainer (Podman.Image "debian:bookworm") (Podman.PortMapping "15442" "5432" Podman.TCPPort) $ \cid -> do+        podmanExec_ cid ["apt-get", "update", "-qq"]+        podmanExec_ cid ["bash", "-c", "DEBIAN_FRONTEND=noninteractive apt-get install -y -qq sudo postgresql"]+        podmanExec_ cid ["bash", "-c", "v=$(pg_lsclusters --no-header | awk '{print $1}' | sort -n | tail -n1); pg_ctlcluster \"$v\" main status >/dev/null || pg_ctlcluster \"$v\" main start"]+        let asPostgres sql = podmanExec_ cid ["sudo", "-u", "postgres", "psql", "-X", "-c", sql]+        withTempDir $ \tmp -> do+            let passfile = tmp </> "app.pass"+                node = Postgres.userPassFile silent ignoreTrack ignoreTrack 5432 (PreExisting passfile) (Postgres.User "app")+                checkNow = (pgUserAct node).extension.check+            writeFile passfile "first-password\n"+            withShimmedPath cid ["sudo"] $ do+                checkNow >>= assertEqual "before up" (Failure "role app does not exist")+                runUp node >>= assertBool "up creates the role"+                checkNow >>= assertEqual "after up" Success++                -- the verifier really is SCRAM, and the password really works+                (code, out, err) <- podmanExecCapture cid ["sudo", "-u", "postgres", "psql", "-tAX", "-c", "SELECT left(rolpassword, 14) FROM pg_authid WHERE rolname = 'app'"]+                assertEqual err ExitSuccess code+                assertEqual "scheme" "SCRAM-SHA-256$" (filter (/= '\n') out)++                asPostgres "ALTER ROLE app PASSWORD 'changed-by-hand'"+                checkNow >>= assertEqual "a drift is seen" (Failure "the password of role app is not the declared one")+                runUp node >>= assertBool "up puts the declared one back"+                checkNow >>= assertEqual "converged again" Success++                writeFile passfile "rotated-password\n"+                checkNow >>= assertBool "a rotation is seen before up" . isFailure+                runUp node >>= assertBool "up applies the rotation"+                checkNow >>= assertEqual "rotated" Success++                asPostgres "ALTER ROLE app NOLOGIN"+                checkNow >>= assertEqual "nologin is seen" (Failure "role app cannot log in")+                runUp node >>= assertBool "up gives login back"+                checkNow >>= assertEqual "login again" Success++                writeFile passfile "md5-password"+                asPostgres "SET password_encryption = 'md5'; ALTER ROLE app PASSWORD 'md5-password'"+                checkNow >>= assertEqual "an MD5 verifier of the declared password" Success+                asPostgres "SET password_encryption = 'md5'; ALTER ROLE app PASSWORD 'other'"+                checkNow >>= assertBool "an MD5 verifier of another" . isFailure++{- | The role SQL as psql reads it on stdin, against PostgreSQL 15: a password+with a quote and a backslash is set as it is (the check, which reads the+verifier, is satisfied by it), a failing statement fails @up@ (a script read+from stdin exits 0 after an error unless @ON_ERROR_STOP@ is set), and the+replication role is created with its attribute and password.+-}+stdinLifecycle :: IO ()+stdinLifecycle = requireExecutable "podman" $+    withContainer (Podman.Image "debian:bookworm") (Podman.PortMapping "15443" "5432" Podman.TCPPort) $ \cid -> do+        podmanExec_ cid ["apt-get", "update", "-qq"]+        podmanExec_ cid ["bash", "-c", "DEBIAN_FRONTEND=noninteractive apt-get install -y -qq sudo postgresql"]+        podmanExec_ cid ["bash", "-c", "v=$(pg_lsclusters --no-header | awk '{print $1}' | sort -n | tail -n1); pg_ctlcluster \"$v\" main status >/dev/null || pg_ctlcluster \"$v\" main start"]+        withTempDir $ \tmp -> do+            let passfile = tmp </> "app.pass"+                node = Postgres.userPassFile silent ignoreTrack ignoreTrack 5432 (PreExisting passfile) (Postgres.User "app")+            writeFile passfile "it's a \\ back\\slash"+            withShimmedPath cid ["sudo"] $ do+                runUp node >>= assertBool "up with a quote and a backslash"+                (pgUserAct node).extension.check >>= assertEqual "the password is the file's, as it is" Success++                let reserved = Postgres.userPassFile silent ignoreTrack ignoreTrack 5432 (PreExisting passfile) (Postgres.User "pg_app")+                runUp reserved >>= assertBool "a reserved role name fails up" . not++                let repl = Postgres.replicationUser silent ignoreTrack ignoreTrack 5432 (Postgres.User "repl") (pw "repl-password")+                runUp repl >>= assertBool "up creates the replication role"+                (code, out, err) <- podmanExecCapture cid ["sudo", "-u", "postgres", "psql", "-tAX", "-c", "SELECT rolreplication, rolcanlogin FROM pg_roles WHERE rolname = 'repl'"]+                assertEqual err ExitSuccess code+                assertEqual "attributes" "t|t" (filter (/= '\n') out)+                Postgres.checkUserPassword 5432 "repl" (pure (pw "repl-password")) >>= assertEqual "its password" Success++                -- the verifier salmon computed is one the server logs a client in with+                (code', out', err') <- podmanExecCapture cid ["env", "PGPASSWORD=repl-password", "psql", "-X", "-tA", "-h", "127.0.0.1", "-U", "repl", "-d", "postgres", "-c", "SELECT 1"]+                assertEqual (out' <> err') ExitSuccess code'++                -- with every statement logged, the log holds a verifier, not the password+                podmanExec_ cid ["sudo", "-u", "postgres", "psql", "-X", "-c", "ALTER SYSTEM SET log_statement = 'all'"]+                podmanExec_ cid ["sudo", "-u", "postgres", "psql", "-X", "-c", "SELECT pg_reload_conf()"]+                let logged = Postgres.replicationUser silent ignoreTrack ignoreTrack 5432 (Postgres.User "logged") (pw "never-in-the-log")+                runUp logged >>= assertBool "up with log_statement = all"+                Postgres.checkUserPassword 5432 "logged" (pure (pw "never-in-the-log")) >>= assertEqual "its password" Success+                (_, logs, _) <- podmanExecCapture cid ["bash", "-c", "cat /var/log/postgresql/*.log"]+                assertBool "the ALTER ROLE was logged" ("ALTER ROLE logged WITH REPLICATION LOGIN PASSWORD 'SCRAM-SHA-256$4096:" `isInfixOfS` logs)+                assertBool "the password was not" (not ("never-in-the-log" `isInfixOfS` logs))++                -- password_encryption = md5: an MD5 verifier is sent, and the check reads it+                podmanExec_ cid ["sudo", "-u", "postgres", "psql", "-X", "-c", "ALTER SYSTEM SET password_encryption = 'md5'"]+                podmanExec_ cid ["sudo", "-u", "postgres", "psql", "-X", "-c", "SELECT pg_reload_conf()"]+                let md5 = Postgres.replicationUser silent ignoreTrack ignoreTrack 5432 (Postgres.User "md5role") (pw "md5-password")+                runUp md5 >>= assertBool "up under md5"+                (code'', out'', err'') <- podmanExecCapture cid ["sudo", "-u", "postgres", "psql", "-tAX", "-c", "SELECT rolpassword FROM pg_authid WHERE rolname = 'md5role'"]+                assertEqual err'' ExitSuccess code''+                assertEqual "an MD5 verifier" (Text.unpack (Postgres.md5Verifier "md5role" (pw "md5-password"))) (filter (/= '\n') out'')+                Postgres.checkUserPassword 5432 "md5role" (pure (pw "md5-password")) >>= assertEqual "its password" Success++                -- a role whose password file ends with a newline, as openssl rand -hex writes it+                podmanExec_ cid ["sudo", "-u", "postgres", "psql", "-X", "-c", "ALTER SYSTEM RESET password_encryption"]+                podmanExec_ cid ["sudo", "-u", "postgres", "psql", "-X", "-c", "SELECT pg_reload_conf()"]+                let scriptedPass = tmp </> "scripted.pass"+                writeFile scriptedPass "scripted-password\n"+                runUp (Postgres.userPassFile silent ignoreTrack ignoreTrack 5432 (PreExisting scriptedPass) (Postgres.User "scripted"))+                    >>= assertBool "up creates the scripted role"++            -- pg-script: the host's psql (bash, PGPASSWORD) against the container's published port+            requireExecutable "psql" $ do+                podmanExec_ cid ["sudo", "-u", "postgres", "psql", "-X", "-c", "ALTER SYSTEM SET listen_addresses = '*'"]+                podmanExec_ cid ["bash", "-c", "echo 'host all all 0.0.0.0/0 md5' >> \"$(sudo -u postgres psql -tAX -c 'SHOW hba_file')\""]+                podmanExec_ cid ["bash", "-c", "v=$(pg_lsclusters --no-header | awk '{print $1}' | sort -n | tail -n1); pg_ctlcluster \"$v\" main restart"]+                podmanExec_ cid ["sudo", "-u", "postgres", "psql", "-X", "-c", "GRANT CREATE ON SCHEMA public TO scripted"]+                let script = tmp </> "001.sql"+                    conn = Postgres.ConnString (Postgres.Server "127.0.0.1" 15443) (Postgres.User "scripted") (tmp </> "scripted.pass") (Postgres.Database "postgres")+                writeFile script "CREATE TABLE scripted_was_here (x int);\n"+                runUp (Postgres.userScript silent ignoreTrack ignoreTrack conn (PreExisting script)) >>= assertBool "pg-script logs in with the file's password, newline and all"+                (code, out, err) <- podmanExecCapture cid ["sudo", "-u", "postgres", "psql", "-tAX", "-c", "SELECT count(*) FROM pg_tables WHERE tablename = 'scripted_was_here'"]+                assertEqual err ExitSuccess code+                assertEqual "the script ran" "1" (filter (/= '\n') out)+                writeFile (tmp </> "wrong.pass") "not-the-password\n"+                runUp (Postgres.userScript silent ignoreTrack ignoreTrack conn{Postgres.connstring_user_pass = tmp </> "wrong.pass"} (PreExisting script))+                    >>= assertBool "a wrong password fails up" . not+                (_, logs, _) <- podmanExecCapture cid ["bash", "-c", "cat /var/log/postgresql/*.log"]+                assertBool "no password in the log" (not ("scripted-password" `isInfixOfS` logs))
test/Test/QemuShutdownSpec.hs view
@@ -6,6 +6,11 @@ powers off (the socket goes away, no hard stop needed), one that ignores ACPI (still listening at the deadline, so the caller must stop it hard) and a VM that was never running (nothing to send to).++The run state ('runState', 'pause', 'resume') is read two ways: the parser on+what a real monitor printed (QEMU 8.2.2, captured, terminal echo and all),+and the three calls against a real @qemu-system-x86_64@ started with no disk+and no guest -- a process, not a VM -- skipped loudly where qemu is missing. -} module Test.QemuShutdownSpec (tests) where @@ -16,8 +21,10 @@ import qualified Data.ByteString.Char8 as C8 import Network.Socket hiding (shutdown) import qualified Network.Socket.ByteString as SocketBS-import Salmon.Builtin.Nodes.Qemu (reset, shutdown)-import System.Directory (removeFile)+import Salmon.Builtin.Nodes.Qemu (RunState (..), interpretStatus, listening, pause, reset, resume, runState, shutdown)+import System.Directory (findExecutable, removeFile)+import System.IO (hPutStrLn, stderr)+import System.Process (CreateProcess (..), StdStream (..), proc, terminateProcess, waitForProcess, withCreateProcess) import System.FilePath ((</>)) import System.IO.Temp (withSystemTempDirectory) import Test.Tasty (TestTree, testGroup)@@ -31,7 +38,74 @@         , testCase "a guest that ignores ACPI: False at the deadline" ignoresAcpi         , testCase "no monitor listening: True, nothing sent" neverRan         , testCase "reset sends system_reset" sendsReset+        , testCase "info status: running, paused, and neither" readsStatus+        , testCase "info status: half an answer is no answer" partialStatus+        , testCase "no monitor listening: the run state is unknown" unknownWithoutMonitor+        , testCase "a real qemu: paused, resumed, paused again" realMonitor         ]++-- | What QEMU 8.2.2's monitor writes back for @info status@ on a fresh connection.+captured :: String -> C8.ByteString+captured status =+    C8.pack $+        "QEMU 8.2.2 monitor - type 'help' for more information\r\n"+            <> "(qemu) i\ESC[K\ESC[Din\ESC[K\ESC[D\ESC[Dinfo status\ESC[K\r\n"+            <> "VM status: "+            <> status+            <> "\r\n(qemu) "++readsStatus :: IO ()+readsStatus = do+    interpretStatus (captured "running") @?= Just Running+    interpretStatus (captured "paused") @?= Just Paused+    -- qemu started with -S, before anything told it to go+    interpretStatus (captured "paused (prelaunch)") @?= Just Paused+    interpretStatus (captured "shutdown") @?= Just (OtherState "shutdown")++partialStatus :: IO ()+partialStatus = do+    interpretStatus (C8.pack "QEMU 8.2.2 monitor\r\n(qemu) info status\r\n") @?= Nothing+    -- the line is there but not finished: "paused" may yet follow "VM status: "+    interpretStatus (C8.pack "(qemu) info status\r\nVM status: ") @?= Nothing+    interpretStatus (C8.pack "(qemu) info status\r\nVM status: runn") @?= Nothing++unknownWithoutMonitor :: IO ()+unknownWithoutMonitor = withSystemTempDirectory "qemu-mon" $ \dir -> do+    st <- runState (dir </> "absent.sock")+    st @?= Nothing+    ok <- pause (dir </> "absent.sock")+    ok @?= False++{- | qemu itself, with nothing to run: @-S@ starts it stopped, which is the+state 'pause' produces, so the three calls can be read back without a guest.+-}+realMonitor :: IO ()+realMonitor = do+    qemu <- findExecutable "qemu-system-x86_64"+    case qemu of+        Nothing -> hPutStrLn stderr "SKIPPED: `qemu-system-x86_64` not found on PATH; the monitor's run state is only checked against captured output"+        Just bin -> withSystemTempDirectory "qm" $ \dir -> do+            let sock = dir </> "m.sock"+                cp =+                    (proc bin ["-display", "none", "-machine", "accel=tcg", "-m", "32", "-S", "-monitor", "unix:" <> sock <> ",server,nowait"])+                        { std_in = NoStream+                        , std_out = NoStream+                        , std_err = NoStream+                        }+            withCreateProcess cp $ \_ _ _ ph -> do+                waitListening sock (50 :: Int)+                runState sock >>= (@?= Just Paused)+                resume sock >>= (@?= True)+                runState sock >>= (@?= Just Running)+                pause sock >>= (@?= True)+                runState sock >>= (@?= Just Paused)+                terminateProcess ph+                void (waitForProcess ph)+  where+    waitListening _ 0 = fail "qemu never opened its monitor"+    waitListening sock n = do+        up <- listening sock+        if up then pure () else threadDelay 100000 >> waitListening sock (n - 1)  -- | The commands received: the liveness probes (connect, hang up) send nothing and are dropped. commands :: Fake -> IO [String]
test/Test/QemuSmokeSpec.hs view
@@ -8,15 +8,20 @@ 'Test.Harness.hasVmPrivileges' (bridge/tap + a systemd unit running qemu, matching this whole VM tier's documented privilege assumption — see "Salmon.Builtin.Nodes.Qemu"'s haddock) and a pre-built VM rootfs at-'smokeRootfs', with-'Salmon.Builtin.Nodes.Debian.Debootstrap.vmEssentials' and-'Salmon.Builtin.Nodes.Debian.Debootstrap.ensureVm9pBoot' already applied-(this test does not run debootstrap itself, same stance as-'Test.Harness.withVm') — no SSH key pre-provisioning needed, 'withVm'-generates and trusts its own per-boot CA-signed key:+'smokeRootfs' (this test does not run debootstrap itself, same stance as+'Test.Harness.withVm'). That rootfs is+built by ops, not by hand: @salmon-qemu-smoke-rootfs-fixture@+(@salmon-ops\/fixtures\/QemuSmokeRootfsFixture.hs@) is+'Salmon.Builtin.Nodes.Debian.Debootstrap.rootTree' with+'Salmon.Builtin.Nodes.Debian.Debootstrap.vmEssentials', then+'Salmon.Builtin.Nodes.Debian.Debootstrap.ensureVm9pBoot', then @etc\/ssh@+handed to the user who runs the tests. It is the one step that needs root: -> sudo debootstrap --include=linux-image-amd64,openssh-server stable /var/lib/salmon-test-vms/smoke/root+> sudo $(cabal list-bin salmon-qemu-smoke-rootfs-fixture) "$USER" +No SSH key pre-provisioning is needed, 'withVm' generates and trusts its+own per-boot CA-signed key.+ Both preconditions are checked and skipped loudly (not failed) if unmet, same "skip/fail loudly, don't hang" spirit as 'Test.Harness.requireExecutable'. -}@@ -50,7 +55,7 @@                 then skip "qemu-system-x86_64 not found on PATH"                 else                     if not hasRootfs-                        then skip ("no VM rootfs at " <> smokeRootfs <> " (run debootstrap by hand first, see this module's haddock)")+                        then skip ("no VM rootfs at " <> smokeRootfs <> " (build it with salmon-qemu-smoke-rootfs-fixture, see this module's haddock)")                         else                             withVm smokeRootfs $ \access -> do                                 (code, out, _err) <- sshToVm access ["echo smoke-ok"]
+ test/Test/QuadletSpec.hs view
@@ -0,0 +1,1092 @@+{-# LANGUAGE OverloadedStrings #-}++{- | Layer 0 coverage for "Salmon.Builtin.Nodes.Podman.Quadlet" and for the+instance login in "Salmon.Builtin.Nodes.Gcp.ArtifactRegistry": what is+rendered, what the check concludes, and what a re-declaration can see.++One group is not pure: where podman's own quadlet generator is installed, the+rendered file is handed to it in dry-run mode, which is the only local+evidence that the keys written are keys this podman accepts. It reads a+scratch directory and writes nothing; it is skipped loudly without the+generator. Nothing here starts a container, talks to systemd, or reaches a+registry or a metadata server; "Test.QuadletUserSpec" runs the node against+this user's own systemd, and the system scope, the pull from a registry and+the instance login are Layer 3 claims not made in either.+-}+module Test.QuadletSpec (tests) where++import qualified Data.ByteString.Lazy.Char8 as LC8+import qualified Data.Map.Strict as Map+import Control.Exception (SomeException, try)+import qualified Data.ByteString as ByteString+import Data.Bits ((.&.))+import Data.Foldable (toList)+import Data.IORef (IORef, atomicModifyIORef', modifyIORef, newIORef, readIORef)+import Data.List (isInfixOf, sort)+import Data.Text (Text)+import qualified Data.Text as Text+import qualified Data.Text.IO as Text+import Data.Time.Clock (addUTCTime)+import Data.Time.Clock.POSIX (posixSecondsToUTCTime)+import System.Directory (createDirectory, doesDirectoryExist, doesFileExist, doesPathExist, listDirectory)+import System.Environment (getEnvironment)+import System.Exit (ExitCode (..))+import System.FilePath ((</>))+import System.IO (hPutStrLn, stderr)+import System.Posix.Files (fileMode, getFileStatus, setFileMode)+import System.Posix.User (getEffectiveUserID)+import System.Process (CreateProcess (..), proc, readCreateProcessWithExitCode)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, assertEqual, assertFailure, testCase)++import Salmon.Actions.UpDown (CheckResult (..))+import Salmon.Builtin.Extension+import qualified Salmon.Builtin.Nodes.Gcp.ArtifactRegistry as ArtifactRegistry+import qualified Salmon.Builtin.Nodes.Gcp.Core as Core+import qualified Salmon.Builtin.Nodes.Podman as Podman+import qualified Salmon.Builtin.Nodes.Podman.Quadlet as Quadlet+import qualified Salmon.Builtin.Nodes.Systemd as Systemd+import Salmon.Op.Actions (Act (..))+import qualified Salmon.Op.Dag as Dag+import Salmon.Op.Ref (mkRef)+import Salmon.Op.Track (Track (..))+import Salmon.Reporter (silent)+import Test.Harness (withTempDir)++tests :: TestTree+tests =+    testGroup+        "Salmon.Builtin.Nodes.Podman.Quadlet"+        [ testGroup "rendering" renderTests+        , testGroup "watched files" watchedTests+        , testGroup "watched files' key" keyTests+        , testGroup "refusals" problemTests+        , testGroup "check" checkTests+        , testGroup "node" nodeTests+        , testGroup "bind volumes" bindTests+        , testGroup "keep-id" keepIdTests+        , testGroup "readiness" readinessTests+        , testGroup "generator" generatorTests+        , testGroup "instance login" loginTests+        ]++app :: Quadlet.Container+app =+    (Quadlet.container (Podman.ContainerName "app") "europe-west1-docker.pkg.dev/acme/repo/app:v3")+        { Quadlet.containerDescription = "the app"+        , Quadlet.containerAfter = ["network-online.target"]+        , Quadlet.containerEnvFile = Just "/etc/app/env"+        , Quadlet.containerPorts = [Podman.PortMapping "8080" "80" Podman.TCPPort]+        , Quadlet.containerVolumes = [Podman.VolumeMount "/srv/app" "/data" Podman.ReadOnly]+        , Quadlet.containerAuthFile = Just (Podman.AuthFile "/etc/app/auth.json")+        , Quadlet.containerStartTimeout = Just 300+        }++renderTests :: [TestTree]+renderTests =+    [ testCase "a full container, key by key" $+        assertEqual+            ""+            ( Text.unlines+                [ "[Unit]"+                , "Description=the app"+                , "After=network-online.target"+                , ""+                , "[Container]"+                , "ContainerName=app"+                , "Image=europe-west1-docker.pkg.dev/acme/repo/app:v3"+                , "EnvironmentFile=/etc/app/env"+                , "PublishPort=8080:80/tcp"+                , "Volume=/srv/app:/data:ro"+                , "PodmanArgs=--authfile=/etc/app/auth.json"+                , ""+                , "[Service]"+                , "Restart=on-failure"+                , "TimeoutStartSec=300"+                , ""+                , "[Install]"+                , "WantedBy=multi-user.target"+                ]+            )+            (Quadlet.renderContainer app)+    , testCase "the starting point renders only what it must" $+        assertEqual+            ""+            ( Text.unlines+                [ "[Unit]"+                , "Description=container web (salmon)"+                , ""+                , "[Container]"+                , "ContainerName=web"+                , "Image=docker.io/library/nginx:1.27"+                , ""+                , "[Service]"+                , "Restart=on-failure"+                , ""+                , "[Install]"+                , "WantedBy=multi-user.target"+                ]+            )+            (Quadlet.renderContainer (Quadlet.container (Podman.ContainerName "web") "docker.io/library/nginx:1.27"))+    , testCase "no WantedBy, no [Install] section" $+        assertBool "" $+            not ("[Install]" `Text.isInfixOf` Quadlet.renderContainer app{Quadlet.containerWantedBy = Nothing})+    , testCase "restart policies and udp ports are spelled as systemd and podman spell them" $ do+        let rendered =+                Quadlet.renderContainer+                    app+                        { Quadlet.containerRestart = Quadlet.RestartAlways+                        , Quadlet.containerPorts = [Podman.PortMapping "5353" "53" Podman.UDPPort]+                        }+        assertBool "" ("Restart=always\n" `Text.isInfixOf` rendered)+        assertBool "" ("PublishPort=5353:53/udp\n" `Text.isInfixOf` rendered)+    , testCase "the file and the unit are named after the container" $ do+        assertEqual "" "/etc/containers/systemd/app.container" (Quadlet.quadletPath app)+        assertEqual "" "app.service" (Quadlet.serviceTarget app)+    , testCase "a new image reference is a different file" $+        assertBool "" $+            Quadlet.renderContainer app /= Quadlet.renderContainer app{Quadlet.containerImage = "europe-west1-docker.pkg.dev/acme/repo/app:v4"}+    ]++watchedTests :: [TestTree]+watchedTests =+    [ testCase "the env file is watched, before anything else named" $+        assertEqual "" ["/etc/app/env", "/etc/app/extra.conf"] (Quadlet.watchedFiles app{Quadlet.containerWatched = ["/etc/app/extra.conf"]})+    , testCase "a changed env file changes the quadlet, an unchanged one does not" $ withTempDir $ \dir -> do+        let env = dir </> "env"+            c = app{Quadlet.containerUnitDir = dir, Quadlet.containerEnvFile = Just env}+        Quadlet.ensureWatchKey c+        writeFile env "PORT=80\n"+        before <- Quadlet.renderContainerWatching c+        again <- Quadlet.renderContainerWatching c+        writeFile env "PORT=81\n"+        after <- Quadlet.renderContainerWatching c+        assertEqual "" before again+        assertBool "the quadlet did not change with the env file" (before /= after)+        assertBool "the fingerprint replaced the declaration" (Quadlet.renderContainer c `Text.isPrefixOf` after)+    , testCase "the env file's contents are not in the quadlet" $ withTempDir $ \dir -> do+        let env = dir </> "env"+            c = app{Quadlet.containerUnitDir = dir, Quadlet.containerEnvFile = Just env}+        Quadlet.ensureWatchKey c+        writeFile env "API_KEY=hunter2hunter2\n"+        rendered <- Quadlet.renderContainerWatching c+        assertBool "" (not ("hunter2" `Text.isInfixOf` rendered))+    , testCase "nothing watched renders the declaration exactly" $ do+        let c = app{Quadlet.containerEnvFile = Nothing}+        rendered <- Quadlet.renderContainerWatching c+        assertEqual "" (Quadlet.renderContainer c) rendered+    , testCase "the written file is the declaration plus the label naming it" $ do+        let c = app{Quadlet.containerEnvFile = Nothing}+        fingerprint <- Quadlet.quadletFingerprint c+        written <- Quadlet.renderQuadlet c+        let label = "Label=" <> Quadlet.quadletLabel <> "=" <> fingerprint+        assertEqual "not exactly one label line" [label] (filter ("Label=" `Text.isPrefixOf`) (Text.lines written))+        assertEqual "the label is not the only difference" (Text.lines (Quadlet.renderContainer c)) (filter (/= label) (Text.lines written))+        assertBool "the label is not in the [Container] section" $+            "[Container]" `elem` takeWhile (/= label) (Text.lines written)+                && "[Service]" `notElem` takeWhile (/= label) (Text.lines written)+    , testCase "the fingerprint moves with the image and with the env file, and not otherwise" $ withTempDir $ \dir -> do+        let env = dir </> "env"+            c = app{Quadlet.containerUnitDir = dir, Quadlet.containerEnvFile = Just env}+        Quadlet.ensureWatchKey c+        writeFile env "PORT=80\n"+        before <- Quadlet.quadletFingerprint c+        again <- Quadlet.quadletFingerprint c+        moved <- Quadlet.quadletFingerprint c{Quadlet.containerImage = "europe-west1-docker.pkg.dev/acme/repo/app:v4"}+        writeFile env "PORT=81\n"+        after <- Quadlet.quadletFingerprint c+        assertEqual "" before again+        assertBool "a new image left the fingerprint alone" (before /= moved)+        assertBool "a new env file left the fingerprint alone" (before /= after)+    ]++{- | The key the watched files' digest is made under: what is in the+quadlet without it, and the key file itself in a scratch directory.+-}+keyTests :: [TestTree]+keyTests =+    [ testCase "the quadlet carries no plain hash of the env file, and no fingerprint made from one" $ withTempDir $ \dir -> do+        let env = dir </> "env"+            c = app{Quadlet.containerUnitDir = dir, Quadlet.containerEnvFile = Just env}+        Quadlet.ensureWatchKey c+        writeFile env "PASSWORD=hunter2\n"+        -- what anybody who can read the env file's path and guess its+        -- contents can compute: the line as it was written before the key+        plain <- Systemd.withWatchedFingerprint [env] ""+        unkeyed <- Quadlet.unkeyedFingerprint c+        written <- Quadlet.renderQuadlet c+        assertBool "the file has no watched-files line" (any ("# salmon-watches: hmac-sha256:" `Text.isPrefixOf`) (Text.lines written))+        assertBool "the plain hash is in the file" (not (Text.strip plain `Text.isInfixOf` written))+        assertBool "the plain hash's digest is in the file" (not (Text.drop (Text.length "# salmon-watches: ") (Text.strip plain) `Text.isInfixOf` written))+        assertBool "the label is the fingerprint from before the key" (not (unkeyed `Text.isInfixOf` written))+    , testCase "the same files under another key are another line, under the same key the same" $ do+        let frames = ["/etc/app/env:17:", "PASSWORD=hunter2\n"]+        assertEqual "" (Quadlet.keyedWatchLine "key one" frames) (Quadlet.keyedWatchLine "key one" frames)+        assertBool "" (Quadlet.keyedWatchLine "key one" frames /= Quadlet.keyedWatchLine "key two" frames)+        assertBool "" (Quadlet.keyedWatchLine "key one" frames /= Quadlet.keyedWatchLine "key one" ["/etc/app/env:17:", "PASSWORD=hunter3\n"])+        -- HMAC-SHA256, worked out outside this code (python's hmac): a+        -- changed construction is a restart of every watching quadlet+        assertEqual+            ""+            "# salmon-watches: hmac-sha256:1gecq1WMRz2R6OrB2pYA4e6aQiTPtJ9JB4aWUWugcz0=\n"+            (Quadlet.keyedWatchLine "key one" frames)+    , testCase "two machines' quadlets for one env file do not match" $ withTempDir $ \dir -> do+        let env = dir </> "env"+            on name = app{Quadlet.containerUnitDir = dir </> name, Quadlet.containerEnvFile = Just env}+        writeFile env "PASSWORD=hunter2\n"+        createDirectory (dir </> "one")+        createDirectory (dir </> "two")+        Quadlet.ensureWatchKey (on "one")+        Quadlet.ensureWatchKey (on "two")+        one <- Quadlet.renderContainerWatching (on "one")+        two <- Quadlet.renderContainerWatching (on "two")+        assertBool "" (one /= two)+    , testCase "without the key nothing is rendered, and there is no falling back to a plain hash" $ withTempDir $ \dir -> do+        let c = app{Quadlet.containerUnitDir = dir, Quadlet.containerEnvFile = Just (dir </> "env")}+        rendered <- try (Quadlet.renderQuadlet c)+        case rendered of+            Left (Quadlet.WatchKeyUnusable path _) -> assertEqual "" (dir </> ".salmon-watch.key") path+            Right text -> assertFailure ("rendered without a key: " <> Text.unpack text)+        writeFile (Quadlet.watchKeyPath c) "\n"+        empty <- try (Quadlet.renderQuadlet c)+        case empty of+            Left (Quadlet.WatchKeyUnusable _ _) -> pure ()+            Right text -> assertFailure ("rendered under an empty key: " <> Text.unpack text)+    , testCase "a quadlet that watches nothing reads no key" $ withTempDir $ \dir -> do+        let c = app{Quadlet.containerUnitDir = dir, Quadlet.containerEnvFile = Nothing}+        written <- Quadlet.renderQuadlet c+        assertBool "" (not ("salmon-watches" `Text.isInfixOf` written))+        assertEqual "" [] =<< listDirectory dir+    , testCase "the key is made once, owner-only, and never replaced" $ withTempDir $ \dir -> do+        let c = app{Quadlet.containerUnitDir = dir}+            path = Quadlet.watchKeyPath c+        Quadlet.ensureWatchKey c+        first <- ByteString.readFile path+        assertBool "a short key" (ByteString.length first >= 32)+        mode <- fileMode <$> getFileStatus path+        assertEqual "the key is readable by others" 0o600 (mode .&. 0o777)+        assertEqual "something was left beside the key" [".salmon-watch.key"] =<< listDirectory dir+        Quadlet.ensureWatchKey c+        assertEqual "the key was replaced" first =<< ByteString.readFile path+        setFileMode path 0o644+        Quadlet.ensureWatchKey c+        again <- fileMode <$> getFileStatus path+        assertEqual "a key opened to others was not closed" 0o600 (again .&. 0o777)+        assertEqual "closing the key replaced it" first =<< ByteString.readFile path+    , testCase "the key node: missing, open to others, empty, as it should be" $ do+        let isFailure r = case r of Failure _ -> True; _ -> False+        assertBool "" (isFailure (Quadlet.interpretWatchKey "/k" Nothing))+        assertBool "" (isFailure (Quadlet.interpretWatchKey "/k" (Just (0o100644, 45))))+        assertBool "" (isFailure (Quadlet.interpretWatchKey "/k" (Just (0o100640, 45))))+        assertBool "" (isFailure (Quadlet.interpretWatchKey "/k" (Just (0o100600, 0))))+        assertEqual "" Success (Quadlet.interpretWatchKey "/k" (Just (0o100600, 45)))+    , testCase "the key node makes the key, then skips; down leaves it" $ withTempDir $ \dir -> do+        let c = app{Quadlet.containerUnitDir = dir}+        act <- maybe (assertFailure "no node") pure (opAct (Quadlet.watchKeyNode c))+        before <- act.extension.check+        assertBool (show before) (case before of Failure _ -> True; _ -> False)+        act.extension.up+        assertEqual "" Success =<< act.extension.check+        act.extension.down+        assertBool "down removed the key" =<< doesFileExist (Quadlet.watchKeyPath c)+    ]++problemTests :: [TestTree]+problemTests =+    [ testCase "a well-formed container has no problems" $+        assertEqual "" [] (Quadlet.containerProblems app)+    , testCase "a line break in the image is refused, not written as a second key" $+        assertBool "" (not (null (Quadlet.containerProblems app{Quadlet.containerImage = "img:v1\nPodmanArgs=--privileged"})))+    , testCase "an empty image or name is refused" $ do+        assertBool "" (not (null (Quadlet.containerProblems app{Quadlet.containerImage = " "})))+        assertBool "" (not (null (Quadlet.containerProblems app{Quadlet.containerName = Podman.ContainerName ""})))+    , testCase "a name that is a path is refused" $+        assertBool "" (not (null (Quadlet.containerProblems app{Quadlet.containerName = Podman.ContainerName "../x"})))+    , testCase "a stamp on a container that stays up is refused" $+        -- its ExecStartPost would run when the container is ready, not when+        -- it finished, and there is no run to record+        assertEqual+            ""+            ["a stamp records a run that finished, and this container is not a job"]+            (Quadlet.containerProblems app{Quadlet.containerStamp = Just "/var/lib/app/stamp"})+    ]++checkTests :: [TestTree]+checkTests =+    [ testCase "a running generated unit is satisfied" $+        assertEqual "" Success (Quadlet.interpretShow ["ActiveState=active", "UnitFileState=generated", "NeedDaemonReload=no"])+    , testCase "the same lines are not satisfied for an authored unit" $+        assertBool "" (Systemd.interpretShow ["ActiveState=active", "UnitFileState=generated", "NeedDaemonReload=no"] /= Success)+    , testCase "a changed quadlet needs bringing up, running or not" $+        assertBool "" (isFailure (Quadlet.interpretShow ["ActiveState=active", "UnitFileState=generated", "NeedDaemonReload=yes"]))+    , testCase "a quadlet systemd has not generated yet needs bringing up" $+        -- what `systemctl show` prints between the file being written and the reload+        assertBool "" (isFailure (Quadlet.interpretShow ["ActiveState=inactive", "UnitFileState=", "NeedDaemonReload=no"]))+    , testCase "a stopped or failed container needs bringing up" $ do+        assertBool "" (isFailure (Quadlet.interpretShow ["ActiveState=inactive", "UnitFileState=generated", "NeedDaemonReload=no"]))+        assertBool "" (isFailure (Quadlet.interpretShow ["ActiveState=failed", "UnitFileState=generated", "NeedDaemonReload=no"]))+    , testCase "a container still starting (pulling) is Unknown, not Failure" $+        assertEqual "" Unknown (Quadlet.interpretShow ["ActiveState=activating", "UnitFileState=generated", "NeedDaemonReload=no"])+    , testCase "a container started from the declared quadlet is satisfied" $+        assertEqual "" Success (Quadlet.interpretRunning "abc123" "abc123\n")+    , testCase "a container started from another quadlet needs bringing up" $+        -- the unit reads active, generated and reloaded: something else ran+        -- the daemon-reload, and nothing restarted this service+        assertBool "" (isFailure (Quadlet.interpretRunning "abc123" "def456\n"))+    , testCase "a container started before its env file was rotated needs bringing up, whatever systemd remembers" $ withTempDir $ \dir -> do+        -- the image and the declaration are the ones it was started with;+        -- only the env file moved, and a daemon-reload run for another+        -- quadlet has already cleared NeedDaemonReload+        let env = dir </> "env"+            c = app{Quadlet.containerUnitDir = dir, Quadlet.containerEnvFile = Just env}+        Quadlet.ensureWatchKey c+        writeFile env "TOKEN=one\n"+        started <- Quadlet.quadletFingerprint c+        assertEqual "" Success (Quadlet.interpretRunning started (started <> "\n"))+        writeFile env "TOKEN=two\n"+        declared <- Quadlet.quadletFingerprint c+        assertBool "" (isFailure (Quadlet.interpretRunning declared (started <> "\n")))+        written <- Quadlet.renderQuadlet c+        assertBool "the rewritten file does not label the container with the new fingerprint" $+            ("Label=" <> Quadlet.quadletLabel <> "=" <> declared) `elem` Text.lines written+    , testCase "a container that does not say what it was started from needs bringing up" $ do+        assertBool "" (isFailure (Quadlet.interpretRunning "abc123" "\n"))+        assertBool "" (isFailure (Quadlet.interpretRunning "abc123" "<no value>\n"))+    , testCase "the verdict on another quadlet's container does not quote its label" $+        -- it may be a label from before the digest was keyed, and the text is a report+        case Quadlet.interpretRunning "abc123" "def456\n" of+            Failure why -> assertBool (Text.unpack why) (not ("def456" `Text.isInfixOf` why))+            other -> assertFailure (show other)+    , testCase "a container started before the digest was keyed, from this declaration and these files, is satisfied" $ do+        assertEqual "" Success (Quadlet.interpretStarted "keyed1" "plain1" "plain1\n")+        assertEqual "" Success (Quadlet.interpretStarted "keyed1" "plain1" "keyed1\n")+        assertBool "" (isFailure (Quadlet.interpretStarted "keyed1" "plain1" "plain0\n"))+        assertBool "" (isFailure (Quadlet.interpretStarted "keyed1" "plain1" "<no value>\n"))+    , testCase "a container started before the digest was keyed and before its env file was rotated needs bringing up" $ withTempDir $ \dir -> do+        let env = dir </> "env"+            c = app{Quadlet.containerUnitDir = dir, Quadlet.containerEnvFile = Just env}+        Quadlet.ensureWatchKey c+        writeFile env "TOKEN=one\n"+        started <- Quadlet.unkeyedFingerprint c+        declaredOne <- Quadlet.quadletFingerprint c+        assertBool "the keyed fingerprint is the plain one" (started /= declaredOne)+        assertEqual "" Success (Quadlet.interpretStarted declaredOne started (started <> "\n"))+        writeFile env "TOKEN=two\n"+        declared <- Quadlet.quadletFingerprint c+        unkeyed <- Quadlet.unkeyedFingerprint c+        assertBool "" (isFailure (Quadlet.interpretStarted declared unkeyed (started <> "\n")))+    , testCase "the fingerprint from before the key is the one such a container carries" $ do+        -- worked out outside this code from the text "a full container, key+        -- by key" pins and the trailing comment as it was written then+        -- (sha256 of "PATH:0:" for a file that is not there, base64): were it+        -- to move, every container started before the key would be restarted+        let c = app{Quadlet.containerEnvFile = Just "/nonexistent/salmon/env"}+        assertEqual "" "rOdZlNjoUBdb" =<< Quadlet.unkeyedFingerprint c+    , testCase "a file rewritten for the key alone is a reload and no restart" $ do+        let active = Just (Quadlet.parseSample ["ActiveState=active", "SubState=running", "NRestarts=0", "Result=success"])+            stopped = Just (Quadlet.parseSample ["ActiveState=inactive", "SubState=dead", "NRestarts=0", "Result=success"])+        assertBool "" (Quadlet.interpretAdoptable "keyed1" "plain1" active (Just "plain1\n"))+        assertBool "a container already on the keyed label was adopted" (not (Quadlet.interpretAdoptable "keyed1" "plain1" active (Just "keyed1\n")))+        assertBool "a container from another declaration was adopted" (not (Quadlet.interpretAdoptable "keyed1" "plain1" active (Just "plain0\n")))+        assertBool "a stopped unit was adopted" (not (Quadlet.interpretAdoptable "keyed1" "plain1" stopped (Just "plain1\n")))+        assertBool "a unit systemd cannot describe was adopted" (not (Quadlet.interpretAdoptable "keyed1" "plain1" Nothing (Just "plain1\n")))+        assertBool "a container podman cannot describe was adopted" (not (Quadlet.interpretAdoptable "keyed1" "plain1" active Nothing))+        assertBool "a quadlet watching nothing was adopted" (not (Quadlet.interpretAdoptable "same" "same" active (Just "same\n")))+    ]+  where+    isFailure (Failure _) = True+    isFailure _ = False++nodeTests :: [TestTree]+nodeTests =+    [ testCase "the node is keyed on the unit, not on the image" $ do+        let a = refOf (node app)+            b = refOf (node app{Quadlet.containerImage = "other:v1"})+        assertEqual "moving the image declared a second node" a b+        assertBool "two containers are one node" (a /= refOf (node app{Quadlet.containerName = Podman.ContainerName "other"}))+    , testCase "a new image is visible in the node's description" $+        -- so `run serve` sees the re-declaration as a change+        assertBool "" (notesOf (node app) /= notesOf (node app{Quadlet.containerImage = "europe-west1-docker.pkg.dev/acme/repo/app:v4"}))+    , testCase "the same declaration describes itself the same way" $+        assertEqual "" (notesOf (node app)) (notesOf (node app))+    , testCase "a declaration that sets no stamp is described, keyed and rendered as it was before there was one" $ do+        -- the hash is that of the text "a full container, key by key" pins,+        -- worked out outside this code (sha256, base64url, 12 characters): a+        -- long-running container whose file or description moved would be+        -- restarted by the pass after an upgrade+        assertEqual "" Nothing app.containerStamp+        assertEqual+            ""+            (Just ["image: europe-west1-docker.pkg.dev/acme/repo/app:v3", "quadlet: aTe0H65oUH_B"])+            (notesOf (node app))+        assertEqual "" (Just "runs europe-west1-docker.pkg.dev/acme/repo/app:v3 as app.service") (helpOf (node app))+        assertEqual "" (Just (mkRef "systemd-unit" ("app.service" :: Text))) (refOf (node app))+        assertBool "" (not ("ExecStart" `Text.isInfixOf` Quadlet.renderContainer app))+    , testCase "two quadlets in one directory share it without a conflict" $ do+        let other = app{Quadlet.containerName = Podman.ContainerName "other"}+            dag = Dag.foldDag Dag.sameRepresentative (evalDeps (op "both" (deps [node app, node other]) id))+        assertEqual "the shared directory was described two ways" 0 (length (Dag.dagConflicts dag))+    , testCase "the shared directory carries nothing of one container's" $ do+        let dag = Dag.foldDag Dag.sameRepresentative (evalDeps (node app))+            dirs = [act | act <- Map.elems (Dag.dagNodes dag), act.shorthand == "podman-quadlet-dir"]+        assertEqual "not exactly one directory node" 1 (length dirs)+        assertBool "the directory carries the container's notes" $+            not (any (Text.isPrefixOf "quadlet:") (concatMap (\act -> act.extension.notes) dirs))+    , testCase "a quadlet that watches a file stands on the directory's key, and one that watches nothing has none" $ do+        let dagOf o = Dag.foldDag Dag.sameRepresentative (evalDeps o)+            refsOf dag short = [r | (r, act) <- Map.toList (Dag.dagNodes dag), act.shorthand == short]+            watching = dagOf (node app)+        key <- one "key node" (refsOf watching "podman-quadlet-key")+        file <- one "file node" (refsOf watching "file-contents")+        dir <- one "directory node" (refsOf watching "podman-quadlet-dir")+        assertEqual "" (mkRef "podman-quadlet-key" ("/etc/containers/systemd/.salmon-watch.key" :: FilePath)) key+        assertBool "the file does not wait for the key" (key `elem` toList (Map.findWithDefault mempty file (Dag.dagDependencies watching)))+        assertBool "the key does not wait for the directory" (dir `elem` toList (Map.findWithDefault mempty key (Dag.dagDependencies watching)))+        assertEqual "" [] (refsOf (dagOf (node app{Quadlet.containerEnvFile = Nothing})) "podman-quadlet-key")+        let job = Quadlet.containerJob (Podman.ContainerName "job") "app:v3" ["run"]+            jobNode = Quadlet.quadletJob silent ignoreTrack ignoreTrack+        assertEqual "" [] (refsOf (dagOf (jobNode job)) "podman-quadlet-key")+        assertEqual "" 1 (length (refsOf (dagOf (jobNode job{Quadlet.containerWatched = ["/etc/job.conf"]})) "podman-quadlet-key"))+    , testCase "two quadlets in one directory share its key without a conflict" $ do+        let other = app{Quadlet.containerName = Podman.ContainerName "other"}+            dag = Dag.foldDag Dag.sameRepresentative (evalDeps (op "both" (deps [node app, node other]) id))+        assertEqual "" 1 (length [() | act <- Map.elems (Dag.dagNodes dag), act.shorthand == "podman-quadlet-key"])+        assertEqual "the shared key was described two ways" 0 (length (Dag.dagConflicts dag))+    , testCase "the image is asked for, and pulled with the container's credentials" $ do+        assertEqual "" ["image", "exists", "europe-west1-docker.pkg.dev/acme/repo/app:v3"] (Quadlet.imagePresentArgs app)+        assertEqual+            ""+            ["pull", "--quiet", "--authfile=/etc/app/auth.json", "europe-west1-docker.pkg.dev/acme/repo/app:v3"]+            (Quadlet.imagePullArgs app)+        assertEqual+            "a public image is pulled with no auth file"+            ["pull", "--quiet", "docker.io/library/nginx:alpine"]+            (Quadlet.imagePullArgs (Quadlet.container (Podman.ContainerName "web") "docker.io/library/nginx:alpine"))+    , testCase "an image on the machine is a skip, a missing one is pulled, a podman that cannot say is unknown" $ do+        assertEqual "" Success (Quadlet.interpretImagePresent "app:v3" ExitSuccess)+        assertEqual "" (Failure "the image app:v3 is not on the machine") (Quadlet.interpretImagePresent "app:v3" (ExitFailure 1))+        assertEqual "" Unknown (Quadlet.interpretImagePresent "app:v3" (ExitFailure 125))+    , testCase "the quadlet file stands on the image, so a failed pull blocks the rewrite and the restart" $ do+        let dag = Dag.foldDag Dag.sameRepresentative (evalDeps (node app))+            refsOf short = [r | (r, act) <- Map.toList (Dag.dagNodes dag), act.shorthand == short]+        image <- one "image node" (refsOf "podman-quadlet-image")+        unit <- one "quadlet node" (refsOf "podman-quadlet")+        let dependants = Map.findWithDefault mempty image (Dag.dagDependants dag)+            files = [r | r <- toList dependants, r /= unit]+        file <- one "dependant of the image other than the unit" files+        assertBool "the unit does not stand on the file that stands on the image" $+            file `elem` toList (Map.findWithDefault mempty unit (Dag.dagDependencies dag))+    , testCase "what the caller's track declares comes before the pull" $ do+        let login = op "the-login" nodeps (\ext -> ext{ref = mkRef "test-login" ("x" :: Text)})+            tracked = Quadlet.quadletContainer silent ignoreTrack (Track (const login)) app+            dag = Dag.foldDag Dag.sameRepresentative (evalDeps tracked)+            refsOf short = [r | (r, act) <- Map.toList (Dag.dagNodes dag), act.shorthand == short]+        image <- one "image node" (refsOf "podman-quadlet-image")+        loginRef <- one "login node" (refsOf "the-login")+        assertBool "the pull does not wait for the login" $+            loginRef `elem` toList (Map.findWithDefault mempty image (Dag.dagDependencies dag))+    , testCase "what the caller's track declares comes before the file, so a failed one leaves the old quadlet" $ do+        let migration = op "the-migration" nodeps (\ext -> ext{ref = mkRef "test-migration" ("x" :: Text)})+            tracked = Quadlet.quadletContainer silent ignoreTrack (Track (const migration)) app+            dag = Dag.foldDag Dag.sameRepresentative (evalDeps tracked)+            refsOf short = [r | (r, act) <- Map.toList (Dag.dagNodes dag), act.shorthand == short]+            dependenciesOf r = toList (Map.findWithDefault mempty r (Dag.dagDependencies dag))+        migrationRef <- one "migration node" (refsOf "the-migration")+        file <- one "file node" (refsOf "file-contents")+        unit <- one "quadlet node" (refsOf "podman-quadlet")+        assertBool "the file does not wait for the track" (migrationRef `elem` dependenciesOf file)+        assertBool "the unit stopped standing on the track" (migrationRef `elem` dependenciesOf unit)+        assertEqual "the track made a cycle" [] (toList (Dag.stuck (\d r -> toList (Map.findWithDefault mempty r (Dag.dagDependencies d))) dag))+    , testCase "a job's file waits for the caller's track too" $ do+        let migration = op "the-migration" nodeps (\ext -> ext{ref = mkRef "test-migration" ("x" :: Text)})+            job = Quadlet.quadletJob silent ignoreTrack (Track (const migration)) (Quadlet.containerJob (Podman.ContainerName "job") "app:v3" ["run"])+            dag = Dag.foldDag Dag.sameRepresentative (evalDeps job)+            refsOf short = [r | (r, act) <- Map.toList (Dag.dagNodes dag), act.shorthand == short]+        migrationRef <- one "migration node" (refsOf "the-migration")+        file <- one "file node" (refsOf "file-contents")+        assertBool "the file does not wait for the track" $+            migrationRef `elem` toList (Map.findWithDefault mempty file (Dag.dagDependencies dag))+    , testCase "the track changes nothing about how the file and the unit are described" $ do+        let migration = op "the-migration" nodeps (\ext -> ext{ref = mkRef "test-migration" ("x" :: Text)})+            described o =+                let dag = Dag.foldDag Dag.sameRepresentative (evalDeps o)+                 in sort+                        [ (show r, act.extension.help, act.extension.notes)+                        | (r, act) <- Map.toList (Dag.dagNodes dag)+                        , act.shorthand /= "the-migration"+                        ]+        assertEqual "" (described (node app)) (described (Quadlet.quadletContainer silent ignoreTrack (Track (const migration)) app))+    , testCase "two containers of one image share the pull without a conflict" $ do+        let other = app{Quadlet.containerName = Podman.ContainerName "other", Quadlet.containerDescription = "another"}+            dag = Dag.foldDag Dag.sameRepresentative (evalDeps (op "both" (deps [node app, node other]) id))+        assertEqual "" 1 (length [() | act <- Map.elems (Dag.dagNodes dag), act.shorthand == "podman-quadlet-image"])+        assertEqual "the shared image was described two ways" 0 (length (Dag.dagConflicts dag))+    , testCase "a job's file does not stand on a pull" $ do+        let job = Quadlet.quadletJob silent ignoreTrack ignoreTrack (Quadlet.containerJob (Podman.ContainerName "job") "app:v3" ["run"])+            dag = Dag.foldDag Dag.sameRepresentative (evalDeps job)+        assertEqual "" 0 (length [() | act <- Map.elems (Dag.dagNodes dag), act.shorthand == "podman-quadlet-image"])+    ]+  where+    one :: String -> [a] -> IO a+    one _ [x] = pure x+    one what xs = assertFailure ("not exactly one " <> what <> ": " <> show (length xs))+    node = Quadlet.quadletContainer silent ignoreTrack ignoreTrack+    refOf o = fmap (\act -> act.extension.ref) (opAct o)+    notesOf o = fmap (\act -> act.extension.notes) (opAct o)+    helpOf o = fmap (\act -> act.extension.help) (opAct o)++{- | 'Quadlet.containerBinds': what is rendered, what is refused, where the+directory's node sits, and the node itself against a scratch directory (no+podman, no systemd).+-}+bindTests :: [TestTree]+bindTests =+    [ testCase "a declaration with no binds is rendered, keyed and described as it was, with no node added" $ do+        -- "a full container, key by key" pins the text and "a declaration+        -- that sets no stamp" its hash; this is the rest of the graph+        assertEqual "" [] app.containerBinds+        assertEqual "" [] (map shorthandOf (Quadlet.hostDirNodes app))+        -- the key is there for the env file `app` watches, not for a bind+        assertEqual+            ""+            ["file-contents", "podman-quadlet", "podman-quadlet-dir", "podman-quadlet-image", "podman-quadlet-key"]+            (sort (shorthands (node app)))+        assertEqual+            ""+            ["file-contents", "podman-quadlet", "podman-quadlet-dir", "podman-quadlet-image"]+            (sort (shorthands (node app{Quadlet.containerEnvFile = Nothing})))+        assertEqual "" 1 (length (filter ("Volume=" `Text.isPrefixOf`) (Text.lines (Quadlet.renderContainer app))))+    , testCase "a bind is one more Volume line, after the plain ones, with its options" $ do+        let c = app{Quadlet.containerBinds = [(Quadlet.bind "/srv/pg" "/var/lib/postgresql/data"){Quadlet.bindChown = True, Quadlet.bindRelabel = Just Quadlet.RelabelPrivate}]}+        assertEqual+            ""+            ["Volume=/srv/app:/data:ro", "Volume=/srv/pg:/var/lib/postgresql/data:rw,U,Z"]+            (filter ("Volume=" `Text.isPrefixOf`) (Text.lines (Quadlet.renderContainer c)))+        assertEqual+            "the bind is not the only difference"+            (Text.lines (Quadlet.renderContainer app))+            (filter (/= "Volume=/srv/pg:/var/lib/postgresql/data:rw,U,Z") (Text.lines (Quadlet.renderContainer c)))+    , testCase "the options are spelled as podman spells them" $ do+        let b = Quadlet.bind "/srv/pg" "/data"+        assertEqual "" "/srv/pg:/data:rw" (Quadlet.renderBind b)+        assertEqual "" "/srv/pg:/data:ro" (Quadlet.renderBind b{Quadlet.bindMode = Podman.ReadOnly})+        assertEqual "" "/srv/pg:/data:rw,U" (Quadlet.renderBind b{Quadlet.bindChown = True})+        assertEqual "" "/srv/pg:/data:rw,z" (Quadlet.renderBind b{Quadlet.bindRelabel = Just Quadlet.RelabelShared})+        assertEqual "" "/srv/pg:/data:ro,Z" (Quadlet.renderBind b{Quadlet.bindMode = Podman.ReadOnly, Quadlet.bindRelabel = Just Quadlet.RelabelPrivate})+    , testCase "the directory's owner and mode are not in the quadlet, so stating them restarts nothing" $ do+        let with d = app{Quadlet.containerBinds = [(Quadlet.bind "/srv/pg" "/data"){Quadlet.bindCreate = d}]}+            stated = Just Quadlet.HostDir{Quadlet.hostDirOwner = Just "70:70", Quadlet.hostDirMode = Just "0700"}+        assertEqual "" (Quadlet.renderContainer (with Nothing)) (Quadlet.renderContainer (with stated))+        assertEqual "" (notesOf (node (with Nothing))) (notesOf (node (with stated)))+    , testCase "a well-formed bind has no problems" $ do+        assertEqual "" [] (Quadlet.bindProblems (Quadlet.bind "/srv/pg" "/data"))+        assertEqual+            ""+            []+            ( Quadlet.containerProblems+                app{Quadlet.containerBinds = [(Quadlet.bind "/srv/pg" "/data"){Quadlet.bindCreate = Just (Quadlet.HostDir (Just "postgres:postgres") (Just "750"))}]}+            )+    , testCase "a relative path, a colon, an owner beside :U, a mode that is not octal are refused" $ do+        let b = Quadlet.bind "/srv/pg" "/data"+            owned o = b{Quadlet.bindCreate = Just Quadlet.hostDir{Quadlet.hostDirOwner = Just o}}+            moded m = b{Quadlet.bindCreate = Just Quadlet.hostDir{Quadlet.hostDirMode = Just m}}+            refused what x = assertBool what (not (null (Quadlet.bindProblems x)))+        refused "a volume name" b{Quadlet.bindHostPath = "pgdata"}+        refused "a relative guest path" b{Quadlet.bindGuestPath = "data"}+        refused "a colon in the host path" b{Quadlet.bindHostPath = "/srv/pg:ro"}+        refused "an owner and :U" (owned "70:70"){Quadlet.bindChown = True}+        refused "an empty owner" (owned "")+        refused "an owner that is an option" (owned "-R")+        refused "an owner of two words" (owned "postgres postgres")+        refused "a symbolic mode" (moded "u+rwx")+        refused "a mode of two digits" (moded "75")+        refused "a mode with a 9" (moded "0790")+        assertBool "a line break in a bind was not refused" $+            not (null (Quadlet.containerProblems app{Quadlet.containerBinds = [b{Quadlet.bindGuestPath = "/data\nImage=evil"}]}))+        assertBool "one directory created two ways was not refused" $+            not (null (Quadlet.containerProblems app{Quadlet.containerBinds = [owned "70:70", (moded "0700"){Quadlet.bindGuestPath = "/other"}]}))+    , testCase "the quadlet file stands on the directory, for a service and for a job" $ do+        let binds = [Quadlet.bind "/srv/pg" "/data"]+            job = (Quadlet.containerJob (Podman.ContainerName "job") "app:v3" ["run"]){Quadlet.containerBinds = binds}+            standing o = do+                let dag = Dag.foldDag Dag.sameRepresentative (evalDeps o)+                    refsOf short = [r | (r, act) <- Map.toList (Dag.dagNodes dag), act.shorthand == short]+                dirRef <- one "bind directory node" (refsOf "podman-quadlet-bind-dir")+                file <- one "file node" (refsOf "file-contents")+                assertBool "the file does not wait for the directory" $+                    dirRef `elem` toList (Map.findWithDefault mempty file (Dag.dagDependencies dag))+                assertEqual "a conflict" 0 (length (Dag.dagConflicts dag))+        standing (node app{Quadlet.containerBinds = binds})+        standing (Quadlet.quadletJob silent ignoreTrack ignoreTrack job)+    , testCase "only a bind that says so gets a directory node, and one path gets one" $ do+        let b = Quadlet.bind "/srv/pg" "/data"+            c = app{Quadlet.containerBinds = [b, b{Quadlet.bindGuestPath = "/again"}, (Quadlet.bind "/srv/theirs" "/x"){Quadlet.bindCreate = Nothing}]}+        assertEqual "" 1 (length (Quadlet.hostDirNodes c))+        assertEqual "" [Just (mkRef "podman-quadlet-bind-dir" ("/srv/pg" :: FilePath))] (map refOf (Quadlet.hostDirNodes c))+    , testCase "two containers binding one directory the same way share its node" $ do+        let binds = [Quadlet.bind "/srv/shared" "/data"]+            a = app{Quadlet.containerBinds = binds}+            b = a{Quadlet.containerName = Podman.ContainerName "other"}+            dag = Dag.foldDag Dag.sameRepresentative (evalDeps (op "both" (deps [node a, node b]) id))+        assertEqual "" 1 (length [() | act <- Map.elems (Dag.dagNodes dag), act.shorthand == "podman-quadlet-bind-dir"])+        assertEqual "the shared directory was described two ways" 0 (length (Dag.dagConflicts dag))+    , testCase "the directory's verdict: there, something else there, missing" $ do+        assertEqual "" Success (Quadlet.interpretHostDir "/srv/pg" True True)+        assertBool "" (isFailure (Quadlet.interpretHostDir "/srv/pg" False True))+        assertBool "" (isFailure (Quadlet.interpretHostDir "/srv/pg" False False))+        assertEqual "" ["--", "70:70", "/srv/pg"] (Quadlet.chownArgs "70:70" "/srv/pg")+    , testCase "a missing directory is created with its parents and its mode, then skipped" $ withTempDir $ \tmp -> do+        let path = tmp </> "data" </> "pg"+        act <- dirAct path Quadlet.hostDir{Quadlet.hostDirMode = Just "0750"}+        act.extension.check >>= assertBool "a missing directory is satisfied" . isFailure+        act.extension.up+        modeOf path >>= assertEqual "" 0o750+        act.extension.check >>= assertEqual "" Success+        act.extension.up+        modeOf path >>= assertEqual "a second up changed the mode" 0o750+    , testCase "an existing directory is left with the mode it has" $ withTempDir $ \tmp -> do+        let path = tmp </> "pg"+        createDirectory path+        setFileMode path 0o700+        act <- dirAct path Quadlet.hostDir{Quadlet.hostDirMode = Just "0755"}+        act.extension.check >>= assertEqual "" Success+        act.extension.up+        modeOf path >>= assertEqual "the image's own mode was overwritten" 0o700+    , testCase "an owner one may name is applied at creation" $ withTempDir $ \tmp -> do+        let path = tmp </> "pg"+        uid <- getEffectiveUserID+        act <- dirAct path Quadlet.hostDir{Quadlet.hostDirOwner = Just (Text.pack (show uid))}+        act.extension.up+        doesDirectoryExist path >>= assertBool "no directory"+    , testCase "an owner that cannot be given fails up and leaves no directory behind" $ withTempDir $ \tmp -> do+        let path = tmp </> "pg"+        act <- dirAct path Quadlet.hostDir{Quadlet.hostDirOwner = Just "no-such-user-salmon-test"}+        outcome <- try act.extension.up :: IO (Either SomeException ())+        assertBool "up did not throw" (either (const True) (const False) outcome)+        doesPathExist path >>= assertBool "the half-made directory would read as satisfied next pass" . not+        act.extension.check >>= assertBool "" . isFailure+    , testCase "a file where the directory should be is a failure, and up throws" $ withTempDir $ \tmp -> do+        let path = tmp </> "pg"+        writeFile path "not a directory"+        act <- dirAct path Quadlet.hostDir+        act.extension.check >>= assertBool "" . isFailure+        outcome <- try act.extension.up :: IO (Either SomeException ())+        assertBool "up did not throw" (either (const True) (const False) outcome)+        readFile path >>= assertEqual "the file was touched" "not a directory"+    , testCase "an invalid declaration creates nothing" $ withTempDir $ \tmp -> do+        let path = tmp </> "pg"+        act <- dirAct path Quadlet.hostDir{Quadlet.hostDirMode = Just "rwx"}+        outcome <- try act.extension.up :: IO (Either SomeException ())+        assertBool "up did not throw" (either (const True) (const False) outcome)+        doesPathExist path >>= assertBool "a directory was made for a refused declaration" . not+    , testCase "down leaves the directory and what is in it" $ withTempDir $ \tmp -> do+        let path = tmp </> "pg"+        act <- dirAct path Quadlet.hostDir+        act.extension.up+        writeFile (path </> "PG_VERSION") "16\n"+        act.extension.down+        readFile (path </> "PG_VERSION") >>= assertEqual "the data went with the container" "16\n"+        act.extension.check >>= assertEqual "" Success+    ]+  where+    one :: String -> [a] -> IO a+    one _ [x] = pure x+    one what xs = assertFailure ("not exactly one " <> what <> ": " <> show (length xs))+    node = Quadlet.quadletContainer silent ignoreTrack ignoreTrack+    refOf o = fmap (\act -> act.extension.ref) (opAct o)+    notesOf o = fmap (\act -> act.extension.notes) (opAct o)+    shorthandOf o = fmap (\act -> act.shorthand) (opAct o)+    shorthands o = [act.shorthand | act <- Map.elems (Dag.dagNodes (Dag.foldDag Dag.sameRepresentative (evalDeps o)))]+    isFailure (Failure _) = True+    isFailure _ = False+    modeOf path = (.&. 0o7777) . fileMode <$> getFileStatus path+    dirAct path d = do+        let b = (Quadlet.bind path "/data"){Quadlet.bindCreate = Just d}+        case Quadlet.hostDirNodes app{Quadlet.containerBinds = [b]} of+            [o] -> maybe (assertFailure "the directory node has no actions") pure (opAct o)+            os -> assertFailure ("not exactly one directory node: " <> show (length os))++{- | 'Quadlet.containerKeepId': what is rendered, what is refused, and what+a declaration that does not set it is.+-}+keepIdTests :: [TestTree]+keepIdTests =+    [ testCase "a declaration that does not set it has no UserNS line" $ do+        -- "a full container, key by key" pins the text and "a declaration+        -- that sets no stamp" its hash, help, notes and ref+        assertEqual "" Nothing app.containerKeepId+        assertBool "" (not ("UserNS" `Text.isInfixOf` Quadlet.renderContainer app))+        assertBool "" (not ("userns" `Text.isInfixOf` Text.toLower (Quadlet.renderContainer rootless)))+    , testCase "the mapping is spelled as podman spells it" $ do+        assertEqual "" "keep-id" (Quadlet.renderKeepId Quadlet.keepId)+        assertEqual "" "keep-id:uid=70,gid=70" (Quadlet.renderKeepId (Quadlet.keepIdAs 70 70))+        assertEqual "" "keep-id:uid=70" (Quadlet.renderKeepId Quadlet.keepId{Quadlet.keepIdUid = Just 70})+        assertEqual "" "keep-id:gid=70" (Quadlet.renderKeepId Quadlet.keepId{Quadlet.keepIdGid = Just 70})+    , testCase "it is one more line of the [Container] section, and the only difference" $ do+        let c = rootless{Quadlet.containerKeepId = Just (Quadlet.keepIdAs 70 70)}+            line = "UserNS=keep-id:uid=70,gid=70"+            rendered = Text.lines (Quadlet.renderContainer c)+        assertEqual "" (Text.lines (Quadlet.renderContainer rootless)) (filter (/= line) rendered)+        assertEqual "" 1 (length (filter (== line) rendered))+        assertBool "the line is not in the [Container] section" $+            "[Container]" `elem` takeWhile (/= line) rendered && "[Service]" `notElem` takeWhile (/= line) rendered+    , testCase "setting it is a changed quadlet on the same node" $ do+        let c = rootless{Quadlet.containerKeepId = Just Quadlet.keepId}+        assertEqual "the mapping declared a second node" (refOf (node rootless)) (refOf (node c))+        assertEqual "" (helpOf (node rootless)) (helpOf (node c))+        -- so `run serve` sees the re-declaration, and the pass restarts once+        assertBool "" (notesOf (node rootless) /= notesOf (node c))+        assertBool "" (notesOf (node c) /= notesOf (node c{Quadlet.containerKeepId = Just (Quadlet.keepIdAs 70 70)}))+        before <- Quadlet.quadletFingerprint rootless{Quadlet.containerEnvFile = Nothing}+        after <- Quadlet.quadletFingerprint c{Quadlet.containerEnvFile = Nothing}+        assertBool "the running container's label would still read as current" (before /= after)+    , testCase "a user-scope container may keep its id, with or without :U on a bind" $ do+        assertEqual "" [] (Quadlet.containerProblems rootless{Quadlet.containerKeepId = Just Quadlet.keepId})+        assertEqual+            ""+            []+            ( Quadlet.containerProblems+                rootless+                    { Quadlet.containerKeepId = Just (Quadlet.keepIdAs 70 70)+                    , Quadlet.containerBinds = [(Quadlet.bind "/home/op/pg" "/var/lib/postgresql/data"){Quadlet.bindChown = True}]+                    }+            )+    , testCase "a system-scope container is refused it, whatever else it says" $ do+        let refused c = case Quadlet.containerProblems c of+                [why] -> assertBool (Text.unpack why) ("keep-id" `Text.isInfixOf` why)+                other -> assertFailure ("not exactly one problem: " <> show other)+        refused app{Quadlet.containerKeepId = Just Quadlet.keepId}+        refused app{Quadlet.containerKeepId = Just (Quadlet.keepIdAs 70 70)}+        refused (Quadlet.containerJob (Podman.ContainerName "job") "app:v3" ["run"]){Quadlet.containerKeepId = Just Quadlet.keepId}+    , testCase "an id that is not one is refused" $ do+        let problems k = Quadlet.keepIdProblems Systemd.User k+        assertEqual "" [] (problems (Quadlet.keepIdAs 0 0))+        assertEqual "" [] (problems (Quadlet.keepIdAs 65535 65535))+        assertEqual "" 1 (length (problems (Quadlet.keepIdAs (-1) 70)))+        assertEqual "" 1 (length (problems (Quadlet.keepIdAs 70 65536)))+        assertEqual "" 2 (length (problems (Quadlet.keepIdAs (-1) (-1))))+    , testCase "a refused mapping writes no file and makes no directory" $ withTempDir $ \tmp -> do+        let c =+                app+                    { Quadlet.containerUnitDir = tmp </> "units"+                    , Quadlet.containerEnvFile = Nothing+                    , Quadlet.containerKeepId = Just Quadlet.keepId+                    , Quadlet.containerBinds = [Quadlet.bind (tmp </> "data") "/data"]+                    }+        [dirNode] <- pure (Quadlet.hostDirNodes c)+        act <- maybe (assertFailure "no directory node") pure (opAct dirNode)+        outcome <- try act.extension.up :: IO (Either SomeException ())+        assertBool "up did not throw" (either (const True) (const False) outcome)+        doesPathExist (tmp </> "data") >>= assertBool "a directory was made for a refused declaration" . not+    ]+  where+    -- `app`, as a user would declare it for their own manager+    rootless = app{Quadlet.containerScope = Systemd.User, Quadlet.containerUnitDir = "/home/op/.config/containers/systemd"}+    node = Quadlet.quadletContainer silent ignoreTrack ignoreTrack+    refOf o = fmap (\act -> act.extension.ref) (opAct o)+    notesOf o = fmap (\act -> act.extension.notes) (opAct o)+    helpOf o = fmap (\act -> act.extension.help) (opAct o)++generatorPath :: FilePath+generatorPath = "/usr/libexec/podman/quadlet"++generatorTests :: [TestTree]+generatorTests =+    [ testCase "podman's own generator accepts the rendered file" $ withGenerator $ withTempDir $ \dir -> do+        let envFile = dir </> "env"+            c = app{Quadlet.containerUnitDir = dir, Quadlet.containerEnvFile = Just envFile}+        writeFile envFile "PORT=80\n"+        -- the key sits in the directory the generator reads, which must not mind it+        Quadlet.ensureWatchKey c+        Text.writeFile (Quadlet.quadletPath c) =<< Quadlet.renderQuadlet c+        fingerprint <- Quadlet.quadletFingerprint c+        environment <- getEnvironment+        (code, out, err) <-+            readCreateProcessWithExitCode+                (proc generatorPath ["--user", "--dryrun"]){env = Just (("QUADLET_UNIT_DIRS", dir) : environment)}+                ""+        let said = out <> err+        case code of+            ExitSuccess -> pure ()+            ExitFailure n -> assertFailure ("the generator exited " <> show n <> ": " <> said)+        assertBool said ("---app.service---" `isInfixOf` said)+        assertBool said (not ("unsupported key" `isInfixOf` said))+        assertBool ("the generator said something about the key: " <> said) (not (".salmon-watch.key" `isInfixOf` said))+        assertBool said ("--authfile=/etc/app/auth.json" `isInfixOf` said)+        assertBool said (("--env-file " <> envFile) `isInfixOf` said)+        assertBool said ("--publish 8080:80/tcp" `isInfixOf` said)+        assertBool said (("--label " <> Text.unpack (Quadlet.quadletLabel <> "=" <> fingerprint)) `isInfixOf` said)+        assertBool said ("europe-west1-docker.pkg.dev/acme/repo/app:v3" `isInfixOf` said)+        assertBool said (("SourcePath=" <> Quadlet.quadletPath c) `isInfixOf` said)+    , testCase "podman's own generator passes a bind's options through" $ withGenerator $ withTempDir $ \dir -> do+        let hostPath = dir </> "pg"+            b = (Quadlet.bind hostPath "/var/lib/postgresql/data"){Quadlet.bindChown = True, Quadlet.bindRelabel = Just Quadlet.RelabelPrivate}+            c = app{Quadlet.containerUnitDir = dir, Quadlet.containerEnvFile = Nothing, Quadlet.containerBinds = [b]}+        Text.writeFile (Quadlet.quadletPath c) =<< Quadlet.renderQuadlet c+        environment <- getEnvironment+        (code, out, err) <-+            readCreateProcessWithExitCode+                (proc generatorPath ["--user", "--dryrun"]){env = Just (("QUADLET_UNIT_DIRS", dir) : environment)}+                ""+        let said = out <> err+        case code of+            ExitSuccess -> pure ()+            ExitFailure n -> assertFailure ("the generator exited " <> show n <> ": " <> said)+        assertBool said ("---app.service---" `isInfixOf` said)+        assertBool said (("-v " <> hostPath <> ":/var/lib/postgresql/data:rw,U,Z") `isInfixOf` said)+        assertBool said ("-v /srv/app:/data:ro" `isInfixOf` said)+        doesPathExist hostPath >>= assertBool "the generator made the directory" . not+    , testCase "podman's own generator takes the keep-id mapping as a user namespace" $ withGenerator $ withTempDir $ \dir -> do+        let hostPath = dir </> "pg"+            c =+                app+                    { Quadlet.containerScope = Systemd.User+                    , Quadlet.containerUnitDir = dir+                    , Quadlet.containerEnvFile = Nothing+                    , Quadlet.containerKeepId = Just (Quadlet.keepIdAs 70 70)+                    , Quadlet.containerBinds = [(Quadlet.bind hostPath "/var/lib/postgresql/data"){Quadlet.bindChown = True}]+                    }+        assertEqual "" [] (Quadlet.containerProblems c)+        Text.writeFile (Quadlet.quadletPath c) =<< Quadlet.renderQuadlet c+        environment <- getEnvironment+        (code, out, err) <-+            readCreateProcessWithExitCode+                (proc generatorPath ["--user", "--dryrun"]){env = Just (("QUADLET_UNIT_DIRS", dir) : environment)}+                ""+        let said = out <> err+        case code of+            ExitSuccess -> pure ()+            ExitFailure n -> assertFailure ("the generator exited " <> show n <> ": " <> said)+        assertBool said ("---app.service---" `isInfixOf` said)+        assertBool said (not ("unsupported key" `isInfixOf` said))+        assertBool said ("--userns keep-id:uid=70,gid=70" `isInfixOf` said)+        assertBool said (("-v " <> hostPath <> ":/var/lib/postgresql/data:rw,U") `isInfixOf` said)+    ]+  where+    withGenerator :: IO () -> IO ()+    withGenerator act = do+        present <- doesFileExist generatorPath+        if present+            then act+            else hPutStrLn stderr ("SKIPPED: no quadlet generator at " <> generatorPath <> "; the rendered file was not checked against podman")++{- | 'Quadlet.awaitReady' against a scripted unit and a clock that only+moves when the wait sleeps: the unit's samples are consumed one per look (the+last one repeating), and the probe says yes once the clock has reached a+given time.+-}+scripted :: [Maybe Quadlet.UnitSample] -> Maybe Int -> IO (Quadlet.Waiting, IORef Int, IORef Int)+scripted samples readyAt = do+    clock <- newIORef 0+    looks <- newIORef 0+    remaining <- newIORef samples+    let next = atomicModifyIORef' remaining $ \xs -> case xs of+            [x] -> ([x], x)+            x : rest -> (rest, x)+            [] -> ([], Nothing)+    pure+        ( Quadlet.Waiting+            { Quadlet.waitSample = modifyIORef looks (+ 1) >> next+            , Quadlet.waitProbe = \_ -> do+                now <- readIORef clock+                pure (maybe False (<= now) readyAt)+            , Quadlet.waitSleep = \ms -> modifyIORef clock (+ ms)+            , Quadlet.waitNow = readIORef clock+            }+        , clock+        , looks+        )++standing :: Int -> Maybe Quadlet.UnitSample+standing n = Just (Quadlet.UnitSample "active" "running" (Just n) "success")++restarting :: Int -> Maybe Quadlet.UnitSample+restarting n = Just (Quadlet.UnitSample "activating" "auto-restart" (Just n) "exit-code")++readinessTests :: [TestTree]+readinessTests =+    [ testCase "a declaration that asks for no readiness is the node it was" $ do+        assertEqual "" Nothing app.containerReady+        assertEqual "" Nothing (Quadlet.container (Podman.ContainerName "x") "img:v1").containerReady+    , testCase "declaring readiness changes neither the file nor its fingerprint, only the node's notes" $ do+        let ready = app{Quadlet.containerReady = Just (Quadlet.stillUp 5)}+        assertEqual "" (Quadlet.renderContainer app) (Quadlet.renderContainer ready)+        plain <- Quadlet.quadletFingerprint app{Quadlet.containerEnvFile = Nothing}+        withIt <- Quadlet.quadletFingerprint ready{Quadlet.containerEnvFile = Nothing}+        assertEqual "the running container would read as started from another quadlet" plain withIt+        assertEqual "" (refOf (node app)) (refOf (node ready))+        assertEqual "" (helpOf (node app)) (helpOf (node ready))+        assertEqual+            ""+            (fmap (<> ["ready: active with no restart for 5s"]) (notesOf (node app)))+            (notesOf (node ready))+    , testCase "readiness is described with its probe" $ do+        assertEqual+            ""+            "tcp 127.0.0.1:8080 accepts within 30s, then active with no restart for 3s"+            (Quadlet.describeReadiness (Quadlet.readyWhen (Quadlet.ProbeTcp "127.0.0.1" 8080) 30))+        assertEqual+            ""+            "`curl -fsS http://127.0.0.1:8080/health` exits 0 within 10s, then active with no restart for 3s"+            (Quadlet.describeReadiness (Quadlet.readyWhen (Quadlet.ProbeCommand "curl" ["-fsS", "http://127.0.0.1:8080/health"]) 10))+    , testCase "readiness on a job, a probe with no time, a port that is not one are refused" $ do+        let job = Quadlet.containerJob (Podman.ContainerName "job") "img:v1" ["true"]+        assertBool "" (not (null (Quadlet.containerProblems job{Quadlet.containerReady = Just (Quadlet.stillUp 3)})))+        assertBool "" (not (null (Quadlet.containerProblems app{Quadlet.containerReady = Just (Quadlet.readyWhen (Quadlet.ProbeTcp "h" 80) 0)})))+        assertBool "" (not (null (Quadlet.containerProblems app{Quadlet.containerReady = Just (Quadlet.readyWhen (Quadlet.ProbeTcp "h" 70000) 5)})))+        assertBool "" (not (null (Quadlet.containerProblems app{Quadlet.containerReady = Just (Quadlet.readyWhen (Quadlet.ProbeCommand "" []) 5)})))+        assertBool "" (not (null (Quadlet.containerProblems app{Quadlet.containerReady = Just (Quadlet.stillUp (-1))})))+        assertEqual "" [] (Quadlet.containerProblems app{Quadlet.containerReady = Just (Quadlet.readyWhen (Quadlet.ProbeTcp "127.0.0.1" 8080) 30)})+    , testCase "the unit is asked for its state and restart count, in its scope" $ do+        assertEqual "" ["show", "app.service", "--property=ActiveState,SubState,NRestarts,Result"] (Quadlet.sampleArgs app)+        assertEqual+            ""+            ["--user", "show", "app.service", "--property=ActiveState,SubState,NRestarts,Result"]+            (Quadlet.sampleArgs app{Quadlet.containerScope = Systemd.User})+    , testCase "systemctl's lines are read whatever their order" $ do+        assertEqual+            ""+            (Quadlet.UnitSample "active" "running" (Just 2) "success")+            (Quadlet.parseSample ["Result=success", "NRestarts=2", "ActiveState=active", "SubState=running"])+        assertEqual "" Nothing (Quadlet.parseSample ["ActiveState=active"]).sampleRestarts+    , testCase "an active unit systemd has not restarted is standing" $ do+        assertEqual "" (Right ()) (Quadlet.interpretStanding [0] (Quadlet.UnitSample "active" "running" (Just 0) "success"))+        assertEqual "no counter to judge by" (Right ()) (Quadlet.interpretStanding [0] (Quadlet.UnitSample "active" "running" Nothing "success"))+    , testCase "a unit being restarted, failed, stopped or already restarted is not" $ do+        assertBool "" (isLeft (Quadlet.interpretStanding [0] (Quadlet.UnitSample "activating" "auto-restart" (Just 0) "exit-code")))+        assertBool "" (isLeft (Quadlet.interpretStanding [0] (Quadlet.UnitSample "failed" "failed" (Just 5) "exit-code")))+        assertBool "" (isLeft (Quadlet.interpretStanding [0] (Quadlet.UnitSample "inactive" "dead" (Just 0) "success")))+        -- the report's case: sampled while active, between two deaths+        assertBool "" (isLeft (Quadlet.interpretStanding [0] (Quadlet.UnitSample "active" "running" (Just 2) "success")))+    , testCase "a container that stays up through the hold is ready, and the hold is waited out" $ do+        (w, clock, _) <- scripted [standing 0] Nothing+        verdict <- Quadlet.awaitReady w (Quadlet.stillUp 3)+        assertEqual "" (Right ()) verdict+        waited <- readIORef clock+        assertEqual "the hold was cut short or overrun" 3000 waited+    , testCase "a container that dies at boot fails the wait at the first look that sees it" $ do+        -- active when restart returned and at the first look, then systemd's auto-restart+        (w, clock, _) <- scripted [standing 0, standing 0, restarting 1] Nothing+        verdict <- Quadlet.awaitReady w (Quadlet.stillUp 5)+        assertBool ("not a failure: " <> show verdict) (isLeft verdict)+        waited <- readIORef clock+        assertBool "it waited the whole hold for a unit already seen down" (waited < 5000)+    , testCase "a container sampled active between two deaths is caught by its restart count" $ do+        (w, _, _) <- scripted [standing 0, standing 0, standing 1] Nothing+        verdict <- Quadlet.awaitReady w (Quadlet.stillUp 5)+        assertBool ("not a failure: " <> show verdict) (isLeft verdict)+    , testCase "a unit already down at the first look is not waited for" $ do+        (w, clock, looks) <- scripted [restarting 0] Nothing+        verdict <- Quadlet.awaitReady w (Quadlet.stillUp 5)+        assertBool ("not a failure: " <> show verdict) (isLeft verdict)+        n <- readIORef looks+        assertEqual "it kept looking" 1 n+        waited <- readIORef clock+        assertEqual "" 0 waited+    , testCase "a restart count left over from before the restart is not a restart" $ do+        -- `systemctl restart` does not reset NRestarts on a unit systemd was+        -- restarting: the baseline is what the first look sees+        (w, _, _) <- scripted [standing 3] Nothing+        verdict <- Quadlet.awaitReady w (Quadlet.stillUp 2)+        assertEqual "" (Right ()) verdict+    , testCase "the probe is waited for, then the hold starts" $ do+        (w, clock, _) <- scripted [standing 0] (Just 2000)+        verdict <- Quadlet.awaitReady w (Quadlet.readyWhen (Quadlet.ProbeTcp "127.0.0.1" 8080) 30)+        assertEqual "" (Right ()) verdict+        waited <- readIORef clock+        assertEqual "2s until the probe held, then the 3s hold" 5000 waited+    , testCase "a probe that never holds fails the wait at its timeout, naming the probe" $ do+        (w, clock, _) <- scripted [standing 0] Nothing+        verdict <- Quadlet.awaitReady w (Quadlet.readyWhen (Quadlet.ProbeTcp "127.0.0.1" 8080) 4)+        case verdict of+            Left why -> assertBool (Text.unpack why) ("tcp 127.0.0.1:8080" `Text.isInfixOf` why)+            Right () -> assertFailure "a container that never answered was called ready"+        waited <- readIORef clock+        assertEqual "" 4000 waited+    , testCase "a container that dies while its probe is waited for does not wait out the timeout" $ do+        (w, clock, _) <- scripted [standing 0, standing 0, restarting 1] Nothing+        verdict <- Quadlet.awaitReady w (Quadlet.readyWhen (Quadlet.ProbeTcp "127.0.0.1" 8080) 60)+        assertBool ("not a failure: " <> show verdict) (isLeft verdict)+        waited <- readIORef clock+        assertBool "it waited for a probe of a unit already seen down" (waited < 5000)+    , testCase "a container that dies during the hold, after its probe held, is not ready" $ do+        (w, _, _) <- scripted [standing 0, standing 0, standing 0, restarting 1] (Just 0)+        verdict <- Quadlet.awaitReady w (Quadlet.readyWhen (Quadlet.ProbeTcp "127.0.0.1" 8080) 30)+        assertBool ("not a failure: " <> show verdict) (isLeft verdict)+    , testCase "a systemd that cannot be asked is not a ready container" $ do+        (w, _, _) <- scripted [Nothing] Nothing+        verdict <- Quadlet.awaitReady w (Quadlet.stillUp 1)+        assertBool ("not a failure: " <> show verdict) (isLeft verdict)+    ]+  where+    isLeft :: Either a b -> Bool+    isLeft = either (const True) (const False)+    node = Quadlet.quadletContainer silent ignoreTrack ignoreTrack+    refOf o = fmap (\act -> act.extension.ref) (opAct o)+    notesOf o = fmap (\act -> act.extension.notes) (opAct o)+    helpOf o = fmap (\act -> act.extension.help) (opAct o)++loginTests :: [TestTree]+loginTests =+    [ testCase "the registry is the region's docker host" $+        assertEqual "" (Podman.Registry "europe-west1-docker.pkg.dev") (ArtifactRegistry.dockerRegistry (Core.Region "europe-west1"))+    , testCase "the metadata server's answer is read for the token and its lifetime" $+        case ArtifactRegistry.parseInstanceToken (LC8.pack "{\"access_token\":\"ya29.abc\",\"expires_in\":3599,\"token_type\":\"Bearer\"}") of+            Right token -> do+                assertEqual "" ("ya29.abc" :: Text) token.tokenValue+                assertEqual "" 3599 token.tokenLifetime+            Left err -> assertFailure err+    , testCase "an answer with no token, or an empty one, is not a token" $ do+        assertBool "" (isLeft' (ArtifactRegistry.parseInstanceToken (LC8.pack "{\"error\":\"nope\"}")))+        assertBool "" (isLeft' (ArtifactRegistry.parseInstanceToken (LC8.pack "{\"access_token\":\"\",\"expires_in\":10}")))+        assertBool "" (isLeft' (ArtifactRegistry.parseInstanceToken (LC8.pack "<html>")))+    , testCase "no stamp is not logged in" $+        assertBool "" (isFailure (ArtifactRegistry.interpretTokenStamp now Nothing))+    , testCase "a token with life left is left alone" $+        assertEqual "" Success (ArtifactRegistry.interpretTokenStamp now (Just "1700003000\n"))+    , testCase "a token inside the margin, or past it, is renewed" $ do+        assertBool "" (isFailure (ArtifactRegistry.interpretTokenStamp now (Just "1700000060")))+        assertBool "" (isFailure (ArtifactRegistry.interpretTokenStamp now (Just "1699990000")))+        assertBool "" (isFailure (ArtifactRegistry.interpretTokenStamp (addUTCTime 2900 now) (Just "1700003000")))+    , testCase "a token the metadata server would not yet replace outlives the margin" $+        -- the server hands out a new token once the cached one has under five+        -- minutes left, so a login never yields one the check refuses at once+        assertBool "" (ArtifactRegistry.refreshMargin < 300)+    , testCase "an unreadable stamp is renewed, not trusted" $+        assertBool "" (isFailure (ArtifactRegistry.interpretTokenStamp now (Just "soon")))+    , testCase "the stamp sits beside the auth file" $+        assertEqual "" "/etc/app/auth.json.expires" (ArtifactRegistry.tokenStampPath (Podman.AuthFile "/etc/app/auth.json"))+    , testCase "the stamp's check and write are the login node's alone" $+        -- the enclosing directory is a predecessor: carrying them, it renewed+        -- the stamp without logging in, and the login was then skipped+        withTempDir $ \tmp -> do+            asked <- newIORef (0 :: Int)+            let authfile = Podman.AuthFile (tmp </> "auth" </> "auth.json")+                stamp = ArtifactRegistry.tokenStampPath authfile+                token = modifyIORef asked (+ 1) >> pure (ArtifactRegistry.InstanceToken "ya29.abc" 3599)+                login = ArtifactRegistry.instanceLoginWith token silent ignoreTrack authfile (Core.Region "europe-west1")+                dag = Dag.foldDag Dag.sameRepresentative (evalDeps login)+                acts = Map.elems (Dag.dagNodes dag)+            assertEqual "the graph is not the login and its directory" ["directory", "podman-login"] (sort (map (\act -> act.shorthand) acts))+            dirAct <- case [act | act <- acts, act.shorthand == "directory"] of+                [act] -> pure act+                _ -> assertFailure "not exactly one directory node"+            -- a login from hours ago: credentials, and a stamp long expired+            dirAct.extension.up+            writeFile (Podman.getAuthFile authfile) "{\"auths\":{}}"+            writeFile stamp "1700000000\n"+            dirCheck <- dirAct.extension.check+            assertEqual "the directory answers for the stamp" Immaterial dirCheck+            dirAct.extension.up+            after <- readFile stamp+            assertEqual "the directory's up renewed the stamp" "1700000000\n" after+            readIORef asked >>= assertEqual "the directory's up asked for a token" 0+            loginCheck <- maybe (assertFailure "no login node") (\act -> act.extension.check) (opAct login)+            assertBool "an expired stamp does not ask for a login" (isFailure loginCheck)+    ]+  where+    now = posixSecondsToUTCTime 1700000000+    isFailure (Failure _) = True+    isFailure _ = False+    isLeft' :: Either String ArtifactRegistry.InstanceToken -> Bool+    isLeft' = either (const True) (const False)
+ test/Test/QuadletUserSpec.hs view
@@ -0,0 +1,741 @@+{-# LANGUAGE OverloadedStrings #-}++{- | Layer 2 for "Salmon.Builtin.Nodes.Podman.Quadlet": 'Quadlet.quadletContainer'+itself, run through 'upTree' and 'downTree' against this user's own systemd+manager and podman, in user scope. No root: the quadlet goes in+@~\/.config\/containers\/systemd@, every @systemctl@ is @--user@, and the+container is rootless.++What is asserted is what the node claims in its haddock: @up@ writes the+file, reloads and leaves the generated service running a container; a second+pass is a 'Skip'; a changed image reference and a changed env file each+restart the service (a new @InvocationID@ and a new container) and an+unchanged one does not; an env file rotated under a pass that rewrote the+quadlet without restarting the service is still a restart at the next pass; a+container started from a quadlet written before the watched files' digest was+keyed is reloaded and /not/ restarted by the pass that keys it, and is+restarted by a rotated env file afterwards; @down@ stops the service, removes the file and the+generated unit goes with it; and a declaration moved onto an image that+cannot be pulled fails at the pull, with the running container, its unit and+its file as they were.++Skipped loudly when @podman@, @podman-user-generator@, a running+@systemd --user@ or the image is missing. The image is one that needs no+registry login, and is only ever used if it is already on the machine; the+second reference the image-change case needs is a local tag of it, made and+removed here. Every container gets a random name, and a bracket removes its+quadlet file, stops its unit, removes its container and the tag, and reloads,+whatever the case did.+-}+module Test.QuadletUserSpec (tests) where++import Control.Concurrent (threadDelay)+import Control.Exception (bracket, finally, throwIO)+import Control.Monad (forM_, unless, void, when)+import Data.IORef (newIORef, readIORef, writeIORef)+import Data.Text (Text)+import qualified Data.Text as Text+import qualified Data.Text.IO as Text+import Numeric (showHex)+import System.Directory (XdgDirectory (XdgConfig), createDirectoryIfMissing, doesDirectoryExist, doesFileExist, findExecutable, getModificationTime, getXdgDirectory, listDirectory, removeDirectory, removeFile)+import System.Exit (ExitCode (..))+import System.FilePath ((</>))+import System.IO (hPutStrLn, stderr)+import System.Posix.Files (fileOwner, fileSize, getFileStatus)+import System.Posix.User (getRealUserID)+import System.Process (readProcessWithExitCode)+import Data.Time.Clock.POSIX (getPOSIXTime)+import System.CPUTime (getCPUTime)+import System.Posix.Process (getProcessID)+import Test.Tasty (DependencyType (..), TestTree, sequentialTestGroup)+import Test.Tasty.HUnit (assertBool, assertEqual, assertFailure, testCaseSteps)++import Salmon.Actions.UpDown (CheckResult (..))+import qualified Salmon.Actions.UpDown as UpDown+import Salmon.Builtin.Extension+import qualified Salmon.Builtin.Nodes.Podman as Podman+import qualified Salmon.Builtin.Nodes.Podman.Quadlet as Quadlet+import qualified Salmon.Builtin.Nodes.Systemd as Systemd+import qualified Salmon.Builtin.Nodes.Systemd.Job as Job+import Salmon.Op.Actions (Act (..))+import Salmon.Op.OpGraph (inject)+import Salmon.Op.Ref (mkRef)+import Salmon.Reporter (silent)+import Test.Harness (runDownCapturing, runUpCapturing, withTempDir)++tests :: TestTree+tests =+    -- one after the other: both share the user's quadlet directory, which+    -- whichever case created it removes again only once it is empty+    sequentialTestGroup+        "Salmon.Builtin.Nodes.Podman.Quadlet (user scope, Layer 2)"+        AllFinish+        [ testCaseSteps "up runs it, a second pass skips, down removes it" $ \step ->+            withUserQuadlets $ \unitDir -> withFreshContainer unitDir $ \c -> lifecycle step c+        , testCaseSteps "a new image or env file restarts it, the same ones do not" $ \step ->+            withUserQuadlets $ \unitDir -> withFreshContainer unitDir $ \c -> restarts step c+        , testCaseSteps "two changed quadlets, one reload: the one not restarted is not skipped" $ \step ->+            withUserQuadlets $ \unitDir ->+                withFreshContainer unitDir $ \a -> withFreshContainer unitDir $ \b -> sharedReload step a b+        , testCaseSteps "a rotated env file behind a failed predecessor, reloaded by a sibling, is not skipped" $ \step ->+            withUserQuadlets $ \unitDir ->+                withFreshContainer unitDir $ \a -> withFreshContainer unitDir $ \b -> rotatedEnv step a b+        , testCaseSteps "a quadlet from before the watched files' key is rewritten without a restart" $ \step ->+            withUserQuadlets $ \unitDir -> withFreshContainer unitDir $ \c -> keying step c+        , testCaseSteps "an image that cannot be pulled leaves the running container alone" $ \step ->+            withUserQuadlets $ \unitDir -> withFreshContainer unitDir $ \c -> unpullable step c+        , testCaseSteps "a stamped container job runs once, again after a change, and fails while its last run did" $ \step ->+            withUserQuadlets $ \unitDir -> withFreshContainer unitDir $ \c -> withTempDir $ \tmp -> completing step tmp c+        , testCaseSteps "under keep-id, what the container writes into a bind is the user's own" $ \step ->+            withUserQuadlets $ \unitDir -> withFreshContainer unitDir $ \c -> withTempDir $ \tmp -> keepingId step tmp c+        ]++-- | Small, long-running with no arguments, no published port, no login.+image :: Text+image = "docker.io/library/nginx:alpine"++-------------------------------------------------------------------------------++lifecycle :: (String -> IO ()) -> Quadlet.Container -> IO ()+lifecycle step c = do+    step "first up"+    reports <- runUpCapturing (node c)+    assertUp reports+    assertEqual "the quadlet node was not applied once" 1 (count isEval reports)+    present <- doesFileExist (Quadlet.quadletPath c)+    assertBool "no quadlet file was written" present+    active <- showProperty c "ActiveState"+    assertEqual "the generated service is not running" "active" active+    state <- showProperty c "UnitFileState"+    assertEqual "the service is not the generator's" "generated" state+    running <- containerRunning c+    assertBool "no container is running under the container's name" running+    invocation <- showProperty c "InvocationID"+    container <- containerId c+    -- where the generator put the unit, to check it is gone after down+    fragment <- Text.unpack <$> showProperty c "FragmentPath"+    generated <- doesFileExist fragment+    assertBool ("the generated unit is not at its FragmentPath " <> fragment) generated+    sourced <- showProperty c "SourcePath"+    assertEqual "the generated unit does not name the quadlet as its source" (Text.pack (Quadlet.quadletPath c)) sourced++    step "second up"+    again <- runUpCapturing (node c)+    assertUp again+    assertEqual "the second pass was not a skip" (1, 0) (count isSkip again, count isEval again)+    assertEqual "the second pass restarted the service" invocation =<< showProperty c "InvocationID"+    assertEqual "the second pass replaced the container" container =<< containerId c++    step "down"+    down <- runDownCapturing (node c)+    assertBool ("down failed: " <> show (failures down)) (null (failures down))+    gone <- not <$> doesFileExist (Quadlet.quadletPath c)+    assertBool "the quadlet file is still there" gone+    stillRunning <- containerRunning c+    assertBool "the container is still there" (not stillRunning)+    load <- showProperty c "LoadState"+    assertEqual "the generated unit outlived its file" "not-found" load+    leftover <- doesFileExist fragment+    assertBool ("the generator's output is still on disk at " <> fragment) (not leftover)++restarts :: (String -> IO ()) -> Quadlet.Container -> IO ()+restarts step c0 = withTempDir $ \dir -> withLocalTag $ \alias -> do+    let envFile = dir </> "env"+        c = c0{Quadlet.containerEnvFile = Just envFile}+    writeFile envFile "SALMON_QUADLET_TEST=one\n"++    step "first up"+    assertUp =<< runUpCapturing (node c)+    assertEqual "the env file was not handed to the container" "one" =<< containerEnv c+    (i1, k1) <- identity c++    step "unchanged up"+    again <- runUpCapturing (node c)+    assertUp again+    assertEqual "an unchanged declaration was applied" 0 (count isEval again)+    assertEqual "an unchanged declaration restarted it" (i1, k1) =<< identity c++    step "changed env file"+    writeFile envFile "SALMON_QUADLET_TEST=two\n"+    envChanged <- runUpCapturing (node c)+    assertUp envChanged+    assertEqual "a changed env file was not applied" 1 (count isEval envChanged)+    (i2, k2) <- identity c+    assertBool "a changed env file did not restart the service" (i2 /= i1)+    assertBool "a changed env file left the old container" (k2 /= k1)+    assertEqual "the restarted container has the old env" "two" =<< containerEnv c++    step "changed image reference"+    let moved = c{Quadlet.containerImage = alias}+    imageChanged <- runUpCapturing (node moved)+    assertUp imageChanged+    assertEqual "a changed image was not applied" 1 (count isEval imageChanged)+    (i3, k3) <- identity moved+    assertBool "a changed image did not restart the service" (i3 /= i2)+    assertBool "a changed image left the old container" (k3 /= k2)+    assertEqual "the container does not run the new reference" alias =<< containerImage moved++    step "unchanged up after the changes"+    settled <- runUpCapturing (node moved)+    assertUp settled+    assertEqual "an unchanged declaration was applied" 0 (count isEval settled)+    assertEqual "an unchanged declaration restarted it" (i3, k3) =<< identity moved++    step "down"+    down <- runDownCapturing (node moved)+    assertBool ("down failed: " <> show (failures down)) (null (failures down))+    stillRunning <- containerRunning moved+    assertBool "the container is still there" (not stillRunning)+  where+    -- the second reference the image change needs: a local tag of the same+    -- image, so the case needs nothing from a registry+    withLocalTag :: (Text -> IO a) -> IO a+    withLocalTag act = do+        let alias = "localhost/" <> Podman.getContainerName c0.containerName <> ":moved"+        (code, _, err) <- readProcessWithExitCode "podman" ["tag", Text.unpack image, Text.unpack alias] ""+        unless (code == ExitSuccess) $ assertFailure ("podman tag failed: " <> err)+        -- `podman untag IMAGE` with no name removes /every/ name the image+        -- has, the shared one included: name the alias twice, as the image+        -- and as the one name to remove+        act alias `finally` void (readProcessWithExitCode "podman" ["untag", Text.unpack alias, Text.unpack alias] "")++    identity c = (,) <$> showProperty c "InvocationID" <*> containerId c++{- | What an interrupted pass leaves behind: both quadlet files rewritten, and+a @daemon-reload@ run on behalf of one of them. systemd then reads+@NeedDaemonReload=no@ for /both/ units, and only the running container can+say that the second one was never restarted.+-}+sharedReload :: (String -> IO ()) -> Quadlet.Container -> Quadlet.Container -> IO ()+sharedReload step a0 b0 = do+    step "both up"+    assertUp =<< runUpCapturing (node a0)+    assertUp =<< runUpCapturing (node b0)+    invocation <- showProperty b0 "InvocationID"+    container <- containerId b0++    step "both files changed, one daemon-reload, neither service restarted"+    let a = a0{Quadlet.containerDescription = "changed (a)"}+        b = b0{Quadlet.containerDescription = "changed (b)"}+    Text.writeFile (Quadlet.quadletPath a) =<< Quadlet.renderQuadlet a+    Text.writeFile (Quadlet.quadletPath b) =<< Quadlet.renderQuadlet b+    (code, _, err) <- readProcessWithExitCode "systemctl" ["--user", "daemon-reload"] ""+    unless (code == ExitSuccess) $ assertFailure ("daemon-reload failed: " <> err)+    assertEqual "systemd still remembers the file changed; the case is not the one it means to be" "no" =<< showProperty b "NeedDaemonReload"+    assertEqual "the service was restarted by the reload" invocation =<< showProperty b "InvocationID"++    step "the check"+    verdict <- Quadlet.checkContainer b+    case verdict of+        UpDown.Failure _ -> pure ()+        other -> assertFailure ("a quadlet whose service was never restarted reads " <> show other)++    step "up"+    reports <- runUpCapturing (node b)+    assertUp reports+    assertEqual "the changed quadlet was skipped" (1, 0) (count isEval reports, count isSkip reports)+    assertBool "the service was not restarted" . (/= invocation) =<< showProperty b "InvocationID"+    assertBool "the old container is still the one running" . (/= container) =<< containerId b+    assertEqual "the restarted service is not satisfied" UpDown.Success =<< Quadlet.checkContainer b++    step "down"+    forM_ [a, b] $ \c -> do+        down <- runDownCapturing (node c)+        assertBool ("down failed: " <> show (failures down)) (null (failures down))++{- | 'sharedReload' as it was met in a deployment, with the env file and not+the declaration as what changed: two quadlets whose env files are rotated,+the second with a predecessor (a migration, say) that fails. Its quadlet+file is rewritten all the same -- the predecessor is injected on the node,+beside the file, not declared in the track -- its service is blocked, and+the first quadlet's @up@ reloads the machine. systemd then has no memory+that the second file changed, the image is the one it was, and only the+running container's label says it was started with the previous env file.++The two quadlets of the failing pass are walked one after the other, the+blocked one first, so that the order the case depends on is not left to the+walk.+-}+rotatedEnv :: (String -> IO ()) -> Quadlet.Container -> Quadlet.Container -> IO ()+rotatedEnv step a0 b0 = withTempDir $ \dir -> do+    let envOf name = dir </> name+        a = a0{Quadlet.containerEnvFile = Just (envOf "a.env")}+        b = b0{Quadlet.containerEnvFile = Just (envOf "b.env")}+        rotate value = forM_ ["a.env", "b.env"] $ \name -> writeFile (envOf name) ("SALMON_QUADLET_TEST=" <> value <> "\n")+    failing <- newIORef False+    let predecessor =+            op "quadlet-test-predecessor" nodeps $ \actions ->+                actions+                    { ref = mkRef "quadlet-test-predecessor" (Podman.getContainerName b.containerName)+                    , up = do+                        broken <- readIORef failing+                        when broken (throwIO (userError "the predecessor fails"))+                    }+        nodeB = node b `inject` predecessor++    step "both up"+    rotate "one"+    assertUp =<< runUpCapturing (node a)+    assertUp =<< runUpCapturing nodeB+    assertEqual "" "one" =<< containerEnv b+    invocation <- showProperty b "InvocationID"+    container <- containerId b++    step "both env files rotated; b's predecessor fails, a is brought up"+    rotate "two"+    writeIORef failing True+    blocked <- runUpCapturing nodeB+    assertEqual "the predecessor is not what failed" ["quadlet-test-predecessor"] [act.shorthand | UpDown.Failed act _ <- blocked]+    assertEqual "b's service was not blocked" 1 (length [() | UpDown.Blocked act <- blocked, act.shorthand == "podman-quadlet"])+    rotated <- Quadlet.renderQuadlet b+    assertEqual "b's quadlet was not rewritten; the case is not the one it means to be" rotated =<< Text.readFile (Quadlet.quadletPath b)+    assertUp =<< runUpCapturing (node a)+    assertEqual "" "two" =<< containerEnv a+    assertEqual "systemd still remembers b's file changed; the case is not the one it means to be" "no" =<< showProperty b "NeedDaemonReload"+    assertEqual "b was restarted by a's pass" invocation =<< showProperty b "InvocationID"+    assertEqual "" "one" =<< containerEnv b++    step "the check"+    verdict <- Quadlet.checkContainer b+    case verdict of+        UpDown.Failure _ -> pure ()+        other -> assertFailure ("a container still running on the previous env file reads " <> show other)++    step "the next pass, nothing failing"+    writeIORef failing False+    reports <- runUpCapturing nodeB+    assertUp reports+    assertEqual "b was skipped" (1, 0) (count isEval reports, count isSkip reports)+    assertBool "b's service was not restarted" . (/= invocation) =<< showProperty b "InvocationID"+    assertBool "b's old container is still the one running" . (/= container) =<< containerId b+    assertEqual "b still presents the previous env file" "two" =<< containerEnv b++    step "and then it is left alone"+    settled <- runUpCapturing nodeB+    assertUp settled+    assertEqual "a settled quadlet was applied again" (0, 1) (count isEval settled, count isSkip settled)++    step "down"+    forM_ [a, b] $ \c -> do+        down <- runDownCapturing (node c)+        assertBool ("down failed: " <> show (failures down)) (null (failures down))++{- | What an upgrade meets: a service running from a quadlet whose trailing+comment is a plain hash of its env file and whose label was made from it.+The file has to be rewritten (that hash is what must go) and the service has+no reason to restart: it runs this declaration with this env file.++The old file is spelled here as it was written then, from+'Quadlet.renderContainer' and 'Systemd.withWatchedFingerprint'.+-}+keying :: (String -> IO ()) -> Quadlet.Container -> IO ()+keying step c0 = withTempDir $ \dir -> do+    let envFile = dir </> "env"+        c = c0{Quadlet.containerEnvFile = Just envFile}+        labelled fingerprint = "Label=" <> Quadlet.quadletLabel <> "=" <> fingerprint+        identity = (,) <$> showProperty c "InvocationID" <*> containerId c+        label = podmanInspect c ("{{index .Config.Labels \"" <> Text.unpack Quadlet.quadletLabel <> "\"}}")+    writeFile envFile "SALMON_QUADLET_TEST=one\n"++    step "a service started from a quadlet written before the key"+    unkeyed <- Quadlet.unkeyedFingerprint c+    plain <- Text.strip <$> Systemd.withWatchedFingerprint [envFile] ""+    let old =+            Text.unlines $+                concat+                    [ [labelled unkeyed | "EnvironmentFile=" `Text.isPrefixOf` l] <> [l]+                    | l <- Text.lines (Quadlet.renderContainer c)+                    ]+                    <> [plain]+        target = Text.unpack (Quadlet.serviceTarget c)+    Text.writeFile (Quadlet.quadletPath c) old+    (reloaded, _, reloadErr) <- readProcessWithExitCode "systemctl" ["--user", "daemon-reload"] ""+    unless (reloaded == ExitSuccess) $ assertFailure ("daemon-reload failed: " <> reloadErr)+    (started, _, startErr) <- readProcessWithExitCode "systemctl" ["--user", "start", target] ""+    unless (started == ExitSuccess) $ assertFailure ("the old quadlet did not start: " <> startErr)+    assertEqual "" "one" =<< containerEnv c+    assertEqual "the container does not carry the old label" (Just unkeyed) =<< label+    before <- identity++    step "the pass that keys the digest"+    reports <- runUpCapturing (node c)+    assertUp reports+    assertEqual "the rewritten quadlet was not reloaded" 1 (count isEval reports)+    assertEqual "the service was restarted, or its container replaced" before =<< identity+    written <- Text.readFile (Quadlet.quadletPath c)+    declared <- Quadlet.quadletFingerprint c+    assertBool "the keyed fingerprint is the old one" (declared /= unkeyed)+    assertEqual "the file is not the keyed one" written =<< Quadlet.renderQuadlet c+    assertBool "the plain hash is still in the file" (not (plain `Text.isInfixOf` written))+    assertBool "the old label is still in the file" (not (unkeyed `Text.isInfixOf` written))+    execStart <- showProperty c "ExecStart"+    assertBool "the generated unit was not made from the keyed file" ((Quadlet.quadletLabel <> "=" <> declared) `Text.isInfixOf` execStart)+    assertBool "the generated unit still carries the old label" (not (unkeyed `Text.isInfixOf` execStart))+    assertEqual "" "no" =<< showProperty c "NeedDaemonReload"+    assertEqual "the adopted container is not satisfied" UpDown.Success =<< Quadlet.checkContainer c++    step "and then it is left alone"+    settled <- runUpCapturing (node c)+    assertUp settled+    assertEqual "a settled quadlet was applied again" (0, 1) (count isEval settled, count isSkip settled)+    assertEqual "a settled quadlet was restarted" before =<< identity++    step "a rotated env file still restarts it"+    writeFile envFile "SALMON_QUADLET_TEST=two\n"+    rotated <- runUpCapturing (node c)+    assertUp rotated+    assertEqual "a changed env file was not applied" 1 (count isEval rotated)+    after <- identity+    assertBool "a changed env file did not restart the service" (fst after /= fst before)+    assertBool "a changed env file left the old container" (snd after /= snd before)+    assertEqual "" "two" =<< containerEnv c+    now <- Quadlet.quadletFingerprint c+    assertEqual "the restarted container does not carry the keyed label" (Just now) =<< label++    step "down"+    down <- runDownCapturing (node c)+    assertBool ("down failed: " <> show (failures down)) (null (failures down))+    stillRunning <- containerRunning c+    assertBool "the container is still there" (not stillRunning)++{- | A healthy container re-declared onto an image no registry will hand+over. Left to the service's start, the pull came after the stop and the+restart took the container down; the pull is a node ahead of the file now,+so the pass fails there and nothing that was running or written changes.++The reference is on a port of this machine nothing listens on, so the pull+is refused at once and reaches nothing outside.+-}+unpullable :: (String -> IO ()) -> Quadlet.Container -> IO ()+unpullable step c = do+    step "first up"+    assertUp =<< runUpCapturing (node c)+    before <- identity+    written <- Text.readFile (Quadlet.quadletPath c)++    step "up onto an image that cannot be pulled"+    let moved = c{Quadlet.containerImage = "localhost:1/salmon-quadlet-test/unpullable:v1"}+    reports <- runUpCapturing (node moved)+    assertEqual+        "the pull is not what failed"+        ["podman-quadlet-image"]+        [act.shorthand | UpDown.Failed act _ <- reports]+    assertEqual "the quadlet node was not blocked" 1 (length [() | UpDown.Blocked act <- reports, act.shorthand == "podman-quadlet"])+    assertEqual "the quadlet node was applied" 0 (count isEval reports)+    assertEqual "the quadlet file was rewritten" written =<< Text.readFile (Quadlet.quadletPath c)+    assertEqual "the service is not running any more" "active" =<< showProperty c "ActiveState"+    assertEqual "the service was restarted, or its container replaced" before =<< identity+    assertEqual "systemd was told the unit changed" "no" =<< showProperty c "NeedDaemonReload"++    step "the previous declaration is still satisfied"+    again <- runUpCapturing (node c)+    assertUp again+    assertEqual "the previous declaration was applied again" 0 (count isEval again)+    assertEqual "the previous declaration restarted it" before =<< identity++    step "down"+    down <- runDownCapturing (node c)+    assertBool ("down failed: " <> show (failures down)) (null (failures down))+    stillRunning <- containerRunning c+    assertBool "the container is still there" (not stillRunning)+  where+    identity = (,) <$> showProperty c "InvocationID" <*> containerId c++{- | A container /job/ that leaves a stamp, under 'Quadlet.completedQuadletJob':+nothing keeps its generated unit loaded, so the stamp is all that remembers+a successful run.+-}+completing :: (String -> IO ()) -> FilePath -> Quadlet.Container -> IO ()+completing step tmp c0 = do+    let stamp = tmp </> "stamp"+        jobOf command =+            c0+                { Quadlet.containerExec = ["/bin/sh", "-c", command]+                , Quadlet.containerLifetime = Quadlet.RunToCompletion+                , Quadlet.containerStamp = Just stamp+                }+        c = jobOf "exit 0"+        completed = Quadlet.completedQuadletJob silent ignoreTrack ignoreTrack+        ran = "systemd-job-completed"+        countOf :: (UpDown.Report Extension -> Maybe (Act Extension)) -> [UpDown.Report Extension] -> Int+        countOf which reports = length [() | Just act <- map which reports, act.shorthand == ran]+        verdict = Job.checkLastRun . Quadlet.containerCompletion++    step "first up runs it"+    reports <- runUpCapturing (completed c)+    assertUp reports+    assertEqual "the job was not run once" 1 (countOf isEval reports)+    assertBool "the run left no stamp" =<< doesFileExist stamp+    assertBool "the run left its running stamp" . not =<< doesFileExist (Job.stampRunning stamp)+    assertBool "the job's container outlived its run" . not =<< containerRunning c+    -- nothing refers to the generated unit, so systemd has forgotten the run+    assertEqual "" "" =<< showProperty c "ExecMainStartTimestamp"+    assertEqual "" Completed =<< verdict c+    firstRun <- getModificationTime stamp++    step "second up skips it"+    again <- runUpCapturing (completed c)+    assertUp again+    assertEqual "the job was not skipped" (1, 0) (countOf isSkip again, countOf isEval again)+    assertEqual "a completed job was run again" firstRun =<< getModificationTime stamp++    step "a changed declaration runs it again"+    let changed = jobOf "true"+    rerun <- runUpCapturing (completed changed)+    assertUp rerun+    assertEqual "the job was not run again" 1 (countOf isEval rerun)+    assertEqual "the changed job was not installed again" 1 (length [() | UpDown.Eval act <- rerun, act.shorthand == "podman-quadlet-job"])+    secondRun <- getModificationTime stamp+    assertBool "the stamp did not move" (secondRun > firstRun)+    assertEqual "" Completed =<< verdict changed++    step "a failing command fails the pass"+    let failing = jobOf "exit 3"+    failed <- runUpCapturing (completed failing)+    -- systemd unloaded the unit when its last run ended; were the job not+    -- installed again, this would run the previous command and succeed+    assertEqual "the changed job was not installed again" 1 (length [() | UpDown.Eval act <- failed, act.shorthand == "podman-quadlet-job"])+    assertEqual "the failed run was not a failed up" [ran] [act.shorthand | UpDown.Failed act _ <- failed]+    said <- verdict failing+    case said of+        Failure why -> assertBool (Text.unpack why) ("exit status 3" `Text.isInfixOf` why)+        other -> assertFailure ("a failed job reads as " <> show other)+    assertEqual "a failed run moved the stamp" secondRun =<< getModificationTime stamp++    step "also once systemd has forgotten the failure"+    void (readProcessWithExitCode "systemctl" ["--user", "reset-failed", Text.unpack (Quadlet.serviceTarget c)] "")+    forgotten <- verdict failing+    case forgotten of+        Failure _ -> pure ()+        other -> assertFailure ("the failure was forgotten with systemd's record of it: " <> show other)++    step "a run that succeeds clears it"+    fixed <- runUpCapturing (completed c)+    assertUp fixed+    assertEqual "" Completed =<< verdict c++    step "down"+    down <- runDownCapturing (completed c)+    assertBool ("down failed: " <> show (failures down)) (null (failures down))+    assertBool "the stamp is still there" . not =<< doesFileExist stamp+    assertBool "the quadlet file is still there" . not =<< doesFileExist (Quadlet.quadletPath c)+    assertEqual "" "not-found" =<< showProperty c "LoadState"++{- | 'Quadlet.containerKeepId' on a real rootless container: a process running+as the image's unprivileged uid (101, nginx's) writes into a bind this module+created, and the file is this user's on the host, where the default mapping+would have made it a subordinate uid's.++The data directory is under the case's temporary directory and holds nothing+but files of this user, so the harness removes it as it removes any other.+-}+keepingId :: (String -> IO ()) -> FilePath -> Quadlet.Container -> IO ()+keepingId step tmp c0 = do+    let dataDir = tmp </> "data"+        written = dataDir </> "who"+        c =+            c0+                { Quadlet.containerKeepId = Just (Quadlet.keepIdAs 101 101)+                , Quadlet.containerBinds = [Quadlet.bind dataDir "/data"]+                , Quadlet.containerExec = ["sh", "-c", "id -u > /data/who && exec sleep 600"]+                }+    me <- getRealUserID++    step "up"+    assertUp =<< runUpCapturing (node c)+    assertEqual "the generated service is not running" "active" =<< showProperty c "ActiveState"++    step "the host user lists and reads what the container wrote"+    awaitFile written+    entries <- listDirectory dataDir+    assertEqual "" ["who"] entries+    inside <- Text.strip <$> Text.readFile written+    assertEqual "the container's process did not run as the mapped uid" "101" inside+    owner <- fileOwner <$> getFileStatus written+    assertEqual "what the container wrote is not this user's on the host" me owner+    dirOwner <- fileOwner <$> getFileStatus dataDir+    assertEqual "the bind's directory changed hands" me dirOwner++    step "second up"+    again <- runUpCapturing (node c)+    assertUp again+    assertEqual "the second pass was not a skip" (1, 0) (count isSkip again, count isEval again)++    step "down"+    down <- runDownCapturing (node c)+    assertBool ("down failed: " <> show (failures down)) (null (failures down))+    kept <- doesFileExist written+    assertBool "down removed the container's data" kept+  where+    -- the unit is started once the container exists, which is before its+    -- command has run+    awaitFile :: FilePath -> IO ()+    awaitFile path = go (50 :: Int)+      where+        go 0 = assertFailure ("the container never wrote " <> path)+        go n = do+            there <- doesFileExist path+            size <- if there then fileSize <$> getFileStatus path else pure 0+            unless (size > 0) (threadDelay 200000 >> go (n - 1))++-------------------------------------------------------------------------------++node :: Quadlet.Container -> Op+node = Quadlet.quadletContainer silent ignoreTrack ignoreTrack++assertUp :: [UpDown.Report Extension] -> IO ()+assertUp reports = do+    assertBool ("up failed: " <> show (failures reports)) (null (failures reports))+    assertEqual "nothing was blocked" 0 (length [() | UpDown.Blocked _ <- reports])++failures :: [UpDown.Report Extension] -> [String]+failures reports = [Text.unpack act.shorthand <> ": " <> show e | UpDown.Failed act e <- reports]++-- | How many reports about the quadlet node itself match.+count :: (UpDown.Report Extension -> Maybe (Act Extension)) -> [UpDown.Report Extension] -> Int+count which reports = length [() | Just act <- map which reports, act.shorthand == "podman-quadlet"]++isEval, isSkip :: UpDown.Report Extension -> Maybe (Act Extension)+isEval (UpDown.Eval act) = Just act+isEval _ = Nothing+isSkip (UpDown.Skip act) = Just act+isSkip _ = Nothing++-------------------------------------------------------------------------------++showProperty :: Quadlet.Container -> String -> IO Text+showProperty c property = do+    (_, out, _) <-+        readProcessWithExitCode+            "systemctl"+            ["--user", "show", Text.unpack (Quadlet.serviceTarget c), "--property=" <> property, "--value"]+            ""+    pure (Text.strip (Text.pack out))++podmanInspect :: Quadlet.Container -> String -> IO (Maybe Text)+podmanInspect c format = do+    (code, out, _) <-+        readProcessWithExitCode+            "podman"+            ["container", "inspect", "--format", format, Text.unpack (Podman.getContainerName c.containerName)]+            ""+    pure $ case code of+        ExitSuccess -> Just (Text.strip (Text.pack out))+        ExitFailure _ -> Nothing++containerRunning :: Quadlet.Container -> IO Bool+containerRunning c = (== Just "true") <$> podmanInspect c "{{.State.Running}}"++containerId :: Quadlet.Container -> IO Text+containerId c = maybe (assertFailure "no container to inspect") pure =<< podmanInspect c "{{.Id}}"++containerImage :: Quadlet.Container -> IO Text+containerImage c = maybe (assertFailure "no container to inspect") pure =<< podmanInspect c "{{.ImageName}}"++containerEnv :: Quadlet.Container -> IO Text+containerEnv c = do+    (code, out, err) <-+        readProcessWithExitCode+            "podman"+            ["exec", Text.unpack (Podman.getContainerName c.containerName), "printenv", "SALMON_QUADLET_TEST"]+            ""+    case code of+        ExitSuccess -> pure (Text.strip (Text.pack out))+        ExitFailure _ -> assertFailure ("printenv in the container failed: " <> err)++-------------------------------------------------------------------------------++userGenerator :: FilePath+userGenerator = "/usr/lib/systemd/user-generators/podman-user-generator"++{- | Runs the action with the user's quadlet directory, or skips loudly when+something it needs is missing. Creates the directory (and its parents under+@~\/.config@) if it is not there, and removes what it created again if it is+empty afterwards, along with the key of the watched files' digest if a case+made it.+-}+withUserQuadlets :: (FilePath -> IO ()) -> IO ()+withUserQuadlets act = do+    uid <- getRealUserID+    podman <- findExecutable "podman"+    generator <- doesFileExist userGenerator+    (managerCode, managerOut, _) <- readProcessWithExitCode "systemctl" ["--user", "is-system-running"] ""+    let manager = managerCode == ExitSuccess || Text.strip (Text.pack managerOut) `elem` ["degraded", "starting"]+    (imageCode, _, _) <-+        if podman == Nothing+            then pure (ExitFailure 1, "", "")+            else readProcessWithExitCode "podman" ["image", "exists", Text.unpack image] ""+    case () of+        _+            | uid == 0 -> skip "running as root; this test is the user-scope one"+            | podman == Nothing -> skip "`podman` not found on PATH"+            | not generator -> skip ("no " <> userGenerator <> "; podman's quadlet user generator is not installed")+            | not manager -> skip "no `systemd --user` manager is answering"+            | imageCode /= ExitSuccess -> skip (Text.unpack image <> " is not on this machine (`podman pull " <> Text.unpack image <> "` to run this test)")+            | otherwise -> do+                dir <- getXdgDirectory XdgConfig ("containers" </> "systemd")+                created <- missingAncestors dir+                createDirectoryIfMissing True dir+                -- the key of the watched files' digest is the directory's and+                -- outlives every `down`: one made here is removed here+                let key = Quadlet.watchKeyPath (Quadlet.container (Podman.ContainerName "any") image){Quadlet.containerUnitDir = dir}+                keyed <- doesFileExist key+                act dir `finally` do+                    unless keyed $ do+                        made <- doesFileExist key+                        when made (removeFile key)+                    mapM_ removeIfEmpty created+  where+    skip why = hPutStrLn stderr ("SKIPPED: " <> why <> "; the quadlet node was not run against a real systemd")++    -- the directories `createDirectoryIfMissing` is about to make, deepest first+    missingAncestors :: FilePath -> IO [FilePath]+    missingAncestors dir = do+        exists <- doesDirectoryExist dir+        if exists+            then pure []+            else do+                parents <- missingAncestors (parentOf dir)+                pure (dir : parents)++    parentOf :: FilePath -> FilePath+    parentOf = reverse . drop 1 . dropWhile (/= '/') . reverse++    removeIfEmpty :: FilePath -> IO ()+    removeIfEmpty dir = do+        exists <- doesDirectoryExist dir+        when exists $ do+            entries <- listDirectory dir+            when (null entries) (removeDirectory dir)++{- | A container under a name nothing real has, and a cleanup that does not+trust the node's own @down@: stop the unit, remove the file, reload, remove the+container.+-}+withFreshContainer :: FilePath -> (Quadlet.Container -> IO a) -> IO a+withFreshContainer unitDir act = bracket fresh cleanup act+  where+    fresh = do+        -- wall clock, CPU time and pid: two runs, or two cases of one run,+        -- never share a name+        now <- getPOSIXTime+        cpu <- getCPUTime+        pid <- getProcessID+        let suffix = showHex (floor (now * 1000000) :: Integer) "" <> "-" <> showHex (cpu `mod` 0xffffff) "" <> "-" <> show pid+            name = Podman.ContainerName (Text.pack ("salmon-quadlet-test-" <> suffix))+        pure+            (Quadlet.container name image)+                { Quadlet.containerScope = Systemd.User+                , Quadlet.containerUnitDir = unitDir+                , Quadlet.containerWantedBy = Nothing+                , Quadlet.containerRestart = Quadlet.RestartNo+                }+    cleanup c = do+        let name = Text.unpack (Podman.getContainerName c.containerName)+            quiet cmd args = void (readProcessWithExitCode cmd args "")+        quiet "systemctl" ["--user", "stop", Text.unpack (Quadlet.serviceTarget c)]+        present <- doesFileExist (Quadlet.quadletPath c)+        when present (removeFile (Quadlet.quadletPath c))+        quiet "systemctl" ["--user", "daemon-reload"]+        quiet "systemctl" ["--user", "reset-failed", Text.unpack (Quadlet.serviceTarget c)]+        quiet "podman" ["rm", "--force", "--ignore", name]
test/Test/QuerySpec.hs view
@@ -1,3 +1,5 @@+{-# LANGUAGE TupleSections #-}+ {- | Layer 0 coverage for "Salmon.Actions.Query" per @specs/advance-querying.md@: pattern matching, selector resolution against a graph with a repeated subtree (the same @Ref@ reachable at two paths — the "passwordless"/"chown"@@ -12,6 +14,7 @@ import qualified Data.Map.Strict as Map import qualified Data.Set as Set import Data.Text (Text)+import qualified Data.Text as Text import Test.Tasty (TestTree, testGroup) import Test.Tasty.HUnit (assertBool, assertEqual, assertFailure, testCase) @@ -28,6 +31,7 @@ import Salmon.Reporter (silent)  import Test.Harness (runUpCapturing)+import Test.PathListReference (outlinePathsUncapped)  tests :: TestTree tests =@@ -39,6 +43,9 @@         , testCase "forceSkip makes upTree report Skip for the excluded node, Eval for the rest" forceSkipSkipsOnlyExcluded         , testCase "pathedNodes carries each node's help text alongside its path/Ref" pathedNodesCarriesHelp         , testCase "renderAnnotated tags same-path, distinct-Ref siblings with a stable shortRef so they aren't mistaken for duplicates" renderAnnotatedDisambiguatesSameTextSiblings+        , testCase "outline: selectors, first paths and shortest paths are what the full path listing gives" outlineAgreesWithTheListing+        , testCase "outline: a node declared again with one more dependency has that dependency" outlineMergesALaterOccurrence+        , testCase "outline: sixty diamonds resolve a selector and list paths without listing 2^60 of them" outlineOnADiamondChain         , testCase "resolveRewrittenSelectors: a plain path selector behaves exactly as resolveSelectors" rewrittenPathSelectorUnchanged         , testCase "resolveRewrittenSelectors: a #ref selector addresses a declared node directly" rewrittenRefSelectorAddressesDeclaredNode         , testCase "resolveRewrittenSelectors: a #ref selector addressing a batch expands to its declared members" rewrittenRefSelectorExpandsABatch@@ -164,6 +171,115 @@     assertBool "the non-colliding root path itself is left untagged" ("/root" `elem` rendered)  -------------------------------------------------------------------------------+-- the outline: one entry per Ref instead of one per path++{- | @n@ diamonds on top of one another: each @top@ stands on a @left@ and a+@right@ that both stand on the next @top@ down, and the last on @bottom@. The+number of paths to a node doubles with every diamond above it.+-}+diamondChain :: Int -> Op+diamondChain n = go n+  where+    go :: Int -> Op+    go 0 = op "bottom" nodeps $ \x -> x{ref = mkRef "diamond" ("bottom" :: Text)}+    go i =+        let below = go (i - 1)+            side name = op name (deps [below]) $ \x -> x{ref = mkRef "diamond" (name, i)}+         in op "top" (deps [side "left", side "right"]) $ \x -> x{ref = mkRef "diamond" ("top" :: Text, i)}++{- | On graphs small enough to list every path: a pattern selects exactly the+'Ref's found at a matching path, the first path per 'Ref' is the first in+the listing, and the paths kept are the head of the listing sorted.+-}+outlineAgreesWithTheListing :: IO ()+outlineAgreesWithTheListing =+    mapM_ agrees [("shared", sharedGraph), ("four diamonds", diamondChain 4)]+  where+    patterns =+        [ "/**"+        , "/root/a/**"+        , "/root/*/shared"+        , "/top"+        , "/top/**/bottom"+        , "/top/left/top/right/**"+        , "/**/right/*"+        , "/**/left/**/right/top/**"+        , "/*/*/*"+        , "/top/right/top/right/top/right/top/right/bottom"+        , "/nothing/**"+        , ""+        ]+    agrees :: (String, Op) -> IO ()+    agrees (name, graph) = do+        let cograph = runIdentity (expand graph)+            entries = Query.pathedRefs cograph+            o = Query.outline cograph+        assertEqual (name <> ": every node") (Set.fromList (map snd entries)) (Query.outlineRefs o)+        mapM_+            ( \pat ->+                assertEqual+                    (name <> ": " <> show pat)+                    (Set.fromList [r | (path, r) <- entries, Query.matchPattern (Query.parsePattern pat) path])+                    (Query.matchOutline (Query.parsePattern pat) o)+            )+            patterns+        assertEqual+            (name <> ": first paths")+            (Map.fromListWith (\_ old -> old) [(r, path) | (path, r) <- entries])+            (Map.fromList [(r, path) | (path, r, _) <- Query.outlineFirstPaths o])+        assertEqual (name <> ": one first path per node") (Set.size (Query.outlineRefs o)) (length (Query.outlineFirstPaths o))+        let width path = sum (map Text.length path) + length path+            listed = Map.map (take 3 . map snd . Set.toAscList) (Map.fromListWith Set.union [(r, Set.singleton (width path, path)) | (path, r) <- entries])+        assertEqual (name <> ": the three shortest paths per node") listed (outlinePathsUncapped 3 o)+        assertEqual (name <> ": and the same, none capped, at the default cap") (Map.map (map (False,)) listed) (Map.map (map (\p -> (Query.listedCapped p, Query.listedSegments p))) (Query.outlinePaths 3 Query.pathCap o))++{- | The shape 'SreBox.PostgresPairPrereqs' relies on: a node named a second+time, same 'Ref', with one more dependency. The first occurrence is the one+walked; the second must still contribute what is new under it.+-}+outlineMergesALaterOccurrence :: IO ()+outlineMergesALaterOccurrence = do+    let extra = op "extra" nodeps $ \x -> x{ref = mkRef "leaf" ("extra" :: Text)}+        base = op "base" nodeps $ \x -> x{ref = mkRef "leaf" ("base" :: Text)}+        member ds = op "member" (deps ds) $ \x -> x{ref = mkRef "mid" ("member" :: Text)}+        root = op "root" (deps [member [base], member [base, extra]]) $ \x -> x{ref = mkRef "root" ()}+        cograph = runIdentity (expand root)+        (everything, _) = Query.resolveSelectors cograph [] []+        (selected, _) = Query.resolveSelectors cograph ["/root/member/extra"] []+    assertBool "the later occurrence's dependency is a node" (mkRef "leaf" ("extra" :: Text) `Set.member` everything)+    assertEqual "and is selected by its path" (Set.singleton (mkRef "leaf" ("extra" :: Text))) selected++{- | Sixty diamonds: 181 nodes, 2^60 paths to the bottom one. Listing them is+not an option, so everything below is only reachable if nothing lists them.+-}+outlineOnADiamondChain :: IO ()+outlineOnADiamondChain = do+    let cograph = runIdentity (expand (diamondChain 60))+        o = Query.outline cograph+        bottom = mkRef "diamond" ("bottom" :: Text)+        (everything, _) = Query.resolveSelectors cograph [] []+        (underTheFirstRight, _) = Query.resolveSelectors cograph ["/top/right/**"] []+        (byALongPath, _) = Query.resolveSelectors cograph [Text.concat (replicate 60 "/top/right") <> "/bottom"] []+        paths = Query.outlinePaths Query.pathLimit Query.pathCap o+        leftAllTheWay = concat (replicate 60 ["top", "left"]) <> ["bottom"]+    assertEqual "every node, once" 181 (Set.size everything)+    assertEqual "everything but the first top and its left" 179 (Set.size underTheFirstRight)+    assertEqual "a path that is the longest of 2^60 still selects its node" (Set.singleton bottom) byALongPath+    assertEqual "a path per node at least" 181 (Map.size (Map.filter (not . null) paths))+    assertEqual "and no more than the limit" Query.pathLimit (maximum (map length (Map.elems paths)))+    assertEqual+        "the shortest path to the bottom goes left all the way (uncapped reference)"+        (Just leftAllTheWay)+        (fmap head (Map.lookup bottom (outlinePathsUncapped Query.pathLimit o)))+    -- 547 characters, over the cap: listed as its last 255 characters'+    -- worth of segments, still sorted first by its full length.+    assertEqual+        "listed capped: its full length and the tail that fits the cap"+        (Just (Query.ListedPath 547 True (["left"] <> concat (replicate 27 ["top", "left"]) <> ["bottom"])))+        (fmap head (Map.lookup bottom paths))+    assertEqual "deduped rendering is one line per node" 181 (length (Query.renderAnnotated cograph Set.empty Set.empty True False))++------------------------------------------------------------------------------- -- resolveRewrittenSelectors (R4)  pkg :: Text -> Op@@ -182,7 +298,8 @@  onlyBatch :: Rewrite.Rewritten Extension -> IO Ref onlyBatch c =-    case Map.keys (Rewrite.computedMembers c) of+    -- the batch of packages, not the collected apt index node beside it+    case [r | (r, members) <- Map.toList (Rewrite.computedMembers c), pkgRef "curl" `Set.member` members] of         [r] -> pure r         rs -> assertFailure ("expected exactly one batch, got " <> show (length rs)) 
+ test/Test/ReadinessSpec.hs view
@@ -0,0 +1,180 @@+{-# LANGUAGE OverloadedStrings #-}++{- | Layer 0 coverage for "Salmon.Builtin.Nodes.Readiness": the wait node run+through 'Salmon.Actions.UpDown.upTree' with in-process probes (an @IO Bool@+that turns true after a while, never, or at once), the poll loop against a+fake clock, and the rendered probes. The only processes started are @true@+and @false@; no container, unit or VM.+-}+module Test.ReadinessSpec (tests) where++import Control.Exception (ErrorCall (..), fromException, throwIO)+import Data.IORef (IORef, atomicModifyIORef', newIORef, readIORef)+import Data.Maybe (isJust)+import Data.Text (Text)+import GHC.Clock (getMonotonicTimeNSec)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, assertEqual, testCase)++import Salmon.Actions.UpDown (Report (..))+import Salmon.Builtin.Extension (Extension (..), Op, deps, op)+import Salmon.Builtin.Nodes.Readiness+import Salmon.Op.Actions (Act (..))+import qualified Salmon.Op.Actions as Actions+import Salmon.Op.OpGraph (OpGraph (..))+import Salmon.Op.Ref (mkRef)++import Test.Harness (runUpCapturing)++tests :: TestTree+tests =+    testGroup+        "Salmon.Builtin.Nodes.Readiness"+        [ testCase "a probe that turns true after 2s: the node is Done, its dependant runs" turnsTrue+        , testCase "a probe never true with a 1s deadline: Failed, its dependant Blocked" neverTrue+        , testCase "a probe already true: the node is skipped and up never runs" alreadyTrue+        , testCase "a probe that throws counts as no" throwingIsNo+        , testCase "pollUntil asks at the interval until the budget, against a fake clock" pollBudget+        , testCase "pollUntil asks once with no budget" pollOnce+        , testCase "a command probe is its exit code" commandProbe+        , testCase "rendered probes and node text" rendered+        ]++-------------------------------------------------------------------------------++-- | A dependant that records whether it ran.+dependantOf :: IORef Bool -> Op -> Op+dependantOf ran w =+    op "user" (deps [w]) $ \x -> x{ref = mkRef "test-user" (), up = atomicModifyIORef' ran (const (True, ()))}++counting :: IORef Int -> IO Bool -> IO Bool+counting n probe = atomicModifyIORef' n (\k -> (k + 1, ())) >> probe++nowMs :: IO Int+nowMs = (\ns -> fromIntegral (ns `div` 1000000)) <$> getMonotonicTimeNSec++shorthands :: (Act Extension -> Bool) -> [Report Extension] -> [Text]+shorthands _ [] = []+shorthands p (r : rs) = here <> shorthands p rs+  where+    here = case r of+        Done a | p a -> ["Done " <> a.shorthand]+        Skip a | p a -> ["Skip " <> a.shorthand]+        Failed a _ | p a -> ["Failed " <> a.shorthand]+        Blocked a | p a -> ["Blocked " <> a.shorthand]+        _ -> []++outcomes :: [Report Extension] -> [Text]+outcomes = shorthands (const True)++-------------------------------------------------------------------------------++turnsTrue :: IO ()+turnsTrue = do+    t0 <- nowMs+    ran <- newIORef False+    let probe = (\t -> t - t0 >= 2000) <$> nowMs+        w = waitUntil (untilIO "svc" "the test clock passed 2s" probe 5)+    reports <- runUpCapturing (dependantOf ran w)+    t1 <- nowMs+    assertEqual "outcomes" ["Done wait-until", "Done user"] (outcomes reports)+    assertBool "the dependant ran" =<< readIORef ran+    assertBool ("waited about 2s, not the deadline: " <> show (t1 - t0) <> "ms") (t1 - t0 >= 2000 && t1 - t0 < 4500)++neverTrue :: IO ()+neverTrue = do+    ran <- newIORef False+    let w = waitUntil (untilIO "svc" "never" (pure False) 1)+    reports <- runUpCapturing (dependantOf ran w)+    assertEqual "outcomes" ["Failed wait-until", "Blocked user"] (outcomes reports)+    assertEqual "dependant did not run" False =<< readIORef ran+    let thrown = [e | Failed _ e <- reports]+    assertBool "the failure is NotReadyInTime" (all (\e -> isJust (fromException e :: Maybe NotReadyInTime)) thrown && not (null thrown))++alreadyTrue :: IO ()+alreadyTrue = do+    ran <- newIORef False+    asked <- newIORef (0 :: Int)+    let w = waitUntil (untilIO "svc" "always" (counting asked (pure True)) 5)+    reports <- runUpCapturing (dependantOf ran w)+    assertEqual "outcomes" ["Skip wait-until", "Done user"] (outcomes reports)+    assertEqual "asked once, by check" 1 =<< readIORef asked++throwingIsNo :: IO ()+throwingIsNo = do+    ok <- askOnce 1000 (throwIO (ErrorCall "boom"))+    assertEqual "askOnce" False ok+    let w = waitUntil (untilIO "svc" "throws" (throwIO (ErrorCall "boom")) 0)+    reports <- runUpCapturing w+    assertEqual "outcomes" ["Failed wait-until"] (outcomes reports)++-- | Time moves only when the loop sleeps: attempts at 0, 250, 500, 750, 1000.+pollBudget :: IO ()+pollBudget = do+    clock <- newIORef (0 :: Int)+    asked <- newIORef []+    let fake =+            Clock+                { clockNow = readIORef clock+                , clockSleep = \ms -> atomicModifyIORef' clock (\t -> (t + ms, ()))+                }+        probe = do+            t <- readIORef clock+            atomicModifyIORef' asked (\ts -> (t : ts, ()))+            pure False+    ok <- pollUntil fake 1000 250 probe+    assertEqual "gave up" False ok+    assertEqual "asked at each interval" [0, 250, 500, 750, 1000] . reverse =<< readIORef asked+    -- and a yes stops it at once+    atomicModifyIORef' clock (const (0, ()))+    yes <- pollUntil fake 1000 250 ((>= 500) <$> readIORef clock)+    assertEqual "answered" True yes+    assertEqual "stopped at the first yes" 500 =<< readIORef clock++pollOnce :: IO ()+pollOnce = do+    asked <- newIORef (0 :: Int)+    ok <- pollUntil systemClock 0 250 (counting asked (pure False))+    assertEqual "gave up" False ok+    assertEqual "asked exactly once" 1 =<< readIORef asked++commandProbe :: IO ()+commandProbe = do+    assertEqual "true" True =<< runProbe (ProbeCommand "true" [])+    assertEqual "false" False =<< runProbe (ProbeCommand "false" [])+    assertEqual "missing binary" False =<< runProbe (ProbeCommand "salmon-no-such-binary-for-readiness" [])++rendered :: IO ()+rendered = do+    assertEqual+        "pg_isready"+        (ProbeCommand "pg_isready" ["--host", "127.0.0.1", "--port", "5432", "--quiet"])+        (pgIsReady "127.0.0.1" 5432)+    assertEqual+        "podman exec"+        (ProbeCommand "podman" ["exec", "db", "pg_isready", "--quiet"])+        (podmanExec "db" "pg_isready" ["--quiet"])+    -- the Quadlet readiness notes render these; moving the type must not change them+    assertEqual "describe tcp" "tcp 127.0.0.1:8080 accepts" (describeProbe (ProbeTcp "127.0.0.1" 8080))+    assertEqual+        "describe command"+        "`pg_isready --host 127.0.0.1 --port 5432 --quiet` exits 0"+        (describeProbe (pgIsReady "127.0.0.1" 5432))+    let w = waitUntil (untilProbe "db-pg" (pgIsReady "127.0.0.1" 5432) 60)+    case w.node of+        Actions.Actions act -> do+            assertEqual "shorthand" "wait-until" act.shorthand+            assertEqual+                "help"+                ("waits until `pg_isready --host 127.0.0.1 --port 5432 --quiet` exits 0 (up to 60s)" :: Text)+                act.extension.help+            assertEqual "ref is the key alone" (mkRef "wait-until" ("db-pg" :: Text)) act.extension.ref+            assertEqual+                "notes"+                [ "asks: `pg_isready --host 127.0.0.1 --port 5432 --quiet` exits 0"+                , "every 250ms, for up to 60s, then fails"+                , "check asks once: no answer is Unknown, not Failure"+                , "down does nothing"+                ]+                act.extension.notes+        _ -> fail "waitUntil built an actionless node"
+ test/Test/RemoteOutputSpec.hs view
@@ -0,0 +1,302 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE OverloadedStrings #-}++{- | What a remote call says while it runs: the routed form of+"Salmon.Builtin.Nodes.Ssh"'s call and the self-call's 'Self.RemoteOutput'+("Salmon.Builtin.Nodes.Self").++Three kinds of case, and what none of them is. /Declared shape/: the calls+that existed before there was a choice declare the node they always did+(same 'Ref', notes, @ssh@ argv), and only 'Self.RemoteReports' changes the+remote command line. /The re-telling/, pure: 'Self.remoteReportLines' over+lines encoded here by the server's own "Salmon.Reporter.Tagged", so a change+of wire shape fails here. /A real process/: a @\/bin\/sh@ standing where+@ssh@ would be, writing those same lines and held open on a file, to show a+remote report is said about the calling node while the command still runs.++No case runs @ssh@: nothing here crosses an ssh hop, real or loopback.++The sink registry is process-wide and this suite runs its groups in parallel+in one process, so every assertion about what a sink saw filters by the+node's own 'Ref'.+-}+module Test.RemoteOutputSpec (tests) where++import Control.Concurrent (forkIO)+import Control.Concurrent.MVar (newEmptyMVar, putMVar, takeMVar, tryPutMVar)+import Control.Exception (ErrorCall (..), toException)+import Control.Monad (void, when)+import Data.Aeson (encode)+import qualified Data.ByteString as ByteString+import qualified Data.ByteString.Char8 as C8+import qualified Data.ByteString.Lazy as LByteString+import Data.Functor.Identity (runIdentity)+import Data.IORef (atomicModifyIORef', newIORef, readIORef)+import Data.Text (Text)+import qualified Data.Text as Text+import qualified Data.Text.Encoding as Text+import System.Directory (doesFileExist)+import System.Exit (ExitCode (..))+import System.FilePath ((</>))+import System.Process (proc)+import System.Process.ListLike (CmdSpec (..), cmdspec)+import System.Timeout (timeout)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, assertEqual, assertFailure, testCase)++import qualified Salmon.Actions.UpDown as UpDown+import qualified Salmon.Actions.Upkeep as Upkeep+import qualified Salmon.Builtin.CommandLine as CLI+import Salmon.Builtin.Extension (Extension (..), Op, Track', ignoreTrack, nodeps, op, opAct)+import qualified Salmon.Builtin.NodeLog as NodeLog+import Salmon.Builtin.Nodes.Binary (Binary, Command (..))+import qualified Salmon.Builtin.Nodes.Binary as Binary+import qualified Salmon.Builtin.Nodes.Self as Self+import qualified Salmon.Builtin.Nodes.Ssh as Ssh+import Salmon.Op.Actions (Act (..), Actions (..))+import Salmon.Op.OpGraph (OpGraph (..))+import Salmon.Op.Ref (Ref, mkRef, shortRef)+import Salmon.Op.Track (trackedGraph)+import Salmon.Reporter (ReporterM (..), silent)+import qualified Salmon.Reporter.Tagged as Tagged+import Test.Harness (withTempDir)++tests :: TestTree+tests =+    testGroup+        "remote calls: output while running"+        [ testGroup+            "declared shape"+            [ testCase "Ssh.call is the node it was, and a routed call is the same node" sshCallUnchanged+            , testCase "the ssh argv does not depend on the routing" sshArgv+            , testCase "callSelf is the node it was" selfCallUnchanged+            , testCase "callSelfAsSudoWith is the node it was" selfSudoCallUnchanged+            , testCase "following the remote's lines keeps the node; asking for its reports adds --json" selfCallOutputs+            , testCase "only up and down are asked for --json" selfCallJsonOnlyWhereTaken+            ]+        , testGroup+            "re-telling a remote salmon's reports"+            [ testCase "a report about a node names it by short ref and shorthand" tellsANodeReport+            , testCase "a failure carries its error, one line per line" tellsAFailure+            , testCase "a line a remote node said keeps its channel" tellsANodeLine+            , testCase "a wrapper is looked through" tellsThroughWrappers+            , testCase "a held action's output line is a line" tellsHeldOutput+            , testCase "what is not a report is said as it is" passesTheRest+            ]+        , testCase "a remote report is said about the calling node while the command runs" saidWhileRunning+        ]++-------------------------------------------------------------------------------++within :: Int -> IO a -> IO a+within secs act = do+    r <- timeout (secs * 1000000) act+    maybe (assertFailure ("timed out after " <> show secs <> "s")) pure r++actOf :: Op -> IO (Act Extension)+actOf o = maybe (assertFailure "the op has no node") pure (opAct o)++remote :: Ssh.Remote+remote = Ssh.Remote "deployer" "198.51.100.7"++self :: Self.RemoteSelf+self = Self.RemoteSelf (Self.Remote "deployer" "198.51.100.7") "/srv/salmon/self"++-- | The 'Ref' an ssh call has always had: the remote program, the remote,+-- its arguments and its standard input.+sshRunRef :: FilePath -> [Text] -> ByteString.ByteString -> Ref+sshRunRef path args input = mkRef "ssh-run" (show (path, remote, args, input))++selfCall :: Self.CallOpts -> CLI.BaseCommand -> Op+selfCall o base = trackedGraph (Self.callSelfOpts o silent ignoreTrack self ignoreTrack base ())++-- | @()@ as a directive on the wire.+unitDirective :: ByteString.ByteString+unitDirective = LByteString.toStrict (encode ())++sshCallUnchanged :: IO ()+sshCallUnchanged = do+    plain <- actOf (Ssh.call silent ignoreTrack ignoreTrack remote "uname" ["-a"] "")+    routed <- actOf (Ssh.callRouted Binary.streamed Ssh.noClientOpts silent ignoreTrack ignoreTrack remote "uname" ["-a"] "")+    assertEqual "the ref it always had" (sshRunRef "uname" ["-a"] "") plain.extension.ref+    assertEqual "notes" ["uname", "-a"] plain.extension.notes+    assertEqual "same ref when streamed" plain.extension.ref routed.extension.ref+    assertEqual "same help" plain.extension.help routed.extension.help+    assertEqual "same notes" plain.extension.notes routed.extension.notes+    assertEqual "same shorthand" plain.shorthand routed.shorthand++sshArgv :: IO ()+sshArgv =+    case cmdspec (prepare Ssh.sshRun (Ssh.Call "/srv/salmon/self" remote ["run", "up", "--json"] Ssh.noClientOpts)) of+        RawCommand bin args -> do+            assertEqual "" "ssh" bin+            assertEqual "" ["deployer@198.51.100.7", "/srv/salmon/self", "run", "up", "--json"] args+        ShellCommand s -> assertFailure ("a shell command: " <> s)++selfCallUnchanged :: IO ()+selfCallUnchanged = do+    old <- actOf (trackedGraph (Self.callSelf silent ignoreTrack self ignoreTrack CLI.Up ()))+    assertEqual "" (sshRunRef "/srv/salmon/self" ["run", "up"] unitDirective) old.extension.ref+    assertEqual "" ["/srv/salmon/self", "run", "up"] old.extension.notes+    viaOpts <- actOf (selfCall Self.defaultCallOpts CLI.Up)+    assertEqual "the default options are callSelf" old.extension.ref viaOpts.extension.ref++selfSudoCallUnchanged :: IO ()+selfSudoCallUnchanged = do+    let client = Ssh.ClientOpts (Just "/keys/client") (Just "/keys/known_hosts")+    old <- actOf (trackedGraph (Self.callSelfAsSudoWith client silent ignoreTrack self ignoreTrack CLI.Up ()))+    assertEqual "" (sshRunRef "sudo" ["/srv/salmon/self", "run", "up"] unitDirective) old.extension.ref+    assertEqual "" ["sudo", "/srv/salmon/self", "run", "up"] old.extension.notes+    plain <- actOf (trackedGraph (Self.callSelfAsSudo silent ignoreTrack self ignoreTrack CLI.Down ()))+    assertEqual "" (sshRunRef "sudo" ["/srv/salmon/self", "run", "down"] unitDirective) plain.extension.ref++selfCallOutputs :: IO ()+selfCallOutputs = do+    captured <- actOf (selfCall Self.defaultCallOpts CLI.Up)+    followed <- actOf (selfCall Self.defaultCallOpts{Self.callOutput = Self.RemoteLines} CLI.Up)+    reported <- actOf (selfCall Self.defaultCallOpts{Self.callOutput = Self.RemoteReports} CLI.Up)+    sudo <- actOf (selfCall Self.defaultCallOpts{Self.callOutput = Self.RemoteReports, Self.callAsSudo = True} CLI.Down)+    assertEqual "lines: the same node" captured.extension.ref followed.extension.ref+    assertEqual "lines: the same notes" captured.extension.notes followed.extension.notes+    assertEqual "reports: --json after the subcommand" ["/srv/salmon/self", "run", "up", "--json"] reported.extension.notes+    assertBool "reports: another node" (reported.extension.ref /= captured.extension.ref)+    assertEqual "reports under sudo" ["sudo", "/srv/salmon/self", "run", "down", "--json"] sudo.extension.notes++selfCallJsonOnlyWhereTaken :: IO ()+selfCallJsonOnlyWhereTaken = do+    let reports = Self.defaultCallOpts{Self.callOutput = Self.RemoteReports}+    tree <- actOf (selfCall reports CLI.Tree)+    dag <- actOf (selfCall reports CLI.DAG)+    assertEqual "" ["/srv/salmon/self", "run", "tree"] tree.extension.notes+    assertEqual "" ["/srv/salmon/self", "run", "dag"] dag.extension.notes++-------------------------------------------------------------------------------++-- | A node as the /remote/ salmon would report it.+remoteRef :: Ref+remoteRef = mkRef "remote-output-spec" ("remote-node" :: Text)++remoteAct :: Act Extension+remoteAct = case remoteOp.node of+    Actions act -> act+    Actionless -> error "fixture op is Actionless"+  where+    remoteOp :: Op+    remoteOp = op "file:/etc/motd" nodeps $ \ext -> ext{help = "a file", ref = remoteRef}++-- | One line of @run up --json@, as the remote salmon writes it.+wire :: Tagged.Tagged -> Text+wire = Text.decodeUtf8 . LByteString.toStrict . encode++short :: Text+short = "[" <> shortRef remoteRef <> "]"++tell :: Tagged.Tagged -> [(NodeLog.Channel, Text)]+tell = Self.remoteReportLines NodeLog.Stdout . wire++tellsANodeReport :: IO ()+tellsANodeReport = do+    assertEqual "" [(NodeLog.Message, "remote: done " <> short <> " file:/etc/motd")] (tell (Tagged.FromUpDown (UpDown.Done remoteAct)))+    assertEqual "" [(NodeLog.Message, "remote: skip " <> short <> " file:/etc/motd")] (tell (Tagged.FromUpDown (UpDown.Skip remoteAct)))+    assertEqual "" [(NodeLog.Message, "remote: blocked " <> short <> " file:/etc/motd")] (tell (Tagged.FromUpDown (UpDown.Blocked remoteAct)))++tellsAFailure :: IO ()+tellsAFailure =+    assertEqual+        ""+        [ (NodeLog.Message, "remote: failed " <> short <> " file:/etc/motd: command failed (exit 1)")+        , (NodeLog.Message, "remote:   stderr:")+        , (NodeLog.Message, "remote:   no such file")+        ]+        (tell (Tagged.FromUpDown (UpDown.Failed remoteAct (toException (ErrorCall "command failed (exit 1)\nstderr:\nno such file")))))++tellsANodeLine :: IO ()+tellsANodeLine = do+    assertEqual "" [(NodeLog.Stdout, "remote: " <> short <> " STEP 2/5: RUN make")] (tell (Tagged.FromNode (NodeLog.Line remoteRef NodeLog.Stdout "STEP 2/5: RUN make")))+    assertEqual "" [(NodeLog.Stderr, "remote: " <> short <> " warning: deprecated")] (tell (Tagged.FromNode (NodeLog.Line remoteRef NodeLog.Stderr "warning: deprecated")))+    assertEqual "" [(NodeLog.Message, "remote: " <> short <> " waiting for the lock")] (tell (Tagged.FromNode (NodeLog.Line remoteRef NodeLog.Message "waiting for the lock")))++tellsThroughWrappers :: IO ()+tellsThroughWrappers =+    assertEqual+        ""+        [(NodeLog.Message, "remote: done " <> short <> " file:/etc/motd")]+        (tell (Tagged.FromUpkeep (Upkeep.Acted (UpDown.Done remoteAct))))++tellsHeldOutput :: IO ()+tellsHeldOutput =+    assertEqual+        ""+        [(NodeLog.Stdout, "remote: " <> short <> " file:/etc/motd listening on 8080")]+        (tell (Tagged.FromUpkeep (Upkeep.Output remoteAct "listening on 8080")))++passesTheRest :: IO ()+passesTheRest = do+    assertEqual "prose on stdout" [(NodeLog.Stdout, "Reading package lists...")] (Self.remoteReportLines NodeLog.Stdout "Reading package lists...")+    assertEqual "an object that is no report" [(NodeLog.Stdout, "{\"a\":1}")] (Self.remoteReportLines NodeLog.Stdout "{\"a\":1}")+    assertEqual "a JSON value that is no object" [(NodeLog.Stdout, "[1,2]")] (Self.remoteReportLines NodeLog.Stdout "[1,2]")+    let report = wire (Tagged.FromUpDown (UpDown.Done remoteAct))+    assertEqual "a report on stderr is not read as one" [(NodeLog.Stderr, report)] (Self.remoteReportLines NodeLog.Stderr report)++-------------------------------------------------------------------------------++sh :: Command "sh" String+sh = Command $ \script -> proc "/bin/sh" ["-c", script]++shTrack :: Track' (Binary "sh")+shTrack = ignoreTrack++{- | The plumbing 'Ssh.callTapped' is made of, with @\/bin\/sh@ where @ssh@+would be: the command writes one remote report, waits on a file that does not+exist yet, then writes a second. The first must be heard, re-told and under+the calling node's ref, before the file is created.+-}+saidWhileRunning :: IO ()+saidWhileRunning = within 30 . withTempDir $ \dir -> do+    let caller = mkRef "remote-output-spec" ("caller" :: Text)+        gate = dir </> "gate"+        firstFile = dir </> "first"+        secondFile = dir </> "second"+        script =+            "cat '" <> firstFile <> "'; while [ ! -e '" <> gate <> "' ]; do sleep 0.05; done; cat '" <> secondFile <> "'; echo plain >&2"+    ByteString.writeFile firstFile (Text.encodeUtf8 (wire (Tagged.FromUpDown (UpDown.Eval remoteAct))) <> "\n")+    ByteString.writeFile secondFile (Text.encodeUtf8 (wire (Tagged.FromUpDown (UpDown.Done remoteAct))) <> "\n")+    heard <- newIORef []+    firstHeard <- newEmptyMVar+    stopped <- newIORef []+    let sink = ReporterM $ \line ->+            when (line.lineRef == caller) $ do+                atomicModifyIORef' heard (\ls -> (line : ls, ()))+                void (tryPutMVar firstHeard line)+        onReport rep = case rep of+            Binary.Requested _ (Binary.CommandStopped _ code out _) -> atomicModifyIORef' stopped (\xs -> ((code, out) : xs, ()))+            _ -> pure ()+        node :: Op+        node =+            Binary.withBinaryStdinTapped Binary.streamed Self.remoteReportLines shTrack sh script "" $ \run ->+                op "remote-call" nodeps $ \actions ->+                    actions{help = "stands in for a self-call", ref = caller, up = run (ReporterM onReport)}+    done <- newEmptyMVar+    NodeLog.withSink sink $ do+        _ <- forkIO (putMVar done =<< UpDown.upTree silent (pure . runIdentity) node)+        first <- takeMVar firstHeard+        assertEqual "the first remote report, re-told" (NodeLog.Line caller NodeLog.Message ("remote: eval " <> short <> " file:/etc/motd")) first+        -- the command cannot have ended: what lets it end does not exist yet+        opened <- doesFileExist gate+        assertBool "the gate was still shut when the first report was heard" (not opened)+        writeFile gate ""+        ok <- takeMVar done+        assertBool "the node came up" ok+    said <- reverse <$> readIORef heard+    assertEqual+        "every line, about the caller: reports re-told, stderr as it was"+        [ (NodeLog.Message, "remote: eval " <> short <> " file:/etc/motd")+        , (NodeLog.Message, "remote: done " <> short <> " file:/etc/motd")+        ]+        [(l.lineChannel, l.lineText) | l <- said, l.lineChannel /= NodeLog.Stderr]+    assertEqual "" [(NodeLog.Stderr, "plain")] [(l.lineChannel, l.lineText) | l <- said, l.lineChannel == NodeLog.Stderr]+    final <- readIORef stopped+    case final of+        [(ExitSuccess, out)] ->+            assertEqual "the final report keeps what the command wrote, not the re-telling" 2 (length (filter ("{" `C8.isPrefixOf`) (C8.lines out)))+        other -> assertFailure ("expected one successful stop, got " <> show (length other))
test/Test/ReportJsonSpec.hs view
@@ -27,6 +27,7 @@ import qualified Data.Text as Text import qualified Data.Text.Lazy as LText import qualified Data.Text.Lazy.Encoding as LText+import Data.Time (UTCTime (..), fromGregorian) import System.IO (hClose) import System.IO.Temp (withSystemTempFile) import Test.Tasty (TestTree, testGroup)@@ -37,6 +38,7 @@ import qualified Salmon.Actions.Serve as Serve import qualified Salmon.Actions.Serve.StatusSink as StatusSink import qualified Salmon.Actions.UpDown as UpDown+import qualified Salmon.Builtin.NodeLog as NodeLog import qualified Salmon.Actions.Upkeep as Upkeep import Salmon.Builtin.Extension (Extension (..), Op, nodeps, op) import Salmon.Op.Actions (Act (..), Actions (..))@@ -54,6 +56,7 @@         "Salmon.Reporter.Tagged"         [ testGroup "golden JSON, one per constructor" [testCase name (golden rep expected) | (name, rep, expected) <- goldens]         , testCase "Tagged adds a stream to the inner object and nothing else" taggedOrigin+        , testCase "taggedNode is the ref the encoding names, looking through a nested report" taggedNodeAgrees         , testCase "a Ref encodes as its short tag and its full text" refShape         , testCase "a Mode encodes as the word status prints" modeShape         , testCase "the text reporters print the same lines beside a JSON one as they do alone" textUnchangedBesideJson@@ -67,6 +70,10 @@ fixtureRef :: Ref fixtureRef = mkRef "report-json-spec" ("fixture" :: Text) +-- | When a held node's next maintenance window opens.+heldUntil :: UTCTime+heldUntil = UTCTime (fromGregorian 2026 9 27) 7200+ -- | A second ref, for the reports that mention two. otherRef :: Ref otherRef = mkRef "report-json-spec" ("other" :: Text)@@ -134,11 +141,40 @@     stripOrigin (Object o) = Object (KeyMap.delete "stream" o)     stripOrigin v = v +{- | What a per-node tail is keyed by ('Tagged.taggedNode') against what a+client reading the event would key it by: the top-level @ref@, else the+nested report's. Over every golden, so a new constructor is checked as soon+as it has one. -}+taggedNodeAgrees :: Assertion+taggedNodeAgrees =+    sequence_+        [ assertEqual ("the node of " <> name) (encodedRef (toJSON rep)) (unRef <$> Tagged.taggedNode rep)+        | (name, rep, _) <- goldens ++ [("tended", Tagged.FromServe (Serve.Tended (Upkeep.Output fixtureAct "x")), "")]+        ]+  where+    encodedRef v = case fullRef v of+        Just r -> Just r+        Nothing -> encodedRef =<< at "report" v+    fullRef v = at "ref" v >>= at "full" >>= asText+    at k (Object o) = KeyMap.lookup k o+    at _ _ = Nothing+    asText (String t) = Just t+    asText _ = Nothing+ -------------------------------------------------------------------------------  goldens :: [(String, Tagged.Tagged, Text)]-goldens = updownGoldens ++ upkeepGoldens ++ serveGoldens ++ followGoldens+goldens = updownGoldens ++ upkeepGoldens ++ serveGoldens ++ followGoldens ++ nodeGoldens +-- | What a node says while it works ("Salmon.Builtin.NodeLog"): one object+-- shape, one golden per channel.+nodeGoldens :: [(String, Tagged.Tagged, Text)]+nodeGoldens =+    [ ("NodeLog.Line stdout", Tagged.FromNode (NodeLog.Line fixtureRef NodeLog.Stdout "STEP 2/5: RUN make"), "{\"kind\":\"log\",\"ref\":" <> refJson <> ",\"channel\":\"stdout\",\"line\":\"STEP 2/5: RUN make\"}")+    , ("NodeLog.Line stderr", Tagged.FromNode (NodeLog.Line fixtureRef NodeLog.Stderr "warning: deprecated"), "{\"kind\":\"log\",\"ref\":" <> refJson <> ",\"channel\":\"stderr\",\"line\":\"warning: deprecated\"}")+    , ("NodeLog.Line message", Tagged.FromNode (NodeLog.Line fixtureRef NodeLog.Message "waiting for ssh"), "{\"kind\":\"log\",\"ref\":" <> refJson <> ",\"channel\":\"message\",\"line\":\"waiting for ssh\"}")+    ]+ updownGoldens :: [(String, Tagged.Tagged, Text)] updownGoldens =     [ ("UpDown.Skip", Tagged.FromUpDown (UpDown.Skip fixtureAct), "{\"kind\":\"skip\"," <> about <> "}")@@ -171,6 +207,8 @@     , ("Upkeep.Demoted", Tagged.FromUpkeep (Upkeep.Demoted fixtureAct otherRef), "{\"kind\":\"demoted\"," <> about <> ",\"dependency\":" <> otherRefJson <> "}")     , ("Upkeep.Parked", Tagged.FromUpkeep (Upkeep.Parked fixtureAct), "{\"kind\":\"parked\"," <> about <> "}")     , ("Upkeep.Reapplying", Tagged.FromUpkeep (Upkeep.Reapplying fixtureAct (Micros 1000)), "{\"kind\":\"reapplying\"," <> about <> ",\"delay_us\":1000}")+    , ("Upkeep.Held", Tagged.FromUpkeep (Upkeep.Held fixtureAct (Just heldUntil)), "{\"kind\":\"held\"," <> about <> ",\"until\":\"2026-09-27T02:00:00Z\"}")+    , ("Upkeep.Held (no window ever opens)", Tagged.FromUpkeep (Upkeep.Held fixtureAct Nothing), "{\"kind\":\"held\"," <> about <> ",\"until\":null}")     , ("Upkeep.Paused", Tagged.FromUpkeep (Upkeep.Paused fixtureAct), "{\"kind\":\"paused\"," <> about <> "}")     , ("Upkeep.Resumed", Tagged.FromUpkeep (Upkeep.Resumed fixtureAct), "{\"kind\":\"resumed\"," <> about <> "}")     , ("Upkeep.GaveUp", Tagged.FromUpkeep (Upkeep.GaveUp fixtureAct 5), "{\"kind\":\"gave-up\"," <> about <> ",\"failures\":5}")@@ -233,6 +271,13 @@         , Tagged.FromServe (Serve.StatusReport Serve.Interactive [(fixtureRef, tendedState), (otherRef, untendedState)] paths)         , "{\"kind\":\"status\",\"mode\":\"interactive\",\"nodes\":[" <> tendedJson <> "," <> untendedJson <> "]}"         )+    ,+        ( "Serve.StatusReport (held by a maintenance window)"+        , Tagged.FromServe (Serve.StatusReport Serve.Interactive [(otherRef, heldState)] Map.empty)+        , "{\"kind\":\"status\",\"mode\":\"interactive\",\"nodes\":[{\"ref\":"+            <> otherRefJson+            <> ",\"shorthand\":\"other-node\",\"help\":\"the other declaration\",\"direction\":\"up\",\"convergence\":\"pending\",\"status\":null,\"paths\":[],\"held_until\":\"2026-09-27T02:00:00Z\"}]}"+        )     , ("Serve.StatusReport (following)", Tagged.FromServe (Serve.StatusReport Serve.Following [] Map.empty), "{\"kind\":\"status\",\"mode\":\"following\",\"nodes\":[]}")     , ("Serve.StatusReport (replay)", Tagged.FromServe (Serve.StatusReport Serve.Replay [] Map.empty), "{\"kind\":\"status\",\"mode\":\"replay\",\"nodes\":[]}")     ,@@ -284,6 +329,7 @@                         , Status.statusEpoch = 2                         , Status.statusOutput = Status.pushRing "second line" (Status.pushRing "first line" Status.emptyRing)                         }+            , Serve.nodeHeld = Nothing             }     untendedState =         Serve.NodeState@@ -292,7 +338,9 @@             , Serve.nodeDirection = Status.TurnDown             , Serve.nodeConvergence = Serve.Stale             , Serve.nodeStatus = Nothing+            , Serve.nodeHeld = Nothing             }+    heldState = untendedState{Serve.nodeDirection = Status.TurnUp, Serve.nodeConvergence = Serve.Pending, Serve.nodeHeld = Just (Just heldUntil)}     tendedJson =         "{\"ref\":"             <> refJson@@ -344,6 +392,9 @@     assertEqual "updown" (Just (String "updown")) (originOf (Tagged.FromUpDown (UpDown.Done fixtureAct)))     assertEqual "upkeep" (Just (String "upkeep")) (originOf (Tagged.FromUpkeep (Upkeep.Holding 1)))     assertEqual "follow" (Just (String "follow")) (originOf (Tagged.FromFollow (Follow.Backoff 1 1)))+    -- a node's own lines are filed with a held action's, so one tail reads both+    assertEqual "node log" (Just (String "output")) (originOf (Tagged.FromNode (NodeLog.Line fixtureRef NodeLog.Message "hi")))+    assertEqual "held action" (Just (String "output")) (originOf (Tagged.FromUpkeep (Upkeep.Output fixtureAct "hi")))     -- the inner object is carried whole: removing the stream gives it back     let inner = toJSON (UpDown.Done fixtureAct)     case toJSON (Tagged.FromUpDown (UpDown.Done fixtureAct)) of@@ -391,7 +442,7 @@     let serveText ref = ReporterM $ \rep -> modifyIORef' ref (++ Serve.renderReport rep)         updownText ref = ReporterM $ \rep -> modifyIORef' ref (++ [Text.pack (show rep)])         jsonR = ReporterM $ \tagged -> modifyIORef' jsonSeen (++ [encode tagged])-        composed = reportBoth (Tagged.reportTexts (serveText besideServe) (updownText besideUpdown) silent silent) jsonR+        composed = reportBoth (Tagged.reportTexts (serveText besideServe) (updownText besideUpdown) silent silent silent) jsonR         serveReports = [Serve.Started, Serve.ConvergeStart 0 2, Serve.Tended (Upkeep.Wedged fixtureAct (Micros 5)), Serve.ConvergeStop True 0]         updownReports = [UpDown.Eval fixtureAct, UpDown.Done fixtureAct, UpDown.Failed otherAct (toException boom)]     mapM_ (runReporter (serveText aloneServe)) serveReports@@ -498,6 +549,7 @@     Upkeep.Demoted{} -> ()     Upkeep.Parked{} -> ()     Upkeep.Reapplying{} -> ()+    Upkeep.Held{} -> ()     Upkeep.Paused{} -> ()     Upkeep.Resumed{} -> ()     Upkeep.GaveUp{} -> ()
test/Test/RewriteSpec.hs view
@@ -11,6 +11,7 @@ -} module Test.RewriteSpec (tests) where +import qualified Data.List.NonEmpty as NEList import qualified Data.Map.Strict as Map import qualified Data.Set as Set import Data.Set (Set)@@ -39,6 +40,9 @@         , testCase "an ignored node is left out of the batch" ignoredIsLeftAlone         , testCase "a batch stands in for its members, other nodes for themselves" membership         , testCase "no packages means no rewrite at all" noPackagesNoOp+        , testCase "a deb node depends on an apt index node for its package" debDependsOnIndex+        , testCase "the index nodes of wanted packages become one, under the install batch" indexBatched+        , testCase "index nodes are left as declared on the way down" indexNotBatchedDown         ]  -------------------------------------------------------------------------------@@ -62,10 +66,22 @@ shorthands c = [act.shorthand | act <- Map.elems (Dag.dagNodes (Rewrite.computedDag c))]  -- | Only rewrites record membership, so these are exactly the nodes a--- rewrite introduced.+-- rewrite introduced -- less the collected apt index node ('indexBatches'),+-- which rides along with every install batch and has tests of its own. batchRefs :: Rewrite.Rewritten Extension -> [Ref]-batchRefs = Map.keys . Rewrite.computedMembers+batchRefs c = filter (`notElem` indexBatches c) (Map.keys (Rewrite.computedMembers c)) +-- | The introduced nodes that stand in for apt index nodes.+indexBatches :: Rewrite.Rewritten Extension -> [Ref]+indexBatches c =+    [ r+    | r <- Map.keys (Rewrite.computedMembers c)+    , fmap (\act -> act.shorthand) (Dag.representativeOf (Rewrite.computedDag c) r) == Just "apt-index"+    ]++indexRef :: Text -> Ref+indexRef name = mkRef "debian-apt-index" [name]+ -- | The one batch this graph produced; fails the test rather than throwing -- if a rewrite produced none or several. onlyBatch :: Rewrite.Rewritten Extension -> IO Ref@@ -117,15 +133,20 @@ -} splitByDirection :: IO () splitByDirection = do-    let desired = Set.fromList [pkgRef "curl", mkRef "root" ()]+    let desired = Set.fromList [pkgRef "curl", indexRef "curl", mkRef "root" ()]         c = computeWith (Phase desired Set.empty) root3     assertEqual "two batches" 2 (length (batchRefs c))     let [(installRef, _)] = [(r, m) | (r, m) <- Map.toList (Rewrite.computedMembers c), m == Set.singleton (pkgRef "curl")]         [(removeRef, _)] = [(r, m) | (r, m) <- Map.toList (Rewrite.computedMembers c), m == Set.fromList [pkgRef "git", pkgRef "jq"]]+    [indexBatch] <- pure (indexBatches c)     assertEqual-        "the install batch waits on the removal batch"-        [removeRef]-        (Dag.dependenciesOf (Rewrite.computedDag c) installRef)+        "the install batch waits on the removal batch, and on the index of what it installs"+        (Set.fromList [removeRef, indexBatch])+        (Set.fromList (Dag.dependenciesOf (Rewrite.computedDag c) installRef))+    assertEqual+        "that index asks after the wanted package only"+        (Set.singleton (indexRef "curl"))+        (Rewrite.membersOf c indexBatch)  {- | Two declarations disagree about @curl@ — one is being retracted, the other still wants it. The ledger already answered that by union, and the@@ -184,6 +205,57 @@         "a node no rewrite touched stands in for itself"         (Set.singleton (mkRef "root" ()))         (Rewrite.membersOf c (mkRef "root" ()))++debDependsOnIndex :: IO ()+debDependsOnIndex = do+    let dag = Dag.foldDag Dag.sameRepresentative (evalDeps (pkg "curl"))+    assertEqual+        "unrewritten, deb curl waits on the index for curl"+        [indexRef "curl"]+        (Dag.dependenciesOf dag (pkgRef "curl"))+    assertEqual+        "and debs waits on one index node for its whole set"+        1+        ( length+            ( filter+                (== "apt-index")+                [ act.shorthand+                | act <-+                    Map.elems+                        ( Dag.dagNodes+                            ( Dag.foldDag+                                Dag.sameRepresentative+                                (evalDeps (Debian.debs (Debian.Package "curl" NEList.:| [Debian.Package "git", Debian.Package "curl"])))+                            )+                        )+                ]+            )+        )++indexBatched :: IO ()+indexBatched = do+    let c = computeWith (Phase (allRefs root3) Set.empty) root3+    assertEqual "three index nodes became one" 1 (length (filter (== "apt-index") (shorthands c)))+    [indexBatch] <- pure (indexBatches c)+    batch <- onlyBatch c+    assertEqual+        "standing in for all three"+        (Set.fromList (map indexRef ["curl", "git", "jq"]))+        (Rewrite.membersOf c indexBatch)+    assertEqual+        "one refresh, before the one install"+        [indexBatch]+        (Dag.dependenciesOf (Rewrite.computedDag c) batch)+    assertEqual+        "and it names every package it asks after"+        (Just ["curl", "git", "jq"])+        (fmap (\act -> act.extension.notes) (Dag.representativeOf (Rewrite.computedDag c) indexBatch))++indexNotBatchedDown :: IO ()+indexNotBatchedDown = do+    let c = computeWith (Phase Set.empty Set.empty) root3+    assertEqual "no index batch" [] (indexBatches c)+    assertEqual "the three declared index nodes survive" 3 (length (filter (== "apt-index") (shorthands c)))  noPackagesNoOp :: IO () noPackagesNoOp = do
+ test/Test/RsyncSpec.hs view
@@ -0,0 +1,583 @@+{-# LANGUAGE OverloadedStrings #-}++{- | Coverage for "Salmon.Builtin.Nodes.Rsync", written with 'receiveDir'.++Layer 0, always run: the argv of every command, the verdict 'receiveDir''s+check draws from @rsync --dry-run --itemize-changes@, the shape of the node,+and that 'sendFile', 'sendDir' and 'receiveFile' render what they rendered+before 'receiveDir' existed. The pure parts carry QuickCheck properties.++Two groups run a real @rsync@:++* "a real rsync, local paths" runs the module's own flags between two local+  directories, skipped loudly without @rsync@. It is what ties the parser to+  what rsync really prints and shows that @--times@ makes a second probe come+  back empty. No ssh is involved, so it says nothing about the remote half.+* "the node, end to end" runs the node itself, and is __opt-in__: it needs an+  @ssh@ on @PATH@ that runs the command locally instead of connecting, which+  this suite will not install for itself (@PATH@ is process-wide and other+  groups, running in parallel, use the real @ssh@). To run it, put this in a+  directory as an executable named @ssh@:++  > #!/bin/sh+  > while [ $# -gt 0 ]; do case "$1" in -l|-i|-o) shift 2;; -*) shift;; *) shift; break;; esac; done+  > exec "$@"++  then run the suite with that directory first on @PATH@ and+  @SALMON_TEST_RSYNC_SSH_SHIM=1@, selecting this group (@-p Rsync@) so that+  nothing else sees the shim.++__No real ssh connection is made by anything here.__ The itemized lines in+the Layer 0 cases were copied from rsync 3.2.7's output.+-}+module Test.RsyncSpec (tests) where++import Control.Monad (unless)+import qualified Data.ByteString.Char8 as C8+import Data.Functor.Identity (runIdentity)+import Data.List (isPrefixOf, (\\))+import Data.Text (Text)+import qualified Data.Text as Text+import qualified Data.Text.Encoding as Text+import System.Directory (createDirectoryIfMissing, doesDirectoryExist)+import System.Environment (lookupEnv)+import System.Exit (ExitCode (..))+import System.FilePath ((</>))+import System.IO (hPutStrLn, stderr)+import System.Process (readProcessWithExitCode)+import System.Process.ListLike (CmdSpec (..), CreateProcess (..))+import Test.QuickCheck (Gen, Property, choose, counterexample, elements, forAll, listOf, listOf1, oneof, shuffle, vectorOf, (.&&.), (=/=), (===), (==>))+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, assertEqual, assertFailure, testCase)+import Test.Tasty.QuickCheck (testProperty)++import Salmon.Actions.Query (pathedNodes)+import Salmon.Actions.UpDown (CheckResult (..))+import Salmon.Builtin.Extension+import Salmon.Builtin.Nodes.Binary (Command (..))+import qualified Salmon.Builtin.Nodes.Filesystem as FS+import Salmon.Builtin.Nodes.Rsync+import qualified Salmon.Builtin.Nodes.Ssh as Ssh+import Salmon.Op.Actions (Act (..))+import Salmon.Op.Eval (expand)+import Salmon.Op.Ref (Ref, mkRef)+import Salmon.Op.Track (Track (..))+import Salmon.Reporter (silent)++import Test.Harness (requireExecutable, runUp, withTempDir)++tests :: TestTree+tests =+    testGroup+        "Salmon.Builtin.Nodes.Rsync"+        [ commandTests+        , verdictTests+        , nodeTests+        , unchangedTests+        , testGroup+            "properties"+            [ testProperty "a fetch names the remote directory, then the local one, last" prop_receiveOperands+            , testProperty "the probe is the fetch plus the two flags that make it a question" prop_probeIsFetchPlusFlags+            , testProperty "ssh options travel through --rsh, and only when there are some" prop_rshOnlyWithOptions+            , testProperty "no command carries --delete" prop_neverDeletes+            , testProperty "send-file, receive-file and send-dir render what they did" prop_olderCommandsUnchanged+            , testProperty "an item rsync would create or update is pending" prop_changedItemsArePending+            , testProperty "an item with a retouched attribute is pending" prop_retouchedItemsArePending+            , testProperty "an item with no attribute named is unchanged" prop_untouchedItemsAreUnchanged+            , testProperty "only what is recognised is harmless" prop_onlyRecognisedIsHarmless+            , testProperty "a failed rsync is Unknown whatever it printed" prop_failedProbeIsUnknown+            , testProperty "in place exactly when nothing is pending, and the count is the reason" prop_verdictCountsPending+            , testProperty "the verdict ignores order and harmless lines" prop_verdictIgnoresNoiseAndOrder+            , testProperty "the reason names no path" prop_reasonNamesNoPath+            , testProperty "the ref is the four coordinates, not the ssh options" prop_refIgnoresOptions+            , testProperty "a different coordinate is a different ref" prop_refSeparatesCoordinates+            , testProperty "the ref is not the one of a node going the other way" prop_refDistinctFromSiblings+            , testProperty "the older nodes keep their refs and help" prop_olderNodesUnchanged+            ]+        , realRsyncTests+        , endToEndTests+        ]++-------------------------------------------------------------------------------+-- Fixtures.++remote :: Remote+remote = Remote "salmon" "1.2.3.4"++opts :: Ssh.ClientOpts+opts = Ssh.noClientOpts{Ssh.optIdentity = Just "/w/ssh/client", Ssh.optKnownHosts = Just "/w/ssh/known_hosts"}++rshValue :: String+rshValue = "ssh -i /w/ssh/client -o IdentitiesOnly=yes -o UserKnownHostsFile=/w/ssh/known_hosts -o StrictHostKeyChecking=accept-new"++argv :: RsyncCommand -> [String]+argv c = case cmdspec (prepare rsyncRun c) of+    RawCommand "rsync" args -> args+    other -> error ("not an rsync argv: " <> show other)++mkdirTrack :: Track' FS.Directory+mkdirTrack = Track FS.dir++fetch :: Ssh.ClientOpts -> Remote -> FilePath -> FilePath -> Op+fetch o rem src dst = receiveDirWith o silent ignoreTrack mkdirTrack rem src (FS.Directory dst)++rootRef :: Op -> Maybe Ref+rootRef o = (\act -> act.extension.ref) <$> opAct o++rootHelp :: Op -> Maybe Text+rootHelp o = (\act -> act.extension.help) <$> opAct o++rootCheck :: Op -> IO CheckResult+rootCheck o = maybe (fail "the op has no node") (\act -> act.extension.check) (opAct o)++helps :: Op -> [Text]+helps o = [h | (_, _, h) <- pathedNodes (runIdentity (expand o))]++-------------------------------------------------------------------------------+-- Unit: commands.++commandTests :: TestTree+commandTests =+    testGroup+        "commands"+        [ testCase "a directory fetch is recursive, follows links and keeps times" $+            assertEqual+                ""+                ["--copy-links", "--recursive", "--times", "salmon@1.2.3.4:/srv/results", "/var/in"]+                (argv (ReceiveDir remote "/srv/results" "/var/in" Ssh.noClientOpts))+        , testCase "a directory fetch carries ssh options through --rsh" $+            assertEqual+                ""+                ["--copy-links", "--recursive", "--times", "--rsh", rshValue, "salmon@1.2.3.4:/srv/results", "/var/in"]+                (argv (ReceiveDir remote "/srv/results" "/var/in" opts))+        , testCase "the probe is the same transfer, dry and itemized" $+            assertEqual+                ""+                ["--copy-links", "--recursive", "--times", "--dry-run", "--itemize-changes", "--rsh", rshValue, "salmon@1.2.3.4:/srv/results", "/var/in"]+                (argv (ProbeReceiveDir remote "/srv/results" "/var/in" opts))+        , testCase "a trailing slash on the remote path is passed as written" $+            assertEqual+                "it is rsync's way of saying 'the contents of'"+                ["salmon@1.2.3.4:/srv/results/", "/var/in"]+                (drop 3 (argv (ReceiveDir remote "/srv/results/" "/var/in" Ssh.noClientOpts)))+        ]++-------------------------------------------------------------------------------+-- Unit: the verdict.++verdictTests :: TestTree+verdictTests =+    testGroup+        "the check's verdict"+        [ testCase "nothing printed: the copy is in place" $+            assertEqual "" Success (interpretProbe ExitSuccess "")+        , testCase "a first fetch: every item is to be created" $+            assertEqual+                ""+                (Failure "4 item(s) differ from the remote directory")+                (interpretProbe ExitSuccess "cd+++++++++ src/\n>f+++++++++ src/a\ncd+++++++++ src/sub/\n>f+++++++++ src/sub/b\n")+        , testCase "one file grew on the remote" $+            assertEqual+                ""+                (Failure "1 item(s) differ from the remote directory")+                (interpretProbe ExitSuccess ">f.s....... src/a\n")+        , testCase "a directory whose time alone differs still differs" $+            assertEqual+                ""+                (Failure "1 item(s) differ from the remote directory")+                (interpretProbe ExitSuccess ".d..t...... src/\n")+        , testCase "a non-regular file rsync never copies does not keep the node unsatisfied" $+            assertEqual "" Success (interpretProbe ExitSuccess "skipping non-regular file \"src/fifo\"\n")+        , testCase "items reported as unchanged are not differences" $+            assertEqual+                "what a doubled --itemize-changes prints"+                Success+                (interpretProbe ExitSuccess ".d          src/\n.d          src/sub/\n.f          src/sub/b\n")+        , testCase "a missing local directory is a difference" $+            assertEqual+                "an unrecognised line is never read as harmless"+                (Failure "2 item(s) differ from the remote directory")+                (interpretProbe ExitSuccess "created directory /var/in\ncd+++++++++ src/\n")+        , testCase "a deletion notice is a difference" $+            assertEqual "" Pending (classifyProbeLine "*deleting   src/gone")+        , testCase "an unreachable remote could not be looked at" $+            assertEqual "" Unknown (interpretProbe (ExitFailure 255) "")+        , testCase "a partial comparison is not a verdict" $+            assertEqual+                "exit 23, as with a dangling symlink under --copy-links"+                Unknown+                (interpretProbe (ExitFailure 23) ">f+++++++++ src/a\n")+        , testCase "a path that happens to start with a dot is not an unchanged item" $+            assertEqual "" Pending (classifyProbeLine ".hidden")+        ]++-------------------------------------------------------------------------------+-- Unit: the node.++nodeTests :: TestTree+nodeTests =+    testGroup+        "the receive-dir node"+        [ testCase "help says what is copied, from whom, and where" $+            assertEqual+                ""+                (Just "copies /srv/results from salmon@1.2.3.4 under /var/in over rsync")+                (rootHelp node)+        , testCase "the ref is keyed on both paths and the remote" $+            assertEqual+                ""+                (Just (mkRef "rsync-receivedir" ("/srv/results" :: FilePath, "/var/in" :: FilePath, "salmon" :: Text, "1.2.3.4" :: Text)))+                (rootRef node)+        , testCase "the local directory is made first, by the caller's track" $+            assertEqual+                ""+                ["copies /srv/results from salmon@1.2.3.4 under /var/in over rsync", "ensures /var/in exists, including subdirs"]+                (helps node)+        , testCase "receiveDir is receiveDirWith without options" $+            assertEqual+                ""+                (rootRef node, rootHelp node, helps node)+                (let n = receiveDir silent ignoreTrack mkdirTrack remote "/srv/results" (FS.Directory "/var/in") in (rootRef n, rootHelp n, helps n))+        ]+  where+    node = fetch Ssh.noClientOpts remote "/srv/results" "/var/in"++-------------------------------------------------------------------------------+-- Unit: the declarations that existed before render the same.++unchangedTests :: TestTree+unchangedTests =+    testGroup+        "declarations older than receive-dir"+        [ testCase "sendDir: ref, help, and still no check" $ do+            let n = sendDir silent ignoreTrack mkdirTrack (FS.Directory "/local/files") remote "/home/salmon/files"+            assertEqual "" (Just (mkRef "rsync-senddir" ("/local/files" :: FilePath, "salmon" :: Text, "1.2.3.4" :: Text))) (rootRef n)+            assertEqual "" (Just "copies /local/files to /home/salmon/files over rsync") (rootHelp n)+            verdict <- rootCheck n+            assertEqual "adding one would change run up for every caller" Immaterial verdict+        , testCase "receiveFile: ref, help, and still no check" $ do+            let n = receiveFile silent ignoreTrack mkdirTrack remote "/srv/dump.sql" "/var/in/dump.sql"+            assertEqual "" (Just (mkRef "rsync-receivefile" ("/srv/dump.sql" :: FilePath, "/var/in/dump.sql" :: FilePath, "salmon" :: Text, "1.2.3.4" :: Text))) (rootRef n)+            assertEqual "" (Just "copies /srv/dump.sql from salmon@1.2.3.4 over rsync") (rootHelp n)+            verdict <- rootCheck n+            assertEqual "" Immaterial verdict+        , testCase "send-dir argv" $+            assertEqual+                ""+                ["--copy-links", "--recursive", "--rsh", rshValue, "/local/files", "salmon@1.2.3.4:/home/salmon/files"]+                (argv (SendDir "/local/files" remote "/home/salmon/files" opts))+        , testCase "receive-file argv" $+            assertEqual+                ""+                ["--copy-links", "salmon@1.2.3.4:/srv/dump.sql", "/var/in/dump.sql"]+                (argv (ReceiveFile remote "/srv/dump.sql" "/var/in/dump.sql" Ssh.noClientOpts))+        , testCase "send-file argv" $+            assertEqual+                ""+                ["--copy-links", "/local/bin", "salmon@1.2.3.4:/home/salmon/bin"]+                (argv (SendFile "/local/bin" remote "/home/salmon/bin" Ssh.noClientOpts))+        ]++-------------------------------------------------------------------------------+-- Generators.++genName :: Gen String+genName = listOf1 (elements (['a' .. 'z'] <> ['0' .. '9'] <> "._-"))++genPath :: Gen FilePath+genPath = do+    parts <- listOf1 genName+    slash <- elements ["", "/"]+    pure (concatMap ('/' :) (take 4 parts) <> slash)++genRemote :: Gen Remote+genRemote = Remote <$> (Text.pack <$> genName) <*> (Text.pack <$> genName)++genOpts :: Gen Ssh.ClientOpts+genOpts = do+    identity <- oneof [pure Nothing, Just <$> genPath]+    hosts <- oneof [pure Nothing, Just <$> genPath]+    pure Ssh.noClientOpts{Ssh.optIdentity = identity, Ssh.optKnownHosts = hosts}++-- | A remote, a path on it, a local path, and ssh options.+genCoordinates :: Gen (Remote, FilePath, FilePath, Ssh.ClientOpts)+genCoordinates = (,,,) <$> genRemote <*> genPath <*> genPath <*> genOpts++-- | The path part of an itemized line: anything rsync could print on one line.+genItemPath :: Gen String+genItemPath = listOf1 (elements (['a' .. 'z'] <> "/. _-+<>*"))++-- | An item rsync would transfer or create: any update kind but @.@.+genChangedItem :: Gen Text+genChangedItem = do+    update <- elements "<>ch"+    kind <- elements "fdLDS"+    attrs <- vectorOf 9 (elements ".+cstTpoguax? ")+    path <- genItemPath+    pure (Text.pack (update : kind : attrs <> " " <> path))++-- | An item not transferred, with at least one attribute to set.+genRetouchedItem :: Gen Text+genRetouchedItem = do+    kind <- elements "fdLDS"+    before <- choose (0, 8)+    lead <- vectorOf before (elements ". ")+    named <- elements "cstTpoguax+?"+    trail <- vectorOf (8 - before) (elements ". cstTpoguax")+    path <- genItemPath+    pure (Text.pack ('.' : kind : lead <> [named] <> trail <> " " <> path))++-- | An item rsync looked at and would leave alone.+genUntouchedItem :: Gen Text+genUntouchedItem = do+    kind <- elements "fdLDS"+    attrs <- vectorOf 9 (elements ". ")+    path <- genItemPath+    pure (Text.pack ('.' : kind : attrs <> " " <> path))++genNoise :: Gen Text+genNoise =+    oneof+        [ pure ""+        , Text.pack <$> listOf (elements " \t")+        , (\p -> "skipping non-regular file \"" <> Text.pack p <> "\"") <$> genItemPath+        , (\p -> "skipping directory " <> Text.pack p) <$> genItemPath+        ]++genPending :: Gen Text+genPending = oneof [genChangedItem, genRetouchedItem, ("*deleting   " <>) . Text.pack <$> genItemPath]++genHarmless :: Gen Text+genHarmless = oneof [genNoise, genUntouchedItem]++render :: [Text] -> C8.ByteString+render = Text.encodeUtf8 . Text.unlines++-- | Any one line: printable characters, a few of them from the itemized alphabet.+genAnyLine :: Gen Text+genAnyLine = Text.pack <$> listOf (elements (['a' .. 'z'] <> " .<>ch*fdLDS+st/\"-"))++-------------------------------------------------------------------------------+-- Properties: commands.++prop_receiveOperands :: Property+prop_receiveOperands = forAll genCoordinates $ \(rem, src, dst, o) ->+    let expected = [Text.unpack rem.remoteUser <> "@" <> Text.unpack rem.remoteHost <> ":" <> src, dst]+        lastTwo xs = drop (length xs - 2) xs+     in lastTwo (argv (ReceiveDir rem src dst o)) === expected+            .&&. lastTwo (argv (ProbeReceiveDir rem src dst o)) === expected++prop_probeIsFetchPlusFlags :: Property+prop_probeIsFetchPlusFlags = forAll genCoordinates $ \(rem, src, dst, o) ->+    let fetchArgv = argv (ReceiveDir rem src dst o)+        probeArgv = argv (ProbeReceiveDir rem src dst o)+     in filter (`notElem` probeFlags) probeArgv === fetchArgv+            .&&. (probeArgv \\ fetchArgv) === ["--dry-run", "--itemize-changes"]+            .&&. counterexample "the fetch itself is not a dry run" (all (`notElem` fetchArgv) probeFlags)+            .&&. counterexample "both keep times, or the second probe is never empty" ("--times" `elem` fetchArgv && "--times" `elem` probeArgv)++prop_rshOnlyWithOptions :: Property+prop_rshOnlyWithOptions = forAll genCoordinates $ \(rem, src, dst, o) ->+    let args = argv (ReceiveDir rem src dst o)+        sshArgs = Ssh.clientArgs o+     in if null sshArgs+            then counterexample (show args) ("--rsh" `notElem` args)+            else dropWhile (/= "--rsh") args === ["--rsh", unwords ("ssh" : sshArgs)] <> drop (length args - 2) args++prop_neverDeletes :: Property+prop_neverDeletes = forAll genCoordinates $ \(rem, src, dst, o) ->+    let flagsOf c = filter ("--" `isPrefixOf`) (takeWhile (/= "--rsh") (argv c))+        all5 = [SendFile src rem dst o, ReceiveFile rem src dst o, SendDir src rem dst o, ReceiveDir rem src dst o, ProbeReceiveDir rem src dst o]+     in counterexample (show (map flagsOf all5)) (all (not . any ("--del" `isPrefixOf`) . flagsOf) all5)++{- | The three commands that predate 'ReceiveDir', against their rendering+spelled out again here.+-}+prop_olderCommandsUnchanged :: Property+prop_olderCommandsUnchanged = forAll genCoordinates $ \(rem, a, b, o) ->+    let rsh = case Ssh.clientArgs o of [] -> []; args -> ["--rsh", unwords ("ssh" : args)]+        at p = Text.unpack rem.remoteUser <> "@" <> Text.unpack rem.remoteHost <> ":" <> p+     in argv (SendFile a rem b o) === (["--copy-links"] <> rsh <> [a, at b])+            .&&. argv (ReceiveFile rem a b o) === (["--copy-links"] <> rsh <> [at a, b])+            .&&. argv (SendDir a rem b o) === (["--copy-links", "--recursive"] <> rsh <> [a, at b])++-------------------------------------------------------------------------------+-- Properties: the verdict.++prop_changedItemsArePending :: Property+prop_changedItemsArePending = forAll genChangedItem $ \l -> classifyProbeLine l === Pending++prop_retouchedItemsArePending :: Property+prop_retouchedItemsArePending = forAll genRetouchedItem $ \l -> classifyProbeLine l === Pending++prop_untouchedItemsAreUnchanged :: Property+prop_untouchedItemsAreUnchanged = forAll genUntouchedItem $ \l -> classifyProbeLine l === Unchanged++{- | The lopsidedness the parser promises: a line is read as harmless only+for one of the two reasons the module gives.+-}+prop_onlyRecognisedIsHarmless :: Property+prop_onlyRecognisedIsHarmless = forAll (oneof [genAnyLine, genPending, genHarmless]) $ \l ->+    case classifyProbeLine l of+        Pending -> counterexample "" True+        Noise -> counterexample (show l) (Text.null (Text.strip l) || "skipping " `Text.isPrefixOf` l)+        Unchanged ->+            counterexample+                (show l)+                ( Text.length l > itemizedWidth+                    && Text.take 1 l == "."+                    && Text.all (`elem` (" ." :: String)) (Text.take 9 (Text.drop 2 l))+                    && Text.index l itemizedWidth == ' '+                )++prop_failedProbeIsUnknown :: Property+prop_failedProbeIsUnknown =+    forAll (choose (1, 255)) $ \code ->+        forAll (listOf (oneof [genPending, genHarmless, genAnyLine])) $ \ls ->+            interpretProbe (ExitFailure code) (render ls) === Unknown++prop_verdictCountsPending :: Property+prop_verdictCountsPending =+    forAll (listOf genPending) $ \pending ->+        forAll (listOf genHarmless) $ \harmless ->+            forAll (shuffle (pending <> harmless)) $ \ls ->+                interpretProbe ExitSuccess (render ls)+                    === case length pending of+                        0 -> Success+                        n -> Failure (Text.pack (show n) <> " item(s) differ from the remote directory")++prop_verdictIgnoresNoiseAndOrder :: Property+prop_verdictIgnoresNoiseAndOrder =+    forAll (listOf (oneof [genPending, genHarmless, genAnyLine])) $ \ls ->+        forAll (listOf genHarmless) $ \extra ->+            forAll (shuffle (ls <> extra)) $ \mixed ->+                interpretProbe ExitSuccess (render mixed) === interpretProbe ExitSuccess (render ls)++-- | Failure text is public: it says how many, never which.+prop_reasonNamesNoPath :: Property+prop_reasonNamesNoPath =+    forAll (listOf1 genName) $ \names ->+        let paths = map (\n -> "dir-" <> n <> "/file-" <> n) names+            out = render (map (\p -> ">f+++++++++ " <> Text.pack p) paths)+         in case interpretProbe ExitSuccess out of+                Failure why -> counterexample (show why) (not (any (\p -> Text.pack p `Text.isInfixOf` why) paths))+                other -> counterexample ("expected a Failure, got " <> show other) False++-------------------------------------------------------------------------------+-- Properties: refs.++prop_refIgnoresOptions :: Property+prop_refIgnoresOptions = forAll genCoordinates $ \(rem, src, dst, o) ->+    forAll genOpts $ \o' ->+        rootRef (fetch o rem src dst) === rootRef (fetch o' rem src dst)+            .&&. rootRef (fetch o rem src dst) === Just (mkRef "rsync-receivedir" (src, dst, rem.remoteUser, rem.remoteHost))+            .&&. rootHelp (fetch o rem src dst) === rootHelp (fetch o' rem src dst)++prop_refSeparatesCoordinates :: Property+prop_refSeparatesCoordinates = forAll genCoordinates $ \(rem, src, dst, o) ->+    forAll genCoordinates $ \(rem', src', dst', _) ->+        let base = rootRef (fetch o rem src dst)+         in (src /= src' ==> base =/= rootRef (fetch o rem src' dst))+                .&&. (dst /= dst' ==> base =/= rootRef (fetch o rem src dst'))+                .&&. (rem.remoteUser /= rem'.remoteUser ==> base =/= rootRef (fetch o rem{remoteUser = rem'.remoteUser} src dst))+                .&&. (rem.remoteHost /= rem'.remoteHost ==> base =/= rootRef (fetch o rem{remoteHost = rem'.remoteHost} src dst))++prop_refDistinctFromSiblings :: Property+prop_refDistinctFromSiblings = forAll genCoordinates $ \(rem, src, dst, o) ->+    let received = rootRef (fetch o rem src dst)+     in received =/= rootRef (sendDirWith o silent ignoreTrack mkdirTrack (FS.Directory dst) rem src)+            .&&. received =/= rootRef (receiveFileWith o silent ignoreTrack mkdirTrack rem src dst)++{- | 'sendDir' and 'receiveFile' against their keys and help spelled out+again here, whatever the options.+-}+prop_olderNodesUnchanged :: Property+prop_olderNodesUnchanged = forAll genCoordinates $ \(rem, a, b, o) ->+    let sent = sendDirWith o silent ignoreTrack mkdirTrack (FS.Directory a) rem b+        got = receiveFileWith o silent ignoreTrack mkdirTrack rem a b+        login = rem.remoteUser <> "@" <> rem.remoteHost+     in rootRef sent === Just (mkRef "rsync-senddir" (a, rem.remoteUser, rem.remoteHost))+            .&&. rootHelp sent === Just ("copies " <> Text.pack a <> " to " <> Text.pack b <> " over rsync")+            .&&. rootRef got === Just (mkRef "rsync-receivefile" (a, b, rem.remoteUser, rem.remoteHost))+            .&&. rootHelp got === Just ("copies " <> Text.pack a <> " from " <> login <> " over rsync")++-------------------------------------------------------------------------------+-- A real rsync between two local directories: the module's flags and parser+-- against what rsync really does. No ssh, so nothing about the remote half.++realRsyncTests :: TestTree+realRsyncTests =+    testGroup+        "a real rsync, local paths"+        [ testCase "probe, fetch, probe again, change, probe" $+            requireExecutable "rsync" $+                withTempDir $ \tmp -> do+                    let src = tmp </> "results"+                        dst = tmp </> "in"+                    createDirectoryIfMissing True (src </> "sub")+                    createDirectoryIfMissing True dst+                    writeFile (src </> "a") "a\n"+                    writeFile (src </> "sub" </> "b") "b\n"+                    let probe = readProcessWithExitCode "rsync" (receiveDirFlags <> probeFlags <> [src, dst]) ""+                        verdictOf (code, out, _) = interpretProbe code (C8.pack out)+                    before <- probe+                    assertEqual (show before) (Failure "4 item(s) differ from the remote directory") (verdictOf before)+                    (code, _, err) <- readProcessWithExitCode "rsync" (receiveDirFlags <> [src, dst]) ""+                    assertEqual err ExitSuccess code+                    landed <- readFile (dst </> "results" </> "sub" </> "b")+                    assertEqual "a path without a trailing slash lands under the local directory" "b\n" landed+                    after <- probe+                    assertEqual ("--times is what makes this empty: " <> show after) Success (verdictOf after)+                    writeFile (src </> "a") "a longer a\n"+                    changed <- probe+                    assertEqual (show changed) (Failure "1 item(s) differ from the remote directory") (verdictOf changed)+                    writeFile (dst </> "results" </> "sub" </> "b") "edited locally\n"+                    both <- probe+                    assertEqual (show both) (Failure "2 item(s) differ from the remote directory") (verdictOf both)+        ]++-------------------------------------------------------------------------------+-- The node itself, through an ssh that runs the command locally. Opt-in; see+-- the module header for why and how.++endToEndTests :: TestTree+endToEndTests =+    testGroup+        "the node, end to end"+        [ testCase "check, up, check, change, check, up, check" $ do+            shim <- lookupEnv "SALMON_TEST_RSYNC_SSH_SHIM"+            case shim of+                Nothing ->+                    hPutStrLn stderr "SKIPPED: Rsync.receiveDir end to end needs a local-exec `ssh` shim on PATH and SALMON_TEST_RSYNC_SSH_SHIM=1 (see Test.RsyncSpec)"+                Just _ -> withTempDir $ \tmp -> do+                    let src = tmp </> "results"+                        dst = tmp </> "deep" </> "in"+                        node = receiveDir silent ignoreTrack mkdirTrack (Remote "nobody" "shim.invalid") src (FS.Directory dst)+                    createDirectoryIfMissing True (src </> "sub")+                    writeFile (src </> "a") "a\n"+                    writeFile (src </> "sub" </> "b") "b\n"+                    first <- rootCheck node+                    assertEqual "the local directory does not exist yet" (Failure "5 item(s) differ from the remote directory") first+                    ok <- runUp node+                    assertBool "up" ok+                    made <- doesDirectoryExist dst+                    assertBool "the mkdir track ran" made+                    landed <- readFile (dst </> "results" </> "sub" </> "b")+                    assertEqual "" "b\n" landed+                    second <- rootCheck node+                    assertEqual "nothing changed: nothing to fetch" Success second+                    again <- runUp node+                    assertBool "a second up is fine" again+                    writeFile (src </> "a") "a longer a\n"+                    third <- rootCheck node+                    assertEqual "" (Failure "1 item(s) differ from the remote directory") third+                    ok' <- runUp node+                    assertBool "up again" ok'+                    refreshed <- readFile (dst </> "results" </> "a")+                    assertEqual "" "a longer a\n" refreshed+                    fourth <- rootCheck node+                    assertEqual "" Success fourth+                    gone <- rootCheck (receiveDir silent ignoreTrack mkdirTrack (Remote "nobody" "shim.invalid") (tmp </> "absent") (FS.Directory dst))+                    unless (gone == Unknown) $+                        assertFailure ("a remote directory that is not there: " <> show gone)+        ]
+ test/Test/SecretDeliverySpec.hs view
@@ -0,0 +1,379 @@+{-# LANGUAGE OverloadedStrings #-}++{- | Layer 0 coverage for "Salmon.Builtin.Nodes.SecretDelivery" and the+Secret Manager file node built on it.++The upload's remote half is three shell scripts, and what a test can do+without a remote is run those very scripts under a local @sh@ -- no ssh, no+sudo, the placement owned by whoever runs the suite. That covers what the+scripts do and what their exit codes mean; the ssh leg itself is only+asserted on as a rendered command line.++Throughout, a sentinel stands in for the secret, and the assertions that+matter most are the ones saying where it does /not/ show up.+-}+module Test.SecretDeliverySpec (tests) where++import Control.Exception (SomeException, try)+import qualified Data.ByteString.Char8 as C8+import Data.Either (isLeft)+import Data.List (isInfixOf, isPrefixOf)+import Data.Text (Text)+import qualified Data.Text as Text+import GHC.IO.Exception (ExitCode (..))+import System.Directory (createDirectory, doesDirectoryExist, doesFileExist, listDirectory)+import System.FilePath ((</>))+import System.IO (stdin)+import System.IO.Temp (withSystemTempDirectory)+import System.Posix.Files (fileMode, getFileStatus, setFileMode)+import System.Process (readProcess)+import System.Process.ByteString (readCreateProcessWithExitCode)+import System.Process.ListLike (CmdSpec (..), cmdspec, proc)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, assertEqual, assertFailure, testCase)++import Data.Bits ((.&.))+import Salmon.Actions.UpDown (CheckResult (..))+import Salmon.Builtin.Extension+import Salmon.Builtin.Nodes.Binary (prepare, prepareIO)+import qualified Salmon.Builtin.Nodes.Gcp.Core as Core+import qualified Salmon.Builtin.Nodes.Gcp.SecretManager as SecretManager+import Salmon.Builtin.Nodes.SecretDelivery+import qualified Salmon.Builtin.Nodes.Ssh as Ssh+import Salmon.Op.Actions (Act (..))+import Salmon.Op.Ref (mkRef)+import Salmon.Reporter (silent)++tests :: TestTree+tests =+    testGroup+        "Salmon.Builtin.Nodes.SecretDelivery"+        [ testGroup "validatePlacement" placementTests+        , testGroup "remote scripts, run locally" scriptTests+        , testGroup "interpretProbe" probeTests+        , testGroup "ssh command" commandTests+        , testGroup "node" nodeTests+        , testGroup "on-machine placement" installTests+        , testGroup "SecretManager.secretFile" secretFileTests+        ]++sentinel :: C8.ByteString+sentinel = "s3ntinel-not-a-real-secret"++sentinelText :: Text+sentinelText = "s3ntinel-not-a-real-secret"++-- | The user and group running the suite: the only owner a test can chown to.+whoami :: IO (Text, Text)+whoami = do+    u <- readProcess "id" ["-un"] ""+    g <- readProcess "id" ["-gn"] ""+    pure (Text.strip (Text.pack u), Text.strip (Text.pack g))++placementAt :: FilePath -> Text -> IO Placement+placementAt path mode = do+    (u, g) <- whoami+    either (\why -> assertFailure (Text.unpack why)) pure (validatePlacement (Placement path u g mode))++runScript :: Text -> C8.ByteString -> IO ExitCode+runScript script input = do+    (code, _out, _err) <- readCreateProcessWithExitCode (proc "sh" ["-c", Text.unpack script]) input+    pure code++modeOf :: FilePath -> IO Int+modeOf path = fromIntegral . (.&. 0o7777) . fileMode <$> getFileStatus path++placementTests :: [TestTree]+placementTests =+    [ testCase "normalizes the mode to what stat prints" $+        assertEqual "" (Right "600") (placeMode <$> validatePlacement (Placement "/etc/x/secret" "root" "root" "0600"))+    , testCase "keeps a group-readable mode" $+        assertEqual "" (Right "640") (placeMode <$> validatePlacement (Placement "/etc/x/secret" "app" "app" "640"))+    , testCase "refuses a mode granting anything to others" $+        assertBool "" (isLeft (validatePlacement (Placement "/etc/x/secret" "root" "root" "0644")))+    , testCase "refuses a mode that is not octal" $+        assertBool "" (isLeft (validatePlacement (Placement "/etc/x/secret" "root" "root" "u+r")))+    , testCase "refuses a relative path" $+        assertBool "" (isLeft (validatePlacement (Placement "secret" "root" "root" "0600")))+    , testCase "refuses a path with a newline" $+        assertBool "" (isLeft (validatePlacement (Placement "/etc/x\n/secret" "root" "root" "0600")))+    , testCase "refuses an owner that is shell rather than a name" $+        assertBool "" (isLeft (validatePlacement (Placement "/etc/x/secret" "root; rm -rf /" "root" "0600")))+    , testCase "refuses an owner that reads as an option" $+        assertBool "" (isLeft (validatePlacement (Placement "/etc/x/secret" "-R" "root" "0600")))+    , testCase "refuses an empty group" $+        assertBool "" (isLeft (validatePlacement (Placement "/etc/x/secret" "root" "" "0600")))+    ]++scriptTests :: [TestTree]+scriptTests =+    [ testCase "upload writes the bytes with the declared mode, creating the directory" $+        withSystemTempDirectory "salmon-secret" $ \dir -> do+            let path = dir </> "sub" </> "secret"+            place <- placementAt path "0640"+            code <- runScript (uploadScript place) sentinel+            assertEqual "exit" ExitSuccess code+            got <- C8.readFile path+            assertEqual "contents" sentinel got+            mode <- modeOf path+            assertEqual "mode" 0o640 mode+            dirMode <- modeOf (dir </> "sub")+            assertEqual "directory mode" 0o750 dirMode+            left <- listDirectory (dir </> "sub")+            assertEqual "no temporary file left behind" ["secret"] left+    , testCase "upload survives a path with a quote and a space in it" $+        withSystemTempDirectory "salmon-secret" $ \dir -> do+            let path = dir </> "it's here" </> "the secret"+            place <- placementAt path "0600"+            code <- runScript (uploadScript place) sentinel+            assertEqual "exit" ExitSuccess code+            got <- C8.readFile path+            assertEqual "contents" sentinel got+    , testCase "upload replaces what was there and leaves an existing directory alone" $+        withSystemTempDirectory "salmon-secret" $ \dir -> do+            let path = dir </> "secret"+            before <- modeOf dir+            C8.writeFile path "old"+            place <- placementAt path "0600"+            code <- runScript (uploadScript place) sentinel+            assertEqual "exit" ExitSuccess code+            got <- C8.readFile path+            assertEqual "contents" sentinel got+            after <- modeOf dir+            assertEqual "directory mode untouched" before after+    , testCase "upload refuses an empty secret and keeps the old one" $+        withSystemTempDirectory "salmon-secret" $ \dir -> do+            let path = dir </> "secret"+            C8.writeFile path "old"+            place <- placementAt path "0600"+            code <- runScript (uploadScript place) ""+            assertBool "non-zero exit" (code /= ExitSuccess)+            got <- C8.readFile path+            assertEqual "contents" "old" got+            left <- listDirectory dir+            assertEqual "no temporary file left behind" ["secret"] left+    , testCase "probe: absent, matching, differing, wrong mode" $+        withSystemTempDirectory "salmon-secret" $ \dir -> do+            let path = dir </> "secret"+            place <- placementAt path "0600"+            absent <- runScript (probeScript place) sentinel+            assertEqual "absent" (ExitFailure 3) absent+            _ <- runScript (uploadScript place) sentinel+            same <- runScript (probeScript place) sentinel+            assertEqual "matching" ExitSuccess same+            differing <- runScript (probeScript place) "something else"+            assertEqual "differing" (ExitFailure 4) differing+            setFileMode path 0o640+            moded <- runScript (probeScript place) sentinel+            assertEqual "wrong mode" (ExitFailure 5) moded+    , testCase "probe prints nothing, whatever it finds" $+        withSystemTempDirectory "salmon-secret" $ \dir -> do+            let path = dir </> "secret"+            place <- placementAt path "0600"+            _ <- runScript (uploadScript place) "another value"+            (_code, out, err) <- readCreateProcessWithExitCode (proc "sh" ["-c", Text.unpack (probeScript place)]) sentinel+            assertEqual "stdout" "" out+            assertEqual "stderr" "" err+    , testCase "the command handed to ssh survives the remote shell's own parsing" $+        -- ssh gives its argument to the remote user's shell, which is what+        -- `sh -c` does with it here: two levels of quoting, as on the wire.+        withSystemTempDirectory "salmon-secret" $ \dir -> do+            let path = dir </> "it's here" </> "the secret"+            place <- placementAt path "0600"+            code <- runScript (Text.pack (remoteCommand AsLoginUser (uploadScript place))) sentinel+            assertEqual "exit" ExitSuccess code+            got <- C8.readFile path+            assertEqual "contents" sentinel got+            probed <- runScript (Text.pack (remoteCommand AsLoginUser (probeScript place))) sentinel+            assertEqual "probe" ExitSuccess probed+    , testCase "remove deletes the file, and succeeds when there is none" $+        withSystemTempDirectory "salmon-secret" $ \dir -> do+            let path = dir </> "secret"+            place <- placementAt path "0600"+            _ <- runScript (uploadScript place) sentinel+            first <- runScript (removeScript place) ""+            assertEqual "exit" ExitSuccess first+            present <- doesFileExist path+            assertBool "gone" (not present)+            again <- runScript (removeScript place) ""+            assertEqual "exit again" ExitSuccess again+    ]++probeTests :: [TestTree]+probeTests =+    [ testCase "0 is Success" $ assertEqual "" Success (interpretProbe place ExitSuccess)+    , testCase "3 is a missing file" $ assertEqual "" (Failure "missing: /etc/x/secret") (interpretProbe place (ExitFailure 3))+    , testCase "4 is different contents" $ assertEqual "" (Failure "/etc/x/secret holds different contents") (interpretProbe place (ExitFailure 4))+    , testCase "5 is owner or mode" $ assertEqual "" (Failure "/etc/x/secret has the wrong owner or mode") (interpretProbe place (ExitFailure 5))+    , testCase "255, ssh not connecting, is Unknown and not a Failure" $ assertEqual "" Unknown (interpretProbe place (ExitFailure 255))+    , testCase "anything else is a Failure naming the exit code" $+        assertEqual "" (Failure "could not inspect /etc/x/secret (exit 1)") (interpretProbe place (ExitFailure 1))+    ]+  where+    place = Placement "/etc/x/secret" "root" "root" "600"++commandTests :: [TestTree]+commandTests =+    [ testCase "the script is one quoted word after sudo -n sh -c" $ do+        args <- argsOf WithSudo+        assertEqual "argv length" 8 (length args)+        assertEqual "login" "salmon@203.0.113.7" (args !! 6)+        assertBool "sudo -n sh -c '...'" ("sudo -n sh -c 'set -eu\n" `isPrefixOf` last args)+    , testCase "no sudo when writing as the login user" $ do+        args <- argsOf AsLoginUser+        assertBool "sh -c '...'" ("sh -c 'set -eu\n" `isPrefixOf` last args)+    , testCase "BatchMode, and the identity and known-hosts file of the caller" $ do+        args <- argsOf WithSudo+        assertEqual "" ["-o", "BatchMode=yes", "-i", "/keys/client", "-o", "IdentitiesOnly=yes"] (take 6 args)+    , testCase "shellQuote closes and reopens around a quote" $+        assertEqual "" "'it'\\''s'" (shellQuote "it's")+    ]+  where+    place = Placement "/etc/x/secret" "root" "root" "600"+    opts = Ssh.ClientOpts (Just "/keys/client") Nothing+    argsOf elevation = do+        p <- prepareIO deliveryCommand (DeliveryCommand opts (Ssh.Remote "salmon" "203.0.113.7") elevation (uploadScript place)) stdin+        pure $ case cmdspec p of+            RawCommand _ args -> args+            ShellCommand s -> [s]++nodeTests :: [TestTree]+nodeTests =+    [ testCase "nothing about the node quotes the file it uploads" $+        withSystemTempDirectory "salmon-secret" $ \dir -> do+            let src = dir </> "source"+            C8.writeFile src sentinel+            case opAct (node src) of+                Nothing -> assertFailure "the upload is not a node"+                Just act -> do+                    let said = Text.unwords (act.extension.help : act.extension.notes)+                    assertBool "help names the source path" (Text.pack src `Text.isInfixOf` said)+                    assertBool "help and notes do not hold the contents" (not (sentinelText `Text.isInfixOf` said))+    , testCase "the ref is the remote host and path, not the source or the login" $+        case (opAct (node "/a/source"), opAct (node "/b/other")) of+            (Just a, Just b) -> do+                assertEqual "same site" a.extension.ref b.extension.ref+                assertEqual "" (mkRef "secret-upload" ("203.0.113.7" :: Text, "/etc/x/secret" :: FilePath)) a.extension.ref+            _ -> assertFailure "the upload is not a node"+    , testCase "a missing source is a Failure naming it, before any connection" $+        withSystemTempDirectory "salmon-secret" $ \dir -> do+            let src = dir </> "absent"+            case opAct (node src) of+                Nothing -> assertFailure "the upload is not a node"+                Just act -> do+                    verdict <- act.extension.check+                    assertEqual "" (Failure ("nothing to upload: " <> Text.pack src)) verdict+                    result <- try act.extension.up+                    case result of+                        Left (e :: SomeException) -> assertBool "names the source" (src `isInfixOf` show e)+                        Right () -> assertFailure "up should have thrown"+    , testCase "an empty source is refused by up" $+        withSystemTempDirectory "salmon-secret" $ \dir -> do+            let src = dir </> "empty"+            C8.writeFile src ""+            case opAct (node src) of+                Nothing -> assertFailure "the upload is not a node"+                Just act -> do+                    result <- try act.extension.up+                    case result of+                        Left (e :: SomeException) -> assertBool "says why" ("empty secret" `isInfixOf` show e)+                        Right () -> assertFailure "up should have thrown"+    , testCase "an invalid placement is refused by check and up alike" $+        case opAct (uploadSecretFile Ssh.noClientOpts silent ignoreTrack (SecretUpload "/a/source" remote (Placement "/etc/x/secret" "root" "root" "0644") WithSudo)) of+            Nothing -> assertFailure "the upload is not a node"+            Just act -> do+                verdict <- act.extension.check+                assertEqual "" (Failure "/etc/x/secret: the mode grants access to others") verdict+                result <- try act.extension.up+                assertBool "up throws" (either (\(_ :: SomeException) -> True) (const False) result)+    , testCase "a report shows paths and an exit code" $ do+        let shown = show (UploadDone (upload "/a/source") (ExitFailure 1))+        assertBool "" ("/a/source" `isInfixOf` shown && "/etc/x/secret" `isInfixOf` shown)+    ]+  where+    remote = Ssh.Remote "salmon" "203.0.113.7"+    upload src = SecretUpload src remote (Placement "/etc/x/secret" "root" "root" "0600") WithSudo+    node src = uploadSecretFile Ssh.noClientOpts silent ignoreTrack (upload src)++installTests :: [TestTree]+installTests =+    [ testCase "install writes the bytes atomically with the declared mode" $+        withSystemTempDirectory "salmon-secret" $ \dir -> do+            let path = dir </> "secret"+            place <- placementAt path "0640"+            installSecretBytes place sentinel+            got <- C8.readFile path+            assertEqual "contents" sentinel got+            mode <- modeOf path+            assertEqual "mode" 0o640 mode+            left <- listDirectory dir+            assertEqual "no temporary file left behind" ["secret"] left+            verdict <- checkInstalledSecret place sentinel+            assertEqual "check" Success verdict+    , testCase "install does not create the directory" $+        withSystemTempDirectory "salmon-secret" $ \dir -> do+            place <- placementAt (dir </> "missing" </> "secret") "0600"+            result <- try (installSecretBytes place sentinel)+            assertBool "throws" (either (\(_ :: SomeException) -> True) (const False) result)+            made <- doesDirectoryExist (dir </> "missing")+            assertBool "no directory" (not made)+    , testCase "install refuses an empty secret" $+        withSystemTempDirectory "salmon-secret" $ \dir -> do+            place <- placementAt (dir </> "secret") "0600"+            result <- try (installSecretBytes place "")+            assertBool "throws" (either (\(_ :: SomeException) -> True) (const False) result)+            present <- doesFileExist (dir </> "secret")+            assertBool "nothing written" (not present)+    , testCase "check: missing, differing, wrong mode -- and never quoting either side" $+        withSystemTempDirectory "salmon-secret" $ \dir -> do+            let path = dir </> "secret"+            place <- placementAt path "0600"+            missing <- checkInstalledSecret place sentinel+            assertEqual "missing" (Failure ("missing: " <> Text.pack path)) missing+            C8.writeFile path "what-is-on-disk-instead"+            setFileMode path 0o600+            differing <- checkInstalledSecret place sentinel+            assertEqual "differing" (Failure (Text.pack path <> " holds different contents")) differing+            installSecretBytes place sentinel+            setFileMode path 0o640+            moded <- checkInstalledSecret place sentinel+            assertEqual "wrong mode" (Failure (Text.pack path <> " has the wrong owner or mode")) moded+    , testCase "a leftover temporary file from a killed pass is not reused with its old mode" $+        withSystemTempDirectory "salmon-secret" $ \dir -> do+            let path = dir </> "secret"+            C8.writeFile (path <> ".salmon-tmp") "stale"+            setFileMode (path <> ".salmon-tmp") 0o644+            place <- placementAt path "0600"+            installSecretBytes place sentinel+            mode <- modeOf path+            assertEqual "mode" 0o600 mode+    , testCase "remove is quiet about a file that is not there" $+        withSystemTempDirectory "salmon-secret" $ \dir -> do+            createDirectory (dir </> "d")+            place <- placementAt (dir </> "d" </> "secret") "0600"+            removeInstalledSecret place+            installSecretBytes place sentinel+            removeInstalledSecret place+            present <- doesFileExist (dir </> "d" </> "secret")+            assertBool "gone" (not present)+    ]++secretFileTests :: [TestTree]+secretFileTests =+    [ testCase "reads the named version of the named secret" $+        assertEqual+            ""+            ["secrets", "versions", "access", "7", "--secret", "db-password", "--project", "acme-prod", "--quiet"]+            (argsOf (SecretManager.VersionsAccess (Core.Project "acme-prod") "db-password" "7"))+    , testCase "the node names the secret and the path, and its ref is the path" $+        case opAct (SecretManager.secretFile silent ignoreTrack file) of+            Nothing -> assertFailure "secretFile is not a node"+            Just act -> do+                assertEqual "" "writes secret db-password to /etc/app/db-password" act.extension.help+                assertEqual "" (mkRef "secret-file" ("/etc/app/db-password" :: FilePath)) act.extension.ref+    ]+  where+    file = SecretManager.SecretFile (Core.Project "acme-prod") "db-password" "latest" (Placement "/etc/app/db-password" "app" "app" "0600")+    argsOf cmd = case cmdspec (prepare SecretManager.secretManagerCommand cmd) of+        RawCommand _ args -> args+        ShellCommand s -> [s]
test/Test/ServeApiSpec.hs view
@@ -7,7 +7,7 @@ Three things are compared with the document, none of them by hand:  * every golden of "Test.ReportJsonSpec" (one per constructor of all four-  report streams), as the object @--json@ prints and as the @data:@ of an+  report streams, and a node's own lines), as the object @--json@ prints and as the @data:@ of an   event, and the status sink's document; * the set of @(stream, kind)@ the document lists, with the set the goldens   cover, both ways: a report constructor with no schema, or a schema for one@@ -115,7 +115,7 @@     nub . sort $         [ (s, k)         | (name, schema) <- schemaNames-        , any (`Text.isPrefixOf` name) ["UpDown_", "Upkeep_", "Serve_", "Follow_"]+        , any (`Text.isPrefixOf` name) ["UpDown_", "Upkeep_", "Serve_", "Follow_", "Node_"]         , Just props <- [field "properties" schema]         , Just (String s) <- [field "stream" props >>= field "const"]         , Just (String k) <- [field "kind" props >>= field "const"]
test/Test/ServeEventsSpec.hs view
@@ -32,6 +32,7 @@ import qualified Data.ByteString.Char8 as Char8 import qualified Data.ByteString.Lazy.Char8 as LChar8 import Data.Foldable (toList)+import qualified Data.List as List import Data.IORef (IORef, atomicModifyIORef', modifyIORef', newIORef, readIORef, writeIORef) import Data.Map.Strict (Map) import qualified Data.Map.Strict as Map@@ -57,6 +58,9 @@ import qualified Test.ServeApi as Api import Test.Tasty (TestTree, testGroup) import Test.Tasty.HUnit (assertBool, assertEqual, assertFailure, testCase)+import Test.Tasty.QuickCheck (Gen, Property, choose, counterexample, forAll, listOf, testProperty, (.&&.), (===))+import qualified Test.Tasty.QuickCheck as QC+import Data.List (nub) import Text.Read (readMaybe)  import qualified Salmon.Actions.Follow as Follow@@ -67,8 +71,9 @@ import qualified Salmon.Actions.UpDown as UpDown import qualified Salmon.Actions.Upkeep as Upkeep import Salmon.Builtin.Extension (Track', check, deps, down, help, nodeps, op, opAct, ref, up)+import qualified Salmon.Builtin.NodeLog as NodeLog import Salmon.Op.Configure (Configure (..))-import Salmon.Op.Ref (mkRef)+import Salmon.Op.Ref (Ref, mkRef, unRef) import Salmon.Op.Track (Track (..)) import Salmon.Reporter (contramap, runReporter) import qualified Salmon.Reporter.Tagged as Tagged@@ -84,6 +89,9 @@             [ testCase "replay is everything after since that the ring still holds; a gap when it does not" ringArithmetic             , testCase "golden JSON for the gap event" gapGolden             , testCase "an event is the Tagged object plus seq, plus origin when it belongs to a command" eventShape+            , testProperty "a node's tail is the newest of each lane about it, oldest first" tailIsNewestPerLane+            , testProperty "the nodes keeping a tail are the most recently heard from, at most the cap" tailKeepsRecentNodes+            , testCase "a published event lands in its node's tail, a server event in none" publishFillsTails             ]         , testGroup             "GET /events"@@ -94,6 +102,8 @@             , testCase "/status and /dag carry seq, and ?since= that seq misses nothing after" snapshotSeq             , testCase "?stream= and ?origin= narrow the stream" filters             , testCase "the pull-mode fetcher's reports are the follow stream: numbered with the rest, no origin, filterable" followStream+            , testCase "what a node says while it works is on the output stream: by ref, by channel, no origin" nodeLogStream+            , testCase "a one-shot up's lines and reports are in /dag's tail afterwards, up to its seq" oneShotTail             , testCase "an idle stream is kept alive, and a client hanging up drops its subscription" keepAliveAndCleanup             ]         ]@@ -183,6 +193,66 @@         kind <- textAt ["kind"] v         pure (stream, kind, seqOf v, textAt ["origin", "name"] v) +-- a lane and a node, among a few, for each of a run of events numbered 1..+tailRun :: Gen [(Int, Events.Lane)]+tailRun = listOf ((,) <$> choose (0, 4) <*> QC.elements [Events.OutputLane, Events.ReportLane])++tailConfig :: Int -> Int -> Int -> Events.Config+tailConfig o r n = Events.defaultConfig{Events.configTailOutput = o, Events.configTailReports = r, Events.configTailNodes = n}++tailRef :: Int -> Ref+tailRef i = mkRef "events-tail" i++recordRun :: Events.Config -> [(Int, Events.Lane)] -> Events.Tails+recordRun cfg run =+    List.foldl'+        (\t (n, (i, lane)) -> Events.recordTail cfg (tailRef i) lane (Events.Event n Nothing (Events.Enqueued (show i))) t)+        Events.emptyTails+        (zip [1 ..] run)++{- | With room for every node, a node's tail is the newest @o@ of its output+events and the newest @r@ of its reports, merged oldest first: checked+against the run itself, filtered and cut, for any bounds. -}+tailIsNewestPerLane :: Property+tailIsNewestPerLane =+    forAll ((,,) <$> choose (0, 5) <*> choose (0, 5) <*> tailRun) $ \(o, r, run) ->+        let tails = recordRun (tailConfig o r 16) run+            numbered = zip [1 :: Word64 ..] run+            expected i =+                List.sort+                    ( lastN o [n | (n, (j, Events.OutputLane)) <- numbered, j == i]+                        <> lastN r [n | (n, (j, Events.ReportLane)) <- numbered, j == i]+                    )+         in QC.conjoin+                [ counterexample ("node " <> show i) (fmap Events.eventSeq (Events.tailOf (tailRef i) tails) === expected i)+                | i <- [0 .. 4]+                ]+  where+    lastN k xs = drop (length xs - k) xs++{- | Past the cap, the nodes keeping a tail are exactly the ones most recently+heard from: a node quiet for longest is the one let go. -}+tailKeepsRecentNodes :: Property+tailKeepsRecentNodes =+    forAll ((,) <$> choose (1, 5) <*> tailRun) $ \(cap, run) ->+        let kept = Events.tailNodes (recordRun (tailConfig 3 3 cap) run)+            recent = take cap (nub (reverse (fmap fst run)))+         in (length kept <= cap) .&&. (List.sort kept === List.sort (fmap tailRef recent))++publishFillsTails :: IO ()+publishFillsTails = do+    ev <- Events.newEvents Events.defaultConfig+    let node = mkRef "events-tail-publish" ("n" :: Text)+    _ <- Events.publish ev Nothing (Events.Enqueued "up n")+    n2 <- Events.publish ev Nothing (Events.Reported (Tagged.FromNode (NodeLog.Line node NodeLog.Stdout "one")))+    _ <- Events.publish ev Nothing (Events.Reported (Tagged.FromServe Serve.Started))+    n4 <- Events.publish ev Nothing (Events.Reported (Tagged.FromNode (NodeLog.Line node NodeLog.Message "two")))+    (lastSeq, tails) <- Events.snapshotTails ev+    assertEqual "read with the last number" 4 lastSeq+    assertEqual "only the node's own events, in order" [n2, n4] (fmap Events.eventSeq (Events.tailOf node tails))+    assertEqual "and no other node has one" [node] (Events.tailNodes tails)+    assertEqual "on the output lane" (Just (node, Events.OutputLane)) (Events.laneOf (Events.Reported (Tagged.FromNode (NodeLog.Line node NodeLog.Stdout "x"))))+ ------------------------------------------------------------------------------- -- the thing served: counters per node name, and one node that is never satisfied @@ -211,12 +281,21 @@             actions                 { ref = mkRef "events-node" name                 , help = "node " <> Text.pack name-                , up = bump name+                , up = bump name >> when (chattyPrefix `List.isPrefixOf` name) (chatter name)                 , down = pure ()                 , check = if name == flakyName then pure (UpDown.Failure "never satisfied") else actions.check                 }     bump name = atomicModifyIORef' upsRef (\m -> (Map.insertWith (+) name 1 m, ()))+    -- what a build says while it runs: a progress message, then a line of+    -- its command's output+    chatter name = do+        NodeLog.say (mkRef "events-node" name) "compiling"+        NodeLog.emit (NodeLog.Line (mkRef "events-node" name) NodeLog.Stdout ("built " <> Text.pack name)) +-- | A node whose name starts with this says two lines while its @up@ runs.+chattyPrefix :: String+chattyPrefix = "chatty"+ ------------------------------------------------------------------------------- -- a running loop with an HTTP server @@ -268,6 +347,10 @@                         Nothing                         [Serve.stdinProducer stdinR, Http.serverProducer server]                 putMVar worldVar w+            -- the loop publishes its inbox from a thread it forks: wait for+            -- it, or the first request can beat it and be answered 503+            timeout (10 * 1000000) (Http.awaitLoop server)+                >>= maybe (assertFailure "the loop did not start within 10s") pure             manager <- unixManager path             r <- act (Running stdinW worldVar server manager upsRef)             _ <- try (hClose stdinW) :: IO (Either IOError ())@@ -568,7 +651,12 @@         _ <- sync running "supervise off"         _ <- sync running "up n1 n2"         _ <- sync running "up n3"-        (_, marker) <- async running "history"+        (queuedAt, marker) <- async running "history"+        -- the last number is read once the loop has handled the marker (its+        -- hang-up is on the stream), not as soon as it is queued: read+        -- earlier, the history's own events land after it under load and+        -- the ring's window moves past what is asserted below+        _ <- withEvents running ("?since=" <> show queuedAt) (readUntil (hungUpFrom marker))         lastSeq <- Events.lastSequence (Http.serverEvents (runningServer running))         assertBool "more happened than the ring holds" (lastSeq > 8)         es <- withEvents running "?since=0" (readUntil (hungUpFrom marker))@@ -683,6 +771,70 @@         without <- withEvents running "?since=0&stream=serve,updown,upkeep,server" (readUntil (hungUpFrom marker))         assertBool "and not there when not asked for" (all ((/= Just "follow") . streamOf) without)         assertBool "the rest is" (not (null without))++{- | A node's own lines ("Salmon.Builtin.NodeLog") reach the ring through a+sink registered for as long as the loop runs ('Http.serverNodeLogReporter',+which is what @run serve@ registers). They are filed on @output@, where a+held action's lines already are, so the client tailing a node reads both;+they name the node by @ref@ and say which channel the line came from. -}+nodeLogStream :: IO ()+nodeLogStream =+    withRunning $ \running -> do+        _ <- sync running "supervise off"+        let node = mkRef "events-spec-node-log" ("builder" :: Text)+        NodeLog.withSink (Http.serverNodeLogReporter (runningServer running)) $ do+            NodeLog.emit (NodeLog.Line node NodeLog.Stdout "STEP 1/2: FROM scratch")+            NodeLog.emit (NodeLog.Line node NodeLog.Stderr "warning: no cache")+            NodeLog.say node "waiting for ssh"+        -- the sink is gone: this one is said to nobody+        NodeLog.say node "after the loop"+        (_, marker) <- async running "history"+        everything <- withEvents running "?since=0" (readUntil (hungUpFrom marker))+        -- other tests in this process may be saying things too: only this node's+        let mine = [e | e <- everything, textAt ["ref", "full"] (sseData e) == Just (unRef node)]+        assertEqual "filed on the output stream" [Just "output", Just "output", Just "output"] (fmap streamOf mine)+        assertEqual "as log lines" ["log", "log", "log"] (fmap (kindOf . sseData) mine)+        assertEqual+            "channel and line, in the order they were said"+            [(Just "stdout", Just "STEP 1/2: FROM scratch"), (Just "stderr", Just "warning: no cache"), (Just "message", Just "waiting for ssh")]+            [(textAt ["channel"] (sseData e), textAt ["line"] (sseData e)) | e <- mine]+        assertBool "nobody typed them: no origin" (all ((== Nothing) . originOf) mine)+        assertNumbered "one counter across a node's lines and the rest" everything+        -- a tail asks for the output stream and gets them without the rest+        tailed <- withEvents running "?since=0&stream=output" (readUntil ((== Just "waiting for ssh") . textAt ["line"] . sseData))+        assertBool "only the output stream" (all ((== Just "output") . streamOf) tailed)+        without <- withEvents running "?since=0&stream=serve,updown,upkeep,server" (readUntil (hungUpFrom marker))+        assertBool "and not there when not asked for" (all ((/= Just "output") . streamOf) without)++{- | The owner's case: an operation said something while its @up@ ran, during+the pass of an @up@ command, and a client opens (or reloads) the page+afterwards. @\/dag@'s @tail@ for that node has the lines and the pass's+reports, numbered no higher than the answer's @seq@, so following+@?since=@ that number shows none of them twice. -}+oneShotTail :: IO ()+oneShotTail =+    withRunning $ \running -> do+        _ <- sync running "supervise off"+        let name = chattyPrefix <> "-tail"+            node = mkRef "events-node" name+        _ <- NodeLog.withSink (Http.serverNodeLogReporter (runningServer running)) (sync running ("up " <> name))+        (_, dag) <- get running "/dag"+        s <- maybe (assertFailure "no seq on /dag") pure (seqOf dag)+        tailOfNode <- case [n | n <- maybe [] arrayOf (field "nodes" dag), textAt ["ref", "full"] n == Just (unRef node)] of+            [n] -> pure (maybe [] arrayOf (field "tail" n))+            ns -> assertFailure ("one node expected, got " <> show (length ns))+        assertEqual+            "the pass's reports and what the node said, in order"+            [("updown", "eval", Nothing), ("output", "log", Just "compiling"), ("output", "log", Just ("built " <> Text.pack name)), ("updown", "done", Nothing)]+            [(maybe "" id (textAt ["stream"] e), kindOf e, textAt ["line"] e) | e <- tailOfNode]+        let seqs = mapMaybe seqOf tailOfNode+        assertEqual "every one numbered" (length tailOfNode) (length seqs)+        assertBool "strictly increasing" (and (zipWith (<) seqs (drop 1 seqs)))+        assertBool "none above the snapshot's seq" (all (<= s) seqs)+        -- and a client following from the snapshot sees none of them again+        (_, marker) <- async running "history"+        after <- withEvents running ("?since=" <> show s) (readUntil (hungUpFrom marker))+        assertBool "nothing about the node after the snapshot" (all ((/= Just (unRef node)) . textAt ["ref", "full"] . sseData) after)  keepAliveAndCleanup :: IO () keepAliveAndCleanup =
test/Test/ServeHttpSpec.hs view
@@ -30,7 +30,8 @@ import qualified Data.Aeson.KeyMap as KeyMap import qualified Data.ByteString.Lazy.Char8 as LChar8 import Data.Foldable (toList)-import Data.List (isInfixOf, sort)+import Data.List (isInfixOf, isPrefixOf, sort)+import qualified Data.List as List import Data.IORef (IORef, atomicModifyIORef', newIORef, readIORef) import Data.Map.Strict (Map) import qualified Data.Map.Strict as Map@@ -55,12 +56,15 @@ import qualified Test.ServeApi as Api import qualified Salmon.Actions.Serve as Serve import Salmon.Actions.Serve (Attributed (..), Convergence (..), Direction (..), NodeState (..), World (..))+import qualified Salmon.Actions.Query as Query+import qualified Salmon.Actions.Serve.Events as Events import qualified Salmon.Actions.Serve.Http as Http-import Salmon.Builtin.Extension (Track', deps, down, help, nodeps, op, ref, up)+import qualified Salmon.Actions.Serve.StatusSink as StatusSink+import Salmon.Builtin.Extension (Op, Track', amendNode, deps, down, help, nodeps, op, ref, up, withCardTemplate) import Salmon.Op.Configure (Configure (..)) import Salmon.Op.Ref (Ref, mkRef) import Salmon.Op.Track (Track (..))-import Salmon.Reporter (contramap)+import Salmon.Reporter (contramap, silent) import qualified Salmon.Reporter.Tagged as Tagged  import Test.Harness (capture, privatePipe, withTempDir)@@ -82,6 +86,9 @@         , testCase "/dag carries the mode the loop's accessor answers at the moment of the read" dagCarriesMode         , testCase "GET / is the web UI's page, /ui/* its files, and a missing one is 404" theWebUi         , testCase "two seeds colliding on one ref: /dag carries the kept and replaced pair while both are wanted" conflictingPairOnDag+        , testCase "a node's card template is on /dag, only on that node, and a new one is not a conflict" cardTemplateOnDag+        , testCase "a chain of diamonds: /dag, /status, status, a --select and a status-sink write all answer" diamondChainAnswers+        , testCase "--http-read-only: every POST /command is 403, reads and stdin still work; read-write by default" readOnlyRefusesCommands         ]  -------------------------------------------------------------------------------@@ -113,6 +120,14 @@     op "http-root" (deps (fmap nodeOp spec.specNames)) $ \actions ->         actions{ref = mkRef "http-root" spec.specNames, help = "the root of " <> Text.pack (unwords spec.specNames)}   where+    nodeOp name | name == diamondsName = diamondChain diamondCount+    -- @card=SRC@ is one node (ref keyed on @card@, help fixed) carrying the+    -- card template @SRC@: two seeds differing only by their template+    nodeOp name+        | Just src <- List.stripPrefix "card=" name =+            amendNode (withCardTemplate (Text.pack src)) $+                op "http-node" nodeps $ \actions ->+                    actions{ref = mkRef "http-node" ("card" :: String), help = "node card", up = bump upsRef "card", down = bump downsRef "card"}     -- @NAME:VARIANT@ is the same node (ref keyed on @NAME@) described     -- differently (help carries the whole word): two seeds colliding on     -- one ref, for the /dag conflict case@@ -129,6 +144,34 @@                 }     bump r name = atomicModifyIORef' r (\m -> (Map.insertWith (+) name 1 m, ())) +-- | The name of the seed word that declares 'diamondChain'.+diamondsName :: String+diamondsName = "diamonds"++{- | How many diamonds: 49 nodes under the root, 2^16 paths to the last.++No more than that because declaring a seed still walks every occurrence+once ('Salmon.Op.Dag.foldDag'): four more diamonds make the declaration+seconds of allocation, during which the suite's timing-sensitive cases,+sharing this process and its collector, miss their windows.+'Test.QuerySpec' has sixty diamonds without a loop around them.+-}+diamondCount :: Int+diamondCount = 16++{- | Diamonds on top of one another: each @top@ stands on a @left@ and a+@right@ that both stand on the next @top@ down, and the last on @bottom@. The+number of paths to a node doubles with every diamond above it, which is the+shape a listing of every path cannot survive.+-}+diamondChain :: Int -> Op+diamondChain 0 = op "bottom" nodeps $ \actions -> actions{ref = mkRef "http-diamond" ("bottom" :: String)}+diamondChain i =+    op "top" (deps [side "left", side "right"]) $ \actions -> actions{ref = mkRef "http-diamond" ("top" :: String, i)}+  where+    below = diamondChain (i - 1)+    side name = op (Text.pack name) (deps [below]) $ \actions -> actions{ref = mkRef "http-diamond" (name, i)}+ ------------------------------------------------------------------------------- -- a running loop with an HTTP server @@ -155,7 +198,11 @@  -- | 'withRunning' with the server's mode accessor chosen by the test. withRunningMode :: IO Serve.Mode -> (Running -> IO a) -> IO a-withRunningMode mode act =+withRunningMode = withRunningAccess Http.ReadWrite++-- | 'withRunningMode' with the server's 'Http.Access' chosen.+withRunningAccess :: Http.Access -> IO Serve.Mode -> (Running -> IO a) -> IO a+withRunningAccess access mode act =     withTempDir $ \dir -> do         let path = dir </> "serve.http"         (stdinR, stdinW) <- privatePipe@@ -164,7 +211,7 @@         upsRef <- newIORef Map.empty         downsRef <- newIORef Map.empty         slow <- Slow <$> newEmptyMVar <*> newEmptyMVar-        Http.withHttpServer path seedHelp mode $ \server -> do+        Http.withHttpServerAccess Events.defaultConfig access [Http.BindUnix path] seedHelp mode $ \server -> do             let base = (contramap attributed (Tagged.serveStream own), contramap attributed (Tagged.updownStream own))                 (serveR, updownR) = Http.serverReporters server base             _ <- forkIO $ do@@ -182,6 +229,10 @@                         Nothing                         [Serve.stdinProducer stdinR, Http.serverProducer server]                 putMVar worldVar w+            -- the loop publishes its inbox from a thread it forks: wait for+            -- it, or the first request can beat it and be answered 503+            timeout (10 * 1000000) (Http.awaitLoop server)+                >>= maybe (assertFailure "the loop did not start within 10s") pure             manager <- unixManager path             let running = Running stdinW worldVar seen upsRef downsRef slow manager             r <- act running@@ -414,6 +465,106 @@         nodes'' <- dagNodes v''         forM_ nodes'' $ \n -> assertEqual ("no conflict left on " <> show (textAt ["help"] n)) Nothing (field "conflict" n) +{- | A node carrying a card template ("Salmon.Op.CardTemplate") has it on+@\/dag@ as @template@ (and the answer is valid against the document); no+other node has the key. Re-declaring it with another template is not a+collision, does not demote the node, and @\/dag@ then carries the new one.+-}+cardTemplateOnDag :: IO ()+cardTemplateOnDag =+    withRunning $ \running -> do+        _ <- sync running "supervise off"+        _ <- sync running "autoconverge off"+        _ <- sync running "up card=.p($ctx.help) n2"+        _ <- sync running "converge"+        let cardOf nodes = [n | n <- nodes, textAt ["help"] n == Just "node card"]+        (_, v) <- get running "/dag"+        nodes <- dagNodes v+        assertEqual "the template" [Just ".p($ctx.help)"] (fmap (textAt ["template"]) (cardOf nodes))+        assertEqual "converged" [Just "converged"] (fmap (textAt ["convergence"]) (cardOf nodes))+        forM_ [n | n <- nodes, textAt ["help"] n /= Just "node card"] $ \n ->+            assertEqual ("no template on " <> show (textAt ["help"] n)) Nothing (field "template" n)+        assertBool "not among the rendered dynamics" (all (\n -> field "dynamics" n == Just (Array mempty)) (cardOf nodes))+        again <- sync running "up card=.div($ctx.shorthand)"+        assertBool ("another template is not a collision: " <> show again) ("conflicting" `notElem` again)+        (_, v') <- get running "/dag"+        nodes' <- dagNodes v'+        assertEqual "the new template" [Just ".div($ctx.shorthand)"] (fmap (textAt ["template"]) (cardOf nodes'))+        assertEqual "still converged" [Just "converged"] (fmap (textAt ["convergence"]) (cardOf nodes'))+        assertEqual "no conflict on it" [Nothing] (fmap (field "conflict") (cardOf nodes'))++{- | A graph whose paths are not to be listed: 'diamondCount' diamonds, 65536+paths to the last node and a quarter of a million in all. Everything that+shows a node's paths or resolves a pattern against them answers within+'exchange'\'s ten seconds, and what it answers is bounded: at most+'Query.pathLimit' paths per node, the shortest first. A pattern still+selects by any path, listed or not.+-}+diamondChainAnswers :: IO ()+diamondChainAnswers =+    withRunning $ \running -> do+        _ <- sync running "supervise off"+        _ <- sync running "autoconverge off"+        declared <- sync running ("up " <> diamondsName)+        assertBool ("declared: " <> show declared) ("declared" `elem` declared)+        let total = 3 * diamondCount + 2+        -- /dag+        (dagCode, dag) <- get running "/dag"+        assertEqual "/dag status" 200 dagCode+        nodes <- dagNodes dag+        let pathsOf n = [p | String p <- maybe [] arrayOf (field "paths" n)]+            bottoms = [n | n <- nodes, textAt ["shorthand"] n == Just "bottom"]+        assertEqual "one node per ref, the root included" total (length nodes)+        forM_ nodes $ \n -> do+            assertBool ("a path to " <> show (textAt ["ref", "short"] n)) (not (null (pathsOf n)))+            assertBool ("no more than the limit for " <> show (textAt ["ref", "short"] n)) (length (pathsOf n) <= Query.pathLimit)+        assertEqual "the last node lists the limit" [Query.pathLimit] (fmap (length . pathsOf) bottoms)+        assertEqual+            "its shortest path first: left all the way down"+            [Just ("http-root/" <> Text.concat (replicate diamondCount "top/left/") <> "bottom")]+            (fmap (headMay . pathsOf) bottoms)+        -- /status, and the typed status+        (statusCode, status) <- get running "/status"+        assertEqual "/status status" 200 statusCode+        assertEqual "/status lists every node" total (length (maybe [] arrayOf (field "nodes" status)))+        typed <- sync running "status"+        assertEqual "the typed status answers" ["status"] typed+        -- a selector: everything under the first right, by whatever path+        (_, q) <- post running "/command" "query --select /http-root/top/right/**"+        let selectedIn v = [n | r <- arrayOf v, n <- maybe [] arrayOf (field "nodes" r), field "selected" n == Just (Bool True)]+        assertEqual "all but the root, the first top and its left" (total - 3) (length (selectedIn q))+        -- by a path that is not among the ones listed+        (_, q') <- post running "/command" ("query --select /http-root" <> concat (replicate diamondCount "/top/right") <> "/bottom")+        assertEqual "the longest path still selects its node" [Just "bottom"] (fmap (textAt ["shorthand"]) (selectedIn q'))+        -- a restricted pass+        _ <- sync running "converge --select /http-root/top/left/**"+        ups <- readIORef (runningUps running)+        assertEqual "no counter node is in this graph" Map.empty ups+        (_, dag') <- get running "/dag"+        nodes' <- dagNodes dag'+        assertEqual+            "the pass converged what the pattern selected and nothing else"+            (total - 3)+            (length [() | n <- nodes', textAt ["convergence"] n == Just "converged"])+        -- the status sink writes the same object+        w <- finish running+        withTempDir $ \dir -> do+            let path = dir </> "status.json"+                cfg = StatusSink.Config{StatusSink.configPath = path, StatusSink.configInterval = 60 * 1000000, StatusSink.configHost = "diamonds"}+            written <- timeout (10 * 1000000) $+                StatusSink.withSink cfg Nothing silent $ \sink -> do+                    StatusSink.sinkObserver sink (pure w)+                    StatusSink.writeNow sink+                    eitherDecode <$> LChar8.readFile path+            case written of+                Nothing -> assertFailure "the status sink did not write within 10s"+                Just (Left err) -> assertFailure ("the sink's document does not parse: " <> err)+                Just (Right (doc :: StatusSink.Document)) ->+                    assertEqual "the sink's status lists every node" total (length (maybe [] arrayOf (field "nodes" (StatusSink.docStatus doc))))+  where+    headMay (x : _) = Just x+    headMay [] = Nothing+ dagBeforeAnyPass :: IO () dagBeforeAnyPass =     withRunning $ \running -> do@@ -607,6 +758,45 @@         (bad, _) <- post running "/command" "status\nhistory"         assertEqual "two lines in one body" 400 bad +{- | @--http-read-only@: nothing typed over HTTP reaches the loop, whatever+the line (a declaration, @clear@, @quit@, even @status@), sync, async or as+a JSON body; the reads answer and stdin still declares. Without the flag+@\/access@ says read-write.+-}+readOnlyRefusesCommands :: IO ()+readOnlyRefusesCommands = do+    withRunning $ \running -> do+        (code, v) <- get running "/access"+        assertEqual "access status" 200 code+        assertEqual "the default is read-write" (Just "read-write") (textAt ["access"] v)+    (ups, w) <- withRunningAccess Http.ReadOnly (pure Serve.Interactive) $ \running -> do+        (code, v) <- get running "/access"+        assertEqual "access status" 200 code+        assertEqual "the server says it is read-only" (Just "read-only") (textAt ["access"] v)+        forM_ ["up n1", "clear", "quit", "supervise off", "converge", "status"] $ \l -> do+            (sc, sv) <- post running "/command" l+            assertEqual ("sync " <> l) 403 sc+            assertBool ("the refusal says why: " <> show sv) (maybe False ("read-only" `Text.isInfixOf`) (textAt ["error"] sv))+            (ac, _) <- post running "/command?async" l+            assertEqual ("async " <> l) 403 ac+        (jc, _) <- postAs running "application/json" "{\"verb\": \"up\", \"seed\": [\"n1\"]}"+        assertEqual "a JSON body" 403 jc+        -- the reads still answer, and nothing above declared anything+        (hc, hist) <- get running "/history"+        assertEqual "history status" 200 hc+        assertEqual "no declaration came in over HTTP" 0 (length (maybe [] arrayOf (field "seeds" hist)))+        (dc, _) <- get running "/dag"+        assertEqual "dag status" 200 dc+        (mna, _) <- post running "/access" "status"+        assertEqual "access takes no POST" 405 mna+        -- the operator's own channel is not the HTTP server's to restrict+        hPutStr (runningStdin running) "up n1\n"+        w <- finish running+        ups <- readIORef (runningUps running)+        pure (ups, w)+    assertEqual "stdin's declaration was applied, and only it" (Map.fromList [("n1", 1)]) ups+    assertEqual "one declaration in the world" 1 (length w.worldEpochs)+ theWebUi :: IO () theWebUi =     withRunning $ \running -> do@@ -625,6 +815,15 @@         (ccode, ctype', _) <- getRaw running "/ui/ui.css"         assertEqual "the stylesheet's status" 200 ccode         assertEqual "the stylesheet's content type" (Just "text/css; charset=utf-8") ctype'+        -- tail templates are opt-in: the page names neither file, the script+        -- imports them only on demand, and both are embedded (no third-party fetch)+        assertBool "the page loads no template code" (not ("tail-template" `isInfixOf` LChar8.unpack body) && not ("card-template" `isInfixOf` LChar8.unpack body) && not ("vendor/" `isInfixOf` LChar8.unpack body))+        assertBool "the script imports the evaluator lazily" ("import(\"./vendor/tramaj-js/index.js\")" `isInfixOf` LChar8.unpack js)+        assertBool "the script has no static import" (not (any ("import " `isPrefixOf`) (lines (LChar8.unpack js))))+        forM_ ["/ui/tail-template.js", "/ui/card-template.js", "/ui/vendor/tramaj-js/index.js", "/ui/vendor/tramaj-js/eval.js"] $ \path -> do+            (vcode, vtype, _) <- getRaw running path+            assertEqual (path <> "'s status") 200 vcode+            assertEqual (path <> "'s content type") (Just "text/javascript; charset=utf-8") vtype         (missing, _) <- get running "/ui/missing"         assertEqual "a file outside the embedded set" 404 missing         (mna, _) <- post running "/" "status"
test/Test/ServeSocketSpec.hs view
@@ -44,13 +44,14 @@ import Test.Tasty.HUnit (assertBool, assertEqual, assertFailure, testCase)  import qualified Salmon.Actions.Serve as Serve-import Salmon.Actions.Serve (Convergence (..), Direction (..), NodeState (..), World (..))+import Salmon.Actions.Serve (Attributed (..), Convergence (..), Direction (..), NodeState (..), World (..)) import qualified Salmon.Actions.Serve.Socket as Socket import Salmon.Builtin.Extension (Track', deps, down, nodeps, op, ref, up)+import qualified Salmon.Builtin.NodeLog as NodeLog import Salmon.Op.Configure (Configure (..)) import Salmon.Op.Ref (Ref, mkRef) import Salmon.Op.Track (Track (..))-import Salmon.Reporter (silent)+import Salmon.Reporter (reportIf, silent) import qualified Salmon.Reporter.Tagged as Tagged  import Test.Harness (capture, privatePipe, withTempDir)@@ -63,6 +64,7 @@         , testCase "a client hanging up is reported and does not end the loop" hangUpDoesNotEndTheLoop         , testCase "`quit` from a client ends the loop" quitFromAClientEndsTheLoop         , testCase "a client that half-closes after typing still gets its reports" halfCloseStillAnswered+        , testCase "what a node says during a client's command reaches that client, and no other" nodeLinesReachTheirClient         , testCase "the socket is owner-only, refused while live, replaced when stale, refused when too long" socketFileRules         ] @@ -88,11 +90,24 @@         op "socket-node" nodeps $ \actions ->             actions                 { ref = mkRef "socket-node" name-                , up = bump upsRef name+                , up = bump upsRef name >> when (isChatty name) (chatter name)                 , down = bump downsRef name                 }     bump r name = atomicModifyIORef' r (\m -> (Map.insertWith (+) name 1 m, ()))+    chatter name = do+        NodeLog.say (mkRef "socket-node" name) "compiling"+        NodeLog.emit (NodeLog.Line (mkRef "socket-node" name) NodeLog.Stdout ("built " <> Text.pack name)) +-- | A node whose name starts with @chatty@ says two lines while its @up@ runs.+isChatty :: String -> Bool+isChatty = (== "chatty") . take 6++{- | The node lines this spec's listener is handed: the registry is+process-wide and other suites say things while these run, so only the lines+of this spec's own chatty nodes. -}+oursOnly :: NodeLog.Line -> Bool+oursOnly line = line.lineRef `elem` [mkRef "socket-node" n | n <- ["chatty-a", "chatty-b" :: String]]+ ------------------------------------------------------------------------------- -- a running loop with a listener @@ -121,9 +136,12 @@         downsRef <- newIORef Map.empty         Socket.withUnixListener path $ \listener -> do             let (serveR, updownR) = Socket.listenerReporters listener own+                saidR = reportIf (\(Attributed _ line) -> oursOnly line) (Socket.listenerNodeLogReporter listener)             _ <- forkIO $ do                 w <--                    Serve.serveAttributed+                    Serve.serveSaying+                        saidR+                        (const (pure ()))                         []                         Nothing                         True@@ -230,6 +248,33 @@ kindsSeen c = fmap kindOf . reverse <$> readIORef (clientSeen c)  -------------------------------------------------------------------------------++{- | Item 2 of the tail work: a socket client is sent what a node said while+the pass its own line started ran ("Salmon.Builtin.NodeLog", stamped by+'Serve.serveSaying'), as the @output@\/@log@ objects @--json@ prints, before+the pass's end; a second client's command, which says nothing, reads none of+them. -}+nodeLinesReachTheirClient :: IO ()+nodeLinesReachTheirClient =+    withRunning $ \running ->+        withClient running $ \a -> withClient running $ \b -> do+            _ <- ask a "supervise off" "supervised"+            _ <- ask a "up chatty-a" "converge-stop"+            bKinds <- ask b "up plain-b" "converge-stop"+            seenA <- reverse <$> readIORef (clientSeen a)+            let logs = [v | v <- seenA, kindOf v == "log"]+            assertEqual+                "A read what its node said, in order, by channel"+                [(Just "message", Just "compiling"), (Just "stdout", Just "built chatty-a")]+                [(textField "channel" v, textField "line" v) | v <- logs]+            assertBool "on the output stream" (all ((== Just "output") . textField "stream") logs)+            assertBool "before its pass ended" ("converge-stop" `notElem` takeWhile (/= "log") (fmap kindOf seenA))+            assertBool ("B read none of it: " <> show bKinds) ("log" `notElem` bKinds)+  where+    textField k (Object o) = case KeyMap.lookup k o of+        Just (String t) -> Just t+        _ -> Nothing+    textField _ _ = Nothing  {- | The script both halves of the comparison run: A declares and asks for history, B asks for status and retires; each waits for the previous
test/Test/ServeSpec.hs view
@@ -16,7 +16,7 @@ import Control.Concurrent (forkIO, threadDelay) import Control.Concurrent.MVar (newEmptyMVar, putMVar, takeMVar) import Control.Concurrent.STM (TVar, atomically, modifyTVar', newTVarIO, readTVar, readTVarIO, retry)-import Control.Exception (IOException, throwIO, try)+import Control.Exception (IOException, finally, onException, throwIO, try) import Control.Monad (unless, when) import Data.Aeson (FromJSON, ToJSON, encode) import qualified Data.ByteString.Lazy as LByteString@@ -32,7 +32,8 @@ import System.FilePath ((</>)) import System.IO (BufferMode (LineBuffering), Handle, IOMode (ReadMode), hClose, hPutStr, hPutStrLn, hSetBuffering, withFile) import System.IO.Temp (withSystemTempFile)-import System.Process (createPipe, proc)+import System.Posix.Signals (sigKILL, signalProcessGroup)+import System.Process (CreateProcess (..), Pid, proc) import System.Timeout (timeout) import Test.Tasty (TestTree, testGroup) import Test.Tasty.HUnit (assertBool, assertEqual, assertFailure, testCase)@@ -42,9 +43,10 @@ import qualified Salmon.Actions.UpDown as UpDown import Salmon.Actions.UpDown (CheckResult (..)) import qualified Salmon.Actions.Upkeep as Upkeep-import Salmon.Builtin.Extension (Extension, Op, Track', check, deps, down, dynamics, managed, nodeps, op, opAct, ref, up)+import Salmon.Builtin.Extension (Extension, Op, Track', check, deps, down, dynamics, managed, nodeps, notes, op, opAct, ref, up) import qualified Salmon.Builtin.Nodes.Daemon as Daemon import qualified Salmon.Builtin.Nodes.Filesystem as FS+import Salmon.Op.Actions (Act (..)) import Salmon.Op.Configure (Configure (..)) import qualified Salmon.Op.Ledger as Ledger import qualified Salmon.Op.Mailbox as Mailbox@@ -54,9 +56,9 @@ import Salmon.Op.Supervision (Strategy (..), Supervision (..), defaultSupervision, supervised) import qualified Salmon.Op.Rewrite as Rewrite import Salmon.Op.Track (Track (..))-import Salmon.Reporter (ReporterM (..), silent)+import Salmon.Reporter (Reporter, ReporterM (..)) -import Test.Harness (capture, withTempDir)+import Test.Harness (capture, privatePipe, withTempDir)  tests :: TestTree tests =@@ -67,6 +69,7 @@         , testCase "retiring a seed tears its nodes down" retireTearsDown         , testCase "retiring a multi-file bundle removes its shared directory cleanly" retireMultiFileBundle         , testCase "`only` retires the previous seed but keeps shared nodes" onlySupersedes+        , testCase "retiring the older of two declarations of one node leaves the newer one's description in force" retiringTheLoserChangesNothing         , testCase "a node whose up threw is retried by the next pass" failedNodeIsRetried         , testCase "a retired declaration survives a failed down" retiredContributionSurvives         , testCase "re-declaring a seed does not accumulate graphs" reDeclareDoesNotAccumulate@@ -281,6 +284,40 @@         -- alone held is down, and the shared directory belongs to `b` now.         assertEqual "only the surviving seed's graph is retained" 1 (length w.worldEpochs) +{- | One node, keyed on the directory alone, whose description (and what its+@up@ does) is the first name of the seed: two seeds, one effect site,+described differently -- a role node declared with a different primary.+-}+described :: IORef [String] -> Track' Spec+described applied = Track $ \spec ->+    let who = concat (take 1 spec.specNames)+     in op "described" nodeps $ \actions ->+            actions+                { ref = mkRef "described" spec.specDir+                , notes = [Text.pack who]+                , up = atomicModifyIORef' applied (\xs -> (xs <> [who], ()))+                }++{- | Replacing a declaration without `only`: declare the new one, then retire+the old. In between the two collide and the newer wins; retiring the older+must then be a statement about who wants the node and nothing else. If the+retired declaration's description went back into the magma, the node would+be re-applied as the declaration that was just withdrawn.+-}+retiringTheLoserChangesNothing :: IO ()+retiringTheLoserChangesNothing =+    withTempDir $ \root -> do+        applied <- newIORef []+        (w, _, _) <- runServe (described applied) root ["up a", "up b", "down a"]+        assertEqual "applied as a, then as b, and not as a again" ["a", "b"] =<< readIORef applied+        assertEqual "one live declaration" 1 (Ledger.liveCount w.worldLedger)+        assertEqual+            "the node is described as the surviving declaration describes it"+            [["b" :: Text]]+            [act.extension.notes | act <- Map.elems w.worldMagma]+        assertAllConverged TurnUp w+        assertBool "no collision is left standing" (Map.null w.worldConflicts)+ failedNodeIsRetried :: IO () failedNodeIsRetried =     withTempDir $ \root -> do@@ -684,14 +721,22 @@     , sessionNodes :: !(TVar [UpDown.Report Extension])     } --- | Run the loop on its own thread over a pipe the body writes into.+{- | Run the loop on its own thread over a pipe the body writes into.++The pipe is 'privatePipe': a daemon a case starts is a child of this+process, and one that inherited the write end would hold this loop's input+open. A body that throws still ends the loop — it lets go of any managed+process first, as 'releaseManaged' does, then types @quit@ and waits a+bounded while for the loop to take it — so a failed case does not leave a+loop tending its nodes, or a daemon running, for the rest of the suite.+-} withSession :: Track' Spec -> FilePath -> (Session -> IO a) -> IO (a, World Spec Spec) withSession prog root body = do     serveTrace <- newTVarIO []     nodeTrace <- newTVarIO []     let serveReporter = ReporterM (\rep -> atomically (modifyTVar' serveTrace (rep :)))     let nodeReporter = ReporterM (\rep -> atomically (modifyTVar' nodeTrace (rep :)))-    (readEnd, writeEnd) <- createPipe+    (readEnd, writeEnd) <- privatePipe     hSetBuffering writeEnd LineBuffering     done <- newEmptyMVar     _ <-@@ -699,12 +744,32 @@             w <- Serve.serveWith [] Nothing True serveReporter nodeReporter (parseSpec root) (Configure pure) prog readEnd             putMVar done w     let session = Session writeEnd serveTrace nodeTrace-    result <- body session+        abandon = do+            _ <- try (mapM_ (hPutStrLn writeEnd) ["autoconverge on", "clear", "quit"]) :: IO (Either IOException ())+            _ <- timeout 5000000 (takeMVar done)+            _ <- try (hClose writeEnd) :: IO (Either IOException ())+            pure ()+    result <- body session `onException` abandon     hPutStrLn writeEnd "quit"     w <- expect "the loop to exit" (takeMVar done)     hClose writeEnd     pure (result, w) +{- | Let go of every managed process the session holds, and wait until the+loop has: @quit@ leaves such a process running, which is its promise, so a+case whose daemon is still wanted at the end says so before leaving.+@autoconverge on@ first because a @clear@ is deferred like anything else+when it is off, and the release happens in the pass.+-}+releaseManaged :: Session -> IO ()+releaseManaged session = do+    before <- releases <$> readTVarIO session.sessionServe+    hPutStrLn session.sessionIn "autoconverge on"+    hPutStrLn session.sessionIn "clear"+    awaitOn session.sessionServe (\rs -> releases rs > before)+  where+    releases rs = length [() | Serve.Tended (Upkeep.Released _) <- rs]+ -- | Block until the reports so far (oldest first) satisfy the predicate. awaitOn :: TVar [a] -> ([a] -> Bool) -> IO () awaitOn trace p =@@ -862,9 +927,9 @@ -} managedNodeStartsDespiteAutoConvergeOff :: IO () managedNodeStartsDespiteAutoConvergeOff =-    withTempDir $ \root -> do+    withTempDir $ \root -> withTickers $ \tk -> do         let ticks = root </> "ticks"-        _ <- withSession (ticker ticks) root $ \session -> do+        _ <- withSession (ticker tk ticks) root $ \session -> do             hPutStrLn session.sessionIn "autoconverge off"             awaitOn session.sessionServe (\rs -> not (null [() | Serve.AutoConverged False <- rs]))             hPutStrLn session.sessionIn "up a"@@ -872,7 +937,7 @@             -- no explicit `converge` is ever typed: the daemon's only route             -- up is the idle tending loop, autoconverge notwithstanding.             _ <- awaitTicks ticks 2-            pure ()+            releaseManaged session         pure ()  {- | @autoconverge@ is read fresh by 'tendOf' every time tending starts, not@@ -1024,9 +1089,9 @@ -} ownedProcessSurvivesCommands :: IO () ownedProcessSurvivesCommands =-    withTempDir $ \root -> do+    withTempDir $ \root -> withTickers $ \tk -> do         let ticks = root </> "ticks"-        (_, w) <- withSession (ticker ticks) root $ \session -> do+        (_, w) <- withSession (ticker tk ticks) root $ \session -> do             hPutStrLn session.sessionIn "up a"             awaitOn session.sessionServe (\rs -> length [() | Serve.ConvergeStop _ _ <- rs] >= 1)             -- it is running@@ -1064,12 +1129,12 @@ -} adoptedDaemonFollowsItsConfig :: IO () adoptedDaemonFollowsItsConfig =-    withTempDir $ \root -> do+    withTempDir $ \root -> withTickers $ \tk -> do         there <- newIORef False         attempts <- newIORef (0 :: Int)         spawns <- newTVarIO (0 :: Int)         let ticks = root </> "ticks"-        _ <- withSession (tickerOnConfig ticks there attempts spawns) root $ \session -> do+        _ <- withSession (tickerOnConfig tk ticks there attempts spawns) root $ \session -> do             hPutStrLn session.sessionIn "up a"             awaitOn session.sessionServe (\rs -> length [() | Serve.ConvergeStop _ _ <- rs] >= 1)             awaitSpawns spawns 1@@ -1082,6 +1147,7 @@             atomicModifyIORef' there (const (False, ()))             awaitSpawns spawns 2             assertEqual "the config node had put its own effect back first" 2 =<< readIORef attempts+            releaseManaged session         pure ()  -- | How many times supervision has (re)started over at least one node.@@ -1097,15 +1163,15 @@ 'Salmon.Op.Supervision.RestForOne', which is the shape the whole strategy exists for: the config is rewritten, so what reads it has to be bounced. -}-tickerOnConfig :: FilePath -> IORef Bool -> IORef Int -> TVar Int -> Track' Spec-tickerOnConfig path there attempts spawns = Track $ \spec ->+tickerOnConfig :: Tickers -> FilePath -> IORef Bool -> IORef Int -> TVar Int -> Track' Spec+tickerOnConfig tk path there attempts spawns = Track $ \spec ->     let cfg = watchedOp there attempts restForOne spec      in op "ticker" (deps [cfg]) $ \actions ->             actions                 { ref = Daemon.daemonRef (tickerDaemon path)                 , managed = Just $ \out -> do                     atomically (modifyTVar' spawns (+ 1))-                    Daemon.runDaemon silent (tickerDaemon path) out+                    Daemon.runDaemon (recordSpawns tk) (tickerDaemon path) out                 , up = throwIO (userError "the ticker cannot be brought up by a one-shot pass")                 }   where@@ -1118,14 +1184,53 @@ convergence pass instead of to a machine, the pass would fail loudly rather than quietly do nothing. -}-ticker :: FilePath -> Track' Spec-ticker path = Track (const (Daemon.daemon silent (tickerDaemon path)))+ticker :: Tickers -> FilePath -> Track' Spec+ticker tk path = Track (const (Daemon.daemon (recordSpawns tk) (tickerDaemon path))) +{- | @close_fds@: the shell is a child of the test process, which runs+other groups at the same time, and would otherwise hold a copy of every+descriptor they had open when it started (their pipes and sockets) for as+long as it runs.+-} tickerDaemon :: FilePath -> Daemon.Daemon tickerDaemon path =     Daemon.defaultDaemon         "ticker"-        (proc "/bin/sh" ["-c", "while true; do echo tick >> " <> path <> "; sleep 0.05; done"])+        (proc "/bin/sh" ["-c", "while true; do echo tick >> " <> path <> "; sleep 0.05; done"]){close_fds = True}++{- | The ticker shells a case started, so that it can stop them on its way+out however it ends.++@quit@ leaves a managed process running (that is its promise), so a case+that ends with its ticker still wanted — passing or not — would otherwise+leave the shell writing into its temp dir. Worse, it races the temp dir's+removal: a tick written between the file's removal and the directory's+recreates the file, the removal gives up, and the shell, no longer failing,+outlives the suite. So a case lets go of its ticker before it leaves+('releaseManaged', and 'withSession' on a failure), and 'withTickers' kills+whatever recorded process group is still there once the case is over, after+checking that the pid is still one of these shells: the last resort for a+loop that did not take its @clear@ in time.+-}+newtype Tickers = Tickers (IORef [Pid])++withTickers :: (Tickers -> IO a) -> IO a+withTickers act = do+    ref <- newIORef []+    act (Tickers ref) `finally` (readIORef ref >>= mapM_ stopTicker)+  where+    stopTicker pid = do+        cmdline <- try (readFile ("/proc/" <> show pid <> "/cmdline")) :: IO (Either IOException String)+        case cmdline of+            Right cmd | "echo tick >> " `Data.List.isInfixOf` cmd -> do+                _ <- try (signalProcessGroup sigKILL pid) :: IO (Either IOException ())+                pure ()+            _ -> pure ()++recordSpawns :: Tickers -> Reporter Daemon.Report+recordSpawns (Tickers ref) = ReporterM $ \rep -> case rep of+    Daemon.Spawned _ (Just pid) -> atomicModifyIORef' ref (\ps -> (pid : ps, ()))+    _ -> pure ()  countTicks :: FilePath -> IO Int countTicks path = do
test/Test/ServeTlsSpec.hs view
@@ -272,6 +272,10 @@                         Nothing                         [Serve.stdinProducer stdinR, Http.serverProducer server]                 putMVar worldVar w+            -- the loop publishes its inbox from a thread it forks: wait for+            -- it, or the first request can beat it and be answered 503+            timeout (10 * 1000000) (Http.awaitLoop server)+                >>= maybe (assertFailure "the loop did not start within 10s") pure             bound <- readTVarIO (Http.serverBoundTcp server)             port <- case bound of                 [Socket.SockAddrInet p _] -> pure (fromIntegral p)
+ test/Test/SharedDirSpec.hs view
@@ -0,0 +1,165 @@+{-# LANGUAGE OverloadedStrings #-}++{- | Layer 0 coverage for 'FS.sharedDir' and 'FS.filecontentsInSharedDir':+a file in a directory salmon does not own alone (a package's @\/etc\/haproxy@,+@\/etc\/sudoers.d@, a config directory next to a pre-provisioned secret).++'FS.filecontents' stands on 'FS.dir', whose @down@ refuses a non-empty+directory, so a @run down@ removed the file and then reported the directory+@Failed@. The teardown cases here run real file IO in a temporary directory;+the graph-shape cases check that the builtins that write into such+directories no longer declare a plain directory node for them. Nothing here+touches the real @\/etc@ or starts a service.+-}+module Test.SharedDirSpec (tests, assertOnSharedDir) where++import Data.Foldable (toList)+import Data.Functor.Identity (runIdentity)+import qualified Data.Map.Strict as Map+import Data.Text (Text)+import System.Directory (doesDirectoryExist, doesFileExist)+import System.FilePath ((</>))+import Test.QuickCheck (Gen, elements, forAll, listOf1, (===))+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (Assertion, assertBool, assertEqual, testCase)+import Test.Tasty.QuickCheck (testProperty)+import Test.Harness (runDown, runDownCapturing, runUp, withTempDir)++import qualified Salmon.Actions.UpDown as UpDown+import Salmon.Builtin.Extension+import qualified Salmon.Builtin.Nodes.Filesystem as FS+import qualified Salmon.Builtin.Nodes.Haproxy as Haproxy+import qualified Salmon.Builtin.Nodes.Nginx as Nginx+import qualified Salmon.Builtin.Nodes.PgBouncer as PgBouncer+import Salmon.Op.Actions (Act (..))+import qualified Salmon.Op.Dag as Dag+import Salmon.Op.OpGraph (OpGraph (..))+import Salmon.Op.Ref (Ref, mkRef)+import Salmon.Reporter (silent)+import qualified SreBox.Initialize as Initialize++tests :: TestTree+tests =+    testGroup+        "Salmon.Builtin.Nodes.Filesystem.sharedDir"+        [ testCase "tearing down a file in a shared directory is clean and leaves the directory and its other files" $ withTempDir $ \tmp -> do+            let dir = tmp </> "etc-haproxy"+                other = dir </> "errors.http"+                node = FS.filecontentsInSharedDir (FS.FileContents (dir </> "haproxy.cfg") ("global\n" :: Text))+            upOk <- runUp node+            assertBool "up failed" upOk+            assertBool "the file was not written" =<< doesFileExist (dir </> "haproxy.cfg")+            writeFile other "not ours\n"+            reports <- runDownCapturing node+            assertEqual "failed or blocked nodes" [] [shorthandOf rep | rep <- reports, isBad rep]+            assertBool "our file is still there" . not =<< doesFileExist (dir </> "haproxy.cfg")+            assertBool "another file was removed" =<< doesFileExist other+            assertBool "the shared directory was removed" =<< doesDirectoryExist dir+            downAgain <- runDown node+            assertBool "a second down is not clean" downAgain+        , testCase "an empty shared directory is left too" $ withTempDir $ \tmp -> do+            let dir = tmp </> "sudoers.d"+                node = FS.filecontentsInSharedDir (FS.FileContents (dir </> "salmon") ("x\n" :: Text))+            _ <- runUp node+            reports <- runDownCapturing node+            assertEqual "failed or blocked nodes" [] [shorthandOf rep | rep <- reports, isBad rep]+            assertBool "the shared directory was removed" =<< doesDirectoryExist dir+        , testCase "up creates a missing shared directory, parents included" $ withTempDir $ \tmp -> do+            let dir = tmp </> "a" </> "b"+            ok <- runUp (FS.filecontentsInSharedDir (FS.FileContents (dir </> "f") ("x" :: Text)))+            assertBool "up failed" ok+            assertBool "not created" =<< doesFileExist (dir </> "f")+        , testCase "the same file over Filesystem.dir is the shape that failed" $ withTempDir $ \tmp -> do+            -- pins why the predecessor was swapped+            let dir = tmp </> "etc-haproxy"+                plain = FS.filecontents (FS.FileContents (dir </> "haproxy.cfg") ("global\n" :: Text))+            _ <- runUp plain+            writeFile (dir </> "errors.http") "not ours\n"+            reports <- runDownCapturing plain+            assertEqual "" ["directory"] [shorthandOf rep | rep <- reports, isBad rep]+        , testProperty "the file node renders what filecontents rendered" $+            forAll genPath $ \(d, name) ->+                let fc = FS.FileContents (d </> name) ("contents\n" :: Text)+                 in describe (FS.filecontentsInSharedDir fc) === describe (FS.filecontents fc)+        , testProperty "the file stands on exactly one shared-directory, keyed on its directory" $+            forAll genPath $ \(d, name) ->+                let preds = [fmap describe' (opAct o) | o <- predecessorsOf (FS.filecontentsInSharedDir (FS.FileContents (d </> name) ("x" :: Text)))]+                 in preds === [Just ("shared-directory", mkRef "shared-directory" d)]+        , testCase "a shared directory and a plain one of the same path do not conflict" $ do+            let both = op "both" (deps [FS.sharedDir (FS.Directory "/srv/x"), FS.dir (FS.Directory "/srv/x")]) id+            assertEqual "" 0 (length (Dag.dagConflicts (Dag.foldDag Dag.sameRepresentative (evalDeps both))))+        , testGroup+            "builtins writing into shared system directories"+            [ testCase "haproxy's config stands on a shared /etc/haproxy" $+                assertOnSharedDir "/etc/haproxy" (Haproxy.configFiles (Haproxy.patroniRouter [Haproxy.Member "n1" "10.0.0.1" 5432 8008]))+            , testCase "pgbouncer's ini and userlist stand on a shared /etc/pgbouncer" $+                assertOnSharedDir "/etc/pgbouncer" (PgBouncer.configFiles bouncer)+            , testCase "nginx's vhosts stand on a shared sites directory" $+                assertOnSharedDir "/etc/nginx/conf.d" (Nginx.configFiles nginx)+            , testCase "Initialize's sudoers file stands on a shared /etc/sudoers.d" $+                assertOnSharedDir "/etc/sudoers.d" (Initialize.initialize silent Nothing)+            ]+        ]+  where+    describe :: Op -> Maybe (Text, Ref, Text, [Text])+    describe o = fmap (\act -> (act.shorthand, act.extension.ref, act.extension.help, act.extension.notes)) (opAct o)++    describe' :: Act Extension -> (Text, Ref)+    describe' act = (act.shorthand, act.extension.ref)++    predecessorsOf :: Op -> [Op]+    predecessorsOf o = toList (runIdentity o.predecessors)++    genPath :: Gen (FilePath, FilePath)+    genPath = do+        segs <- listOf1 (listOf1 (elements "abcxyz-_"))+        name <- listOf1 (elements "abcxyz-_.")+        pure ("/" <> foldr1 (\a b -> a <> "/" <> b) segs, name)++    bouncer :: PgBouncer.BouncerConfig+    bouncer =+        PgBouncer.BouncerConfig+            { PgBouncer.bouncer_config_dir = "/etc/pgbouncer"+            , PgBouncer.bouncer_listen_addr = "0.0.0.0"+            , PgBouncer.bouncer_listen_port = 6432+            , PgBouncer.bouncer_databases = [PgBouncer.BouncerDatabase "app" (PgBouncer.UpstreamDb "10.0.0.1" 5432 "app")]+            , PgBouncer.bouncer_users = [PgBouncer.AuthUser "router" "hunter2"]+            , PgBouncer.bouncer_pool_mode = PgBouncer.TransactionPooling+            , PgBouncer.bouncer_max_client_conn = 100+            , PgBouncer.bouncer_default_pool_size = 20+            , PgBouncer.bouncer_admin_users = []+            , PgBouncer.bouncer_routing_file = Just "/etc/pgbouncer/routing.ini"+            }++    nginx :: Nginx.NginxConfig+    nginx =+        Nginx.NginxConfig+            { Nginx.nginx_sites_dir = "/etc/nginx/conf.d"+            , Nginx.nginx_vhosts =+                [ Nginx.VHost "a.example" 80 Nginx.Http [Nginx.Upstream "10.0.0.1" 8080]+                , Nginx.VHost "b.example" 80 Nginx.Http [Nginx.Upstream "10.0.0.2" 8080]+                ]+            }++{- | The node's graph declares one 'FS.sharedDir' of @path@ and no 'FS.dir'+of it.+-}+assertOnSharedDir :: FilePath -> Op -> Assertion+assertOnSharedDir path o = do+    let acts = Map.elems (Dag.dagNodes (Dag.foldDag Dag.sameRepresentative (evalDeps o)))+        refs = [(act.shorthand, act.extension.ref) | act <- acts]+    assertEqual "shared-directory nodes of the path" 1 (length (filter (== ("shared-directory", mkRef "shared-directory" path)) refs))+    assertEqual "plain directory nodes of the path" 0 (length (filter ((== mkRef "directory" path) . snd) refs))++shorthandOf :: UpDown.Report Extension -> Text+shorthandOf rep = case rep of+    UpDown.Failed act _ -> act.shorthand+    UpDown.Blocked act -> act.shorthand+    _ -> ""++isBad :: UpDown.Report Extension -> Bool+isBad rep = case rep of+    UpDown.Failed _ _ -> True+    UpDown.Blocked _ -> True+    _ -> False+
test/Test/StatusSinkSpec.hs view
@@ -17,7 +17,7 @@ module Test.StatusSinkSpec (tests) where  import Control.Concurrent (forkIO, threadDelay)-import Control.Concurrent.MVar (newEmptyMVar, putMVar)+import Control.Concurrent.MVar (newEmptyMVar, putMVar, takeMVar, tryPutMVar) import Control.Concurrent.STM (TChan, atomically, newTChanIO, readTChan, writeTChan) import Control.Exception (SomeException, throwIO, try) import Data.IORef (IORef, atomicModifyIORef', modifyIORef', newIORef, readIORef, writeIORef)@@ -53,10 +53,10 @@ import Salmon.Op.Configure (Configure (..)) import Salmon.Op.Ref (mkRef) import Salmon.Op.Track (Track (..))-import Salmon.Reporter (Reporter, contramap, reportBoth, silent)+import Salmon.Reporter (Reporter, contramap, reportBoth, runReporter, silent) import qualified Salmon.Reporter.Tagged as Tagged -import Test.Harness (capture, withTempDir)+import Test.Harness (capture, replaceFile, withTempDir)  tests :: TestTree tests =@@ -64,6 +64,7 @@         "Salmon.Actions.Serve.StatusSink and Salmon.Actions.Fleet"         [ testCase "two loops, one registry, one sink directory: each document names its own host, label, id and mode; rewritten after a typed convergence and after an injection; the fold shows both" twoHostsOneDirectory         , testCase "an unwritable sink path is reported once and the loop keeps serving" unwritableSink+        , testCase "the sink stops when its body returns even with the writer mid-write, and the cancel is no failed write" cancelledMidWrite         , testCase "the fold: both hosts, the label filter, the stale flag, the node counts" pureFold         , testCase "a URL sink POSTs the same document to a server, after each trigger and on the interval" postedSink         , testCase "a URL sink that is refused is reported once, the loop keeps serving, and a later answer of 2xx resumes it" refusedPostSink@@ -88,7 +89,7 @@   where     parsed args =         case Opt.execParserPure Opt.defaultPrefs (Opt.info CommandLine.runCommandParser mempty) args of-            Opt.Success (CommandLine.RunServe _ _ _ _ _ _ _ _ _ sink _) -> pure sink+            Opt.Success (CommandLine.RunServe _ _ _ _ _ _ _ _ _ sink _ _ _ _) -> pure sink             Opt.Success other -> assertFailure ("not a serve command: " <> show other)             Opt.Failure f -> assertFailure ("parse failed: " <> fst (Opt.renderFailure f "salmon"))             Opt.CompletionInvoked _ -> assertFailure "completion"@@ -167,7 +168,7 @@                 , Follow.followVerify = Follow.noVerifier                 }         followed = Follow.followed pk modeVar appliedVar-        own = Tagged.reportTexts serveReporter nodeReporter silent followReporter+        own = Tagged.reportTexts serveReporter nodeReporter silent followReporter silent         cfg = StatusSink.Config{StatusSink.configPath = sinkPath, StatusSink.configInterval = 1000000, StatusSink.configHost = host}         driver =             Driver@@ -176,11 +177,14 @@                 , followReports = readFollow                 }     resultVar <- newTChanIO+    loopVar <- newEmptyMVar     _ <- forkIO $ do         outcome <- try (body driver)         atomically (writeTChan stdinChan Nothing)         atomically (writeTChan resultVar outcome)-    w <- StatusSink.withSink cfg (Just followed) own $ \sink -> do+    -- the loop and its sink on a thread of their own, so that one that does+    -- not stop is a failure that says so rather than a suite that never ends+    _ <- forkIO $ (putMVar loopVar =<<) . try $ StatusSink.withSink cfg (Just followed) own $ \sink -> do         let tagged = reportBoth own (StatusSink.sinkReporter sink)             producers =                 [ Follow.follower (Tagged.followStream tagged) pk modeVar appliedVar follow (putMVar gate ())@@ -199,6 +203,11 @@             (Just followed)             producers     outcome <- atomically (readTChan resultVar)+    ended <- timeout (20 * 1000000) (takeMVar loopVar)+    w <- case ended of+        Nothing -> assertFailure ("host " <> Text.unpack host <> ": the loop and its sink did not stop within 20s of the end of input")+        Just (Left (ex :: SomeException)) -> throwIO ex+        Just (Right w) -> pure w     case outcome of         Left (ex :: SomeException) -> throwIO ex         Right a -> (,,,) w <$> readServe <*> readFollow <*> pure a@@ -224,7 +233,7 @@ publish :: FilePath -> Label -> Text -> [[String]] -> IO () publish root lbl did seeds = do     createDirectoryIfMissing True (registryDir root)-    LByteString.writeFile (Follow.documentPath (registryDir root) lbl) (encode (Document did (fmap SeedWords seeds) Nothing))+    replaceFile (Follow.documentPath (registryDir root) lbl) (encode (Document did (fmap SeedWords seeds) Nothing))  waitFor :: String -> IO Bool -> IO () waitFor what cond = do@@ -329,6 +338,41 @@         pure $ case eitherDecode bytes of             Right (Object o) -> KeyMap.lookup key o             _ -> Nothing++{- | What hung the Layer 0 suite about one run in a hundred: the writer+caught the cancel 'StatusSink.withSink' ends it with as one more failed+write ("SinkFailed ... AsyncCancelled"), went on looping, and 'withAsync''s+uninterruptible wait for it never returned, so neither did the loop's+@quit@ — and nothing could interrupt the test either. Here the status read+is made to block, so the cancel lands inside the write every time rather+than once in a hundred. 'withSink' runs on its own thread so that a+regression is a failure with a message, not a hang.+-}+cancelledMidWrite :: IO ()+cancelledMidWrite =+    withTempDir $ \root -> do+        (own, readOwn) <- capture :: IO (Reporter Tagged.Tagged, IO [Tagged.Tagged])+        entered <- newEmptyMVar+        finished <- newEmptyMVar+        let cfg = StatusSink.Config{StatusSink.configPath = root </> "zeta.json", StatusSink.configInterval = 1000000, StatusSink.configHost = "zeta"}+            slowWorld = do+                _ <- tryPutMVar entered ()+                threadDelay (30 * 1000000)+                pure (Serve.emptyWorld :: World Spec Spec)+        _ <- forkIO $ do+            outcome <- try $ StatusSink.withSink cfg Nothing own $ \sink -> do+                StatusSink.sinkObserver sink slowWorld+                -- a pass ending wakes the writer at once+                runReporter (StatusSink.sinkReporter sink) (Tagged.FromServe (Serve.ConvergeStop True 0))+                takeMVar entered+            putMVar finished (outcome :: Either SomeException ())+        ended <- timeout (10 * 1000000) (takeMVar finished)+        case ended of+            Nothing -> assertFailure "withSink did not return: the writer swallowed its cancel"+            Just (Left ex) -> assertFailure ("withSink threw: " <> show ex)+            Just (Right ()) -> pure ()+        reports <- readOwn+        assertEqual "the cancel is not reported as a failed write" [] [() | Tagged.FromServe Serve.SinkFailed{} <- reports]  {- | The sink's directory is a regular file, so neither the temp file nor the rename can succeed. One report, then silence; the loop still answers. -}
+ test/Test/SystemdJobSpec.hs view
@@ -0,0 +1,1332 @@+{-# LANGUAGE OverloadedStrings #-}++{- | "Salmon.Builtin.Nodes.Systemd.Job" and the job half of+"Salmon.Builtin.Nodes.Podman.Quadlet".++'tests' is Layer 0 (what is rendered, what is refused, what the check+concludes, how the nodes are keyed) plus two groups that hand the rendered+files to the tools that read them, skipped loudly where the tool is missing:+@systemd-analyze verify@ for the service and its timer, and podman's quadlet+generator in dry-run for the container job. Neither starts anything.++'userTests' is Layer 2: the nodes through 'upTree' and 'downTree' against+this user's own systemd manager, in user scope, with no root and no+container. It asserts what the haddock claims: installing a job runs+nothing, a second pass is a skip, the timer is enabled and waiting, a+changed schedule re-arms it, a changed command is re-installed without+being run, 'Job.runJob' runs the command with its words+unexpanded and fails when the command does, and @down@ removes all of it and+can be run twice. Every unit gets a random name and a bracket that removes it+whatever the case did. The next two cases are 'Job.completedRun': a job with+a stamp is run once and then skipped, run again when a file it stands on is+written, fails every pass while its last run failed (also once systemd has+forgotten the failure), and a job with a waiting timer and no stamp is+remembered by systemd alone. The fifth is 'Job.launchedRun': a job of a few+seconds is started and the pass returns while it runs, a second pass starts+nothing, a later one finds it completed, and a failing one is found failed+and not run again until a file it stands on is written.++The pure half of 'Job.launchedRun' is also held by QuickCheck properties+('launchProperties') over generated @systemctl show@ output: its check+answers what 'Job.completedRun''s does, and nothing running or completed is+ever started.++Not run anywhere: system scope, a timer actually firing, a reboot (what+systemd forgets at one is imitated with @reset-failed@), and a container job+actually running (the generator's output is as far as that goes).+-}+module Test.SystemdJobSpec (tests, userTests) where++import Control.Concurrent (threadDelay)+import Control.Exception (bracket)+import Control.Monad (forM_, void, when)+import Data.List (isInfixOf, sort)+import qualified Data.Map.Strict as Map+import Data.Text (Text)+import qualified Data.Text as Text+import qualified Data.Text.IO as Text+import Data.Time (UTCTime, addUTCTime, defaultTimeLocale, formatTime)+import Data.Time.Clock.POSIX (getPOSIXTime, posixSecondsToUTCTime)+import Numeric (showHex)+import System.Directory (XdgDirectory (XdgConfig), doesDirectoryExist, doesFileExist, findExecutable, getHomeDirectory, getModificationTime, getXdgDirectory, removeFile)+import System.Environment (getEnvironment)+import System.Exit (ExitCode (..))+import System.FilePath ((</>))+import System.IO (hPutStrLn, stderr)+import System.Posix.Process (getProcessID)+import System.Posix.User (getRealUserID)+import System.Process (CmdSpec (..), CreateProcess (..), proc, readCreateProcessWithExitCode, readProcessWithExitCode)+import Test.Tasty (DependencyType (..), TestTree, sequentialTestGroup, testGroup)+import Test.QuickCheck (Gen, choose, counterexample, elements, forAll, frequency, listOf, listOf1, oneof, property, shuffle, (.&&.), (=/=), (===), (==>))+import Test.Tasty.HUnit (assertBool, assertEqual, assertFailure, testCase, testCaseSteps)+import Test.Tasty.QuickCheck (testProperty)++import Salmon.Actions.UpDown (CheckResult (..))+import qualified Salmon.Actions.UpDown as UpDown+import Salmon.Builtin.Extension+import Salmon.Builtin.Nodes.Binary (Command (..))+import qualified Salmon.Builtin.Nodes.Podman as Podman+import qualified Salmon.Builtin.Nodes.Podman.Quadlet as Quadlet+import qualified Salmon.Builtin.Nodes.Systemd as Systemd+import qualified Salmon.Builtin.Nodes.Systemd.Job as Job+import Salmon.Op.Actions (Act (..))+import qualified Salmon.Op.Dag as Dag+import Salmon.Op.OpGraph (inject)+import Salmon.Op.Ref (mkRef)+import Salmon.Reporter (silent)+import Test.Harness (runDownCapturing, runUpCapturing, withTempDir)++tests :: TestTree+tests =+    testGroup+        "Salmon.Builtin.Nodes.Systemd.Job"+        [ testGroup "command words" wordTests+        , testGroup "job rendering" jobRenderTests+        , testGroup "timer rendering" timerRenderTests+        , testGroup "refusals" problemTests+        , testGroup "check" checkTests+        , testGroup "last run" lastRunTests+        , testGroup "nodes" nodeTests+        , testGroup "launched run" launchTests+        , testGroup "launched run (properties)" launchProperties+        , testGroup "container job" containerTests+        , testGroup "systemd-analyze" verifyTests+        , testGroup "quadlet generator" generatorTests+        ]++backup :: Job.Job+backup =+    (Job.job "backup" ["/usr/local/bin/backup", "--to", "/srv/backups", "two words"])+        { Job.jobDescription = "nightly backup"+        , Job.jobAfter = ["network-online.target", "postgresql.service"]+        , Job.jobUser = Just "backup"+        , Job.jobGroup = Just "backup"+        , Job.jobWorkingDir = Just "/srv"+        , Job.jobEnvironment = [("PGHOST", "/run/postgresql"), ("NOTE", "50% done")]+        , Job.jobEnvFile = Just "/etc/backup/env"+        , Job.jobTimeout = Just 3600+        }++nightly :: Job.Timer+nightly =+    (Job.jobTimer backup [Job.OnCalendar "*-*-* 03:00:00", Job.OnBootSec "15min"])+        { Job.timerPersistent = True+        , Job.timerRandomizedDelay = Just "10min"+        }++wordTests :: [TestTree]+wordTests =+    [ testCase "a plain word is left alone" $+        assertEqual "" "--to" (Systemd.literalArg "--to")+    , testCase "a word with whitespace is one word" $+        assertEqual "" "\"two words\"" (Systemd.literalArg "two words")+    , testCase "a dollar and a percent reach the program" $+        -- systemd substitutes both inside quotes too+        assertEqual "" "\"echo $$HOME %%h $${X}\"" (Systemd.literalArg "echo $HOME %h ${X}")+    , testCase "quotes and backslashes are escaped" $+        assertEqual "" "\"a\\\"b\\\\c\"" (Systemd.literalArg "a\"b\\c")+    , testCase "an empty word stays a word, and a semicolon does not end the command" $ do+        assertEqual "" "\"\"" (Systemd.literalArg "")+        assertEqual "" "\";\"" (Systemd.literalArg ";")+    , testCase "an authored service's words are quoted as they were" $ do+        assertEqual "" "plain" (Systemd.quoteArg "plain")+        assertEqual "" "\"console=ttyS0 root=/dev/root\"" (Systemd.quoteArg "console=ttyS0 root=/dev/root")+    ]++jobRenderTests :: [TestTree]+jobRenderTests =+    [ testCase "a full job, key by key" $+        assertEqual+            ""+            ( Text.unlines+                [ "[Unit]"+                , "Description=nightly backup"+                , "After=network-online.target postgresql.service"+                , ""+                , "[Service]"+                , "Type=oneshot"+                , "User=backup"+                , "Group=backup"+                , "WorkingDirectory=/srv"+                , "Environment=PGHOST=/run/postgresql"+                , "Environment=\"NOTE=50%% done\""+                , "EnvironmentFile=/etc/backup/env"+                , "TimeoutStartSec=3600"+                , "ExecStart=/usr/local/bin/backup --to /srv/backups \"two words\""+                ]+            )+            (Job.renderJobService backup)+    , testCase "the smallest job" $+        assertEqual+            ""+            (Text.unlines ["[Unit]", "Description=job tidy (salmon)", "", "[Service]", "Type=oneshot", "ExecStart=/usr/bin/tidy"])+            (Job.renderJobService (Job.job "tidy" ["/usr/bin/tidy"]))+    , testCase "nothing starts a job at boot" $+        assertBool "" (not ("[Install]" `Text.isInfixOf` Job.renderJobService backup))+    , testCase "a user-scope job names no user" $ do+        let rendered = Job.renderJobService backup{Job.jobScope = Systemd.User}+        assertBool (Text.unpack rendered) (not ("User=" `Text.isInfixOf` rendered))+        assertBool (Text.unpack rendered) (not ("Group=" `Text.isInfixOf` rendered))+    , testCase "the unit and its file are named after the job" $ do+        assertEqual "" "backup.service" (Job.jobServiceTarget backup)+        assertEqual "" "/etc/systemd/system/backup.service" (Job.jobServicePath backup)+    ]++timerRenderTests :: [TestTree]+timerRenderTests =+    [ testCase "a full timer, key by key" $+        assertEqual+            ""+            ( Text.unlines+                [ "[Unit]"+                , "Description=runs backup.service (salmon)"+                , ""+                , "[Timer]"+                , "Unit=backup.service"+                , "OnCalendar=*-*-* 03:00:00"+                , "OnBootSec=15min"+                , "Persistent=true"+                , "RandomizedDelaySec=10min"+                , ""+                , "[Install]"+                , "WantedBy=timers.target"+                ]+            )+            (Job.renderTimer nightly)+    , testCase "every kind of schedule has its key" $ do+        let rendered =+                Job.renderTimer+                    ( Job.timer+                        "t"+                        "x.service"+                        [Job.OnStartupSec "1min", Job.OnUnitActiveSec "1h", Job.OnUnitInactiveSec "30s"]+                    )+        forM_ ["OnStartupSec=1min", "OnUnitActiveSec=1h", "OnUnitInactiveSec=30s"] $ \line ->+            assertBool (Text.unpack line) (line `elem` Text.lines rendered)+        assertBool "a timer that makes up nothing does not say Persistent" (not ("Persistent" `Text.isInfixOf` rendered))+    , testCase "a job's timer shares its name, scope and directory" $ do+        let j = backup{Job.jobScope = Systemd.User, Job.jobUnitDir = "/home/u/.config/systemd/user"}+            tm = Job.jobTimer j [Job.OnCalendar "daily"]+        assertEqual "" "backup.timer" (Job.timerTarget tm)+        assertEqual "" "/home/u/.config/systemd/user/backup.timer" (Job.timerPath tm)+        assertEqual "" Systemd.User tm.timerScope+        assertEqual "" "backup.service" tm.timerTriggers+    , testCase "a timer can trigger a container job's service" $ do+        let tm = Job.timer "report" (Quadlet.serviceTarget report) [Job.OnCalendar "weekly"]+        assertBool "" ("Unit=report.service" `elem` Text.lines (Job.renderTimer tm))+    ]++problemTests :: [TestTree]+problemTests =+    [ testCase "the examples have none" $ do+        assertEqual "" [] (Job.jobProblems backup)+        assertEqual "" [] (Job.timerProblems nightly)+        assertEqual "" [] (Quadlet.containerProblems report)+    , testCase "a job needs a name that is a unit name" $ do+        assertBool "" (not (null (Job.jobProblems backup{Job.jobName = ""})))+        assertBool "" (not (null (Job.jobProblems backup{Job.jobName = "a b"})))+        assertBool "" (not (null (Job.jobProblems backup{Job.jobName = "../etc/x"})))+    , testCase "a job needs a command, by absolute path" $ do+        assertBool "" (not (null (Job.jobProblems backup{Job.jobCommand = []})))+        assertBool "" (not (null (Job.jobProblems backup{Job.jobCommand = ["backup", "--now"]})))+    , testCase "a line break anywhere is refused" $ do+        assertBool "" (not (null (Job.jobProblems backup{Job.jobCommand = ["/bin/sh", "-c", "a\nExecStart=/bin/rm -rf /"]})))+        assertBool "" (not (null (Job.jobProblems backup{Job.jobDescription = "x\n[Service]"})))+        assertBool "" (not (null (Job.jobProblems backup{Job.jobEnvironment = [("A", "b\nc")]})))+        assertBool "" (not (null (Job.timerProblems nightly{Job.timerSchedules = [Job.OnCalendar "daily\nUnit=other.service"]})))+    , testCase "an environment name is one word with no equals sign" $ do+        assertBool "" (not (null (Job.jobProblems backup{Job.jobEnvironment = [("", "x")]})))+        assertBool "" (not (null (Job.jobProblems backup{Job.jobEnvironment = [("A=B", "x")]})))+    , testCase "a timer needs a schedule, and something else to trigger" $ do+        assertBool "" (not (null (Job.timerProblems nightly{Job.timerSchedules = []})))+        assertBool "" (not (null (Job.timerProblems nightly{Job.timerSchedules = [Job.OnCalendar " "]})))+        assertBool "" (not (null (Job.timerProblems nightly{Job.timerTriggers = ""})))+        assertBool "" (not (null (Job.timerProblems nightly{Job.timerTriggers = "backup.timer"})))+    , testCase "problems are collected, not first-found" $+        assertBool "" (length (Job.jobProblems backup{Job.jobName = "", Job.jobCommand = []}) >= 2)+    , testCase "a job node with problems refuses to write its file" $ withTempDir $ \dir -> do+        let j = backup{Job.jobUnitDir = dir, Job.jobCommand = ["relative"]}+        reports <- runUpCapturing (Job.unitFile silent Systemd.User (Job.jobServicePath j) (Job.jobProblems j) (Job.renderJobService j))+        assertBool "the file node did not fail" (not (null [() | UpDown.Failed _ _ <- reports]))+        assertBool "the file was written" . not =<< doesFileExist (Job.jobServicePath j)+    ]++checkTests :: [TestTree]+checkTests =+    [ testCase "loaded as written is installed, running or not" $+        assertEqual "" Success (Systemd.interpretLoaded ["LoadState=loaded", "NeedDaemonReload=no"])+    , testCase "a file changed since it was loaded needs a reload" $+        assertBool "" (isFailure (Systemd.interpretLoaded ["LoadState=loaded", "NeedDaemonReload=yes"]))+    , testCase "a unit systemd has never heard of is not installed" $+        assertBool "" (isFailure (Systemd.interpretLoaded ["LoadState=not-found", "NeedDaemonReload=no"]))+    , testCase "a unit systemd refuses is not installed" $ do+        assertBool "" (isFailure (Systemd.interpretLoaded ["LoadState=bad-setting", "NeedDaemonReload=no"]))+        assertBool "" (isFailure (Systemd.interpretLoaded ["LoadState=masked", "NeedDaemonReload=no"]))+    , testCase "no answer is not an installed unit" $+        assertBool "" (isFailure (Systemd.interpretLoaded []))+    , testCase "an idle job is not a broken service" $+        -- the reason a job has a check of its own: the service one would+        -- restart it at every pass+        assertBool "" (isFailure (Systemd.interpretShow ["ActiveState=inactive", "UnitFileState=static", "NeedDaemonReload=no"]))+    , testCase "a waiting timer is a running unit" $+        assertEqual "" Success (Systemd.interpretShow ["ActiveState=active", "UnitFileState=enabled", "NeedDaemonReload=no"])+    , testCase "the new systemctl calls" $ do+        assertEqual "" ["--user", "start", "x.service"] (argv (Systemd.StartUnit Systemd.User "x.service"))+        assertEqual "" ["disable", "--now", "x.timer"] (argv (Systemd.Disable Systemd.System "x.timer"))+    ]+  where+    argv call = case cmdspec (prepare Systemd.callSystemctl call) of+        RawCommand _ args -> args+        ShellCommand line -> [line]++{- | @systemctl --user show --timestamp=us+utc@ on systemd 255, for the+properties 'Job.lastRunProperties' names: a oneshot unit a waiting timer+keeps loaded after a successful run, one whose run exited 1, and one with+nothing referring to it after a successful run (the same lines as one never+run).+-}+ranOk, ranBadly, forgotten :: [Text]+ranOk =+    [ "Result=success"+    , "ExecMainStartTimestamp=Mon 2026-10-05 09:56:02.675581 UTC"+    , "ExecMainStatus=0"+    , "LoadState=loaded"+    , "ActiveState=inactive"+    ]+ranBadly =+    [ "Result=exit-code"+    , "ExecMainStartTimestamp=Mon 2026-10-05 09:56:02.744529 UTC"+    , "ExecMainStatus=1"+    , "LoadState=loaded"+    , "ActiveState=failed"+    ]+forgotten =+    [ "Result=success"+    , "ExecMainStartTimestamp="+    , "ExecMainStatus=0"+    , "LoadState=loaded"+    , "ActiveState=inactive"+    ]++-- | When 'ranOk' started, and that many seconds from it.+ranAt :: UTCTime+ranAt = posixSecondsToUTCTime 1791194162.675581++after, before :: Double -> UTCTime+after seconds = addUTCTime (realToFrac seconds) ranAt+before seconds = after (negate seconds)++lastRunTests :: [TestTree]+lastRunTests =+    [ testCase "the timestamp systemd prints is read to the microsecond" $+        assertEqual "" (Just ranAt) (Job.parseTimestamp "Mon 2026-10-05 09:56:02.675581 UTC")+    , testCase "a run that succeeded is a completed job, not a stopped service" $+        assertEqual "" Completed (Job.interpretLastRun Job.noEvidence ranOk)+    , testCase "a run that failed is a failure naming how" $+        case Job.interpretLastRun Job.noEvidence ranBadly of+            Failure why -> do+                assertBool (Text.unpack why) ("exit-code" `Text.isInfixOf` why)+                assertBool (Text.unpack why) ("exit status 1" `Text.isInfixOf` why)+            other -> assertFailure (show other)+    , testCase "a failed run is not excused by an older success" $+        assertBool "" (isFailure (Job.interpretLastRun Job.noEvidence{Job.evidenceSucceeded = Just (before 60)} ranBadly))+    , testCase "a result other than success fails whatever the active state" $+        assertBool "" (isFailure (Job.interpretLastRun Job.noEvidence (map (Text.replace "Result=success" "Result=timeout") ranOk)))+    , testCase "a job systemd has no run of has not run" $+        assertBool "" (isFailure (Job.interpretLastRun Job.noEvidence forgotten))+    , testCase "a run under way is neither" $+        assertEqual "" Unknown (Job.interpretLastRun Job.noEvidence (map (Text.replace "ActiveState=inactive" "ActiveState=activating") ranOk))+    , testCase "a unit systemd does not hold has not run" $ do+        assertBool "" (isFailure (Job.interpretLastRun Job.noEvidence (map (Text.replace "LoadState=loaded" "LoadState=not-found") forgotten)))+        assertBool "" (isFailure (Job.interpretLastRun Job.noEvidence []))+    , testCase "a run older than a file it stands on does not count, and the file is named" $ do+        let evidence = Job.noEvidence{Job.evidenceWritten = [("/etc/backup/env", before 5), ("/usr/local/bin/backup", after 0.5)]}+        case Job.interpretLastRun evidence ranOk of+            Failure why -> assertBool (Text.unpack why) ("/usr/local/bin/backup" `Text.isInfixOf` why)+            other -> assertFailure (show other)+    , testCase "a run newer than every file counts" $+        assertEqual "" Completed (Job.interpretLastRun Job.noEvidence{Job.evidenceWritten = [("/etc/backup/env", before 5), ("/x", before 0.001)]} ranOk)+    , testCase "the stamp stands in for what systemd forgot" $ do+        assertEqual "" Completed (Job.interpretLastRun Job.noEvidence{Job.evidenceSucceeded = Just ranAt} forgotten)+        assertEqual "" Completed (Job.interpretLastRun (Job.RunEvidence [("/x", before 1)] (Just ranAt) Nothing) forgotten)+        assertBool "" (isFailure (Job.interpretLastRun (Job.RunEvidence [("/x", after 1)] (Just ranAt) Nothing) forgotten))+    , testCase "the later of systemd's run and the stamp's is the last one" $ do+        assertEqual "" Completed (Job.interpretLastRun (Job.RunEvidence [("/x", before 1)] (Just (before 3600)) Nothing) ranOk)+        assertEqual "" Completed (Job.interpretLastRun (Job.RunEvidence [("/x", after 1)] (Just (after 2)) Nothing) ranOk)+    , testCase "a run that started and left no stamp failed, though systemd forgot it" $ do+        assertBool "" (isFailure (Job.interpretLastRun (Job.RunEvidence [] (Just ranAt) (Just (after 60))) forgotten))+        assertBool "" (isFailure (Job.interpretLastRun (Job.RunEvidence [] Nothing (Just ranAt)) forgotten))+    , testCase "a running stamp left by an older failure does not undo a later success" $+        assertEqual "" Completed (Job.interpretLastRun (Job.RunEvidence [] (Just ranAt) (Just (before 60))) forgotten)+    , testCase "a timestamp that cannot be read is not a missing run" $+        assertEqual "" Unknown (Job.interpretLastRun Job.noEvidence (map (Text.replace "Mon 2026-10-05 09:56:02.675581 UTC" "lundi matin") ranOk))+    , testCase "a stamped job records its runs around the command" $ do+        let ls = Text.lines (Job.renderJobService backup{Job.jobStamp = Just "/var/lib/backup/last run"})+            execs = [l | l <- ls, "Exec" `Text.isPrefixOf` l]+        assertEqual+            ""+            [ "ExecStartPre=touch \"/var/lib/backup/last run.running\""+            , "ExecStart=/usr/local/bin/backup --to /srv/backups \"two words\""+            , "ExecStartPost=mv -f \"/var/lib/backup/last run.running\" \"/var/lib/backup/last run\""+            ]+            execs+    , testCase "a job with no stamp is rendered as before" $+        assertBool "" (not ("ExecStartP" `Text.isInfixOf` Job.renderJobService backup))+    , testCase "a stamp that is not an absolute path, or holds a line break, is refused" $ do+        assertBool "" (not (null (Job.jobProblems backup{Job.jobStamp = Just "stamp"})))+        assertBool "" (not (null (Job.jobProblems backup{Job.jobStamp = Just "/a\nExecStart=/bin/evil"})))+        assertEqual "" [] (Job.jobProblems backup{Job.jobStamp = Just "/var/lib/backup/stamp"})+    , testCase "a job's run is judged against its unit file, its environment file and its stamp" $+        assertEqual+            ""+            (Job.Completion Systemd.System "backup.service" ["/etc/systemd/system/backup.service", "/etc/backup/env"] (Just "/s"))+            (Job.jobCompletion backup{Job.jobStamp = Just "/s"})+    , testCase "a completed job is the run's effect site, standing on the installed job" $ do+        assertEqual "" (refOf (runNode backup)) (refOf (completedNode backup))+        let dag = Dag.foldDag Dag.sameRepresentative (evalDeps (completedNode backup))+        assertEqual "" ["file-contents", "systemd-job", "systemd-job-completed", "systemd-unit-dir"] (sort [act.shorthand | act <- Map.elems (Dag.dagNodes dag)])+    , testCase "what a run is compared with is visible in the node's description" $+        assertBool "" (notesOf (completedNode backup) /= notesOf (completedNode backup{Job.jobEnvFile = Nothing}))+    ]+  where+    refOf o = fmap (\act -> act.extension.ref) (opAct o)+    notesOf o = fmap (\act -> act.extension.notes) (opAct o)++completedNode :: Job.Job -> Op+completedNode = Job.completedJob silent ignoreTrack ignoreTrack++nodeTests :: [TestTree]+nodeTests =+    [ testCase "a job is keyed on its unit, not on its command" $ do+        assertEqual "" (refOf (jobNode backup)) (refOf (jobNode backup{Job.jobCommand = ["/bin/true"]}))+        assertBool "" (refOf (jobNode backup) /= refOf (jobNode backup{Job.jobName = "other"}))+    , testCase "a changed command is visible in the job's description" $+        -- so `run serve` sees the re-declaration as a change+        assertBool "" (notesOf (jobNode backup) /= notesOf (jobNode backup{Job.jobCommand = ["/bin/true"]}))+    , testCase "a changed schedule is visible in the timer's description" $+        assertBool "" (notesOf (timerNode nightly) /= notesOf (timerNode nightly{Job.timerSchedules = [Job.OnCalendar "hourly"]}))+    , testCase "the job, its timer and a run of it are three nodes" $ do+        let refs = [refOf (jobNode backup), refOf (timerNode nightly), refOf (runNode backup)]+        assertBool "a node has no ref" (Nothing `notElem` refs)+        assertBool "two of them share a ref" (and [x /= y | (i, x) <- zip [0 :: Int ..] refs, (k, y) <- zip [0 ..] refs, i /= k])+    , testCase "a scheduled job is the timer standing on the job and both files" $ do+        let dag = Dag.foldDag Dag.sameRepresentative (evalDeps (scheduled backup))+            names = [act.shorthand | act <- Map.elems (Dag.dagNodes dag)]+        assertEqual "" ["file-contents", "file-contents", "systemd-job", "systemd-timer", "systemd-unit-dir"] (sort names)+        assertEqual "the shared directory was described two ways" 0 (length (Dag.dagConflicts dag))+    , testCase "two jobs in one directory share it without a conflict" $ do+        let both = op "both" (deps [scheduled backup, scheduled backup{Job.jobName = "other"}]) id+            dag = Dag.foldDag Dag.sameRepresentative (evalDeps both)+        assertEqual "" 0 (length (Dag.dagConflicts dag))+        assertEqual "" 1 (length [() | act <- Map.elems (Dag.dagNodes dag), act.shorthand == "systemd-unit-dir"])+    , testCase "a container job is keyed on its unit, and is not the service node" $ do+        assertEqual "" (refOf (containerNode report)) (refOf (containerNode report{Quadlet.containerExec = ["/bin/true"]}))+        assertEqual "" (Just "podman-quadlet-job") (fmap (\act -> act.shorthand) (opAct (containerNode report)))+        assertBool "" (notesOf (containerNode report) /= notesOf (containerNode report{Quadlet.containerExec = ["/bin/true"]}))+    ]+  where+    refOf o = fmap (\act -> act.extension.ref) (opAct o)+    notesOf o = fmap (\act -> act.extension.notes) (opAct o)++jobNode :: Job.Job -> Op+jobNode = Job.jobService silent ignoreTrack ignoreTrack++timerNode :: Job.Timer -> Op+timerNode = Job.timerUnit silent ignoreTrack ignoreTrack++runNode :: Job.Job -> Op+runNode j = Job.runJob silent ignoreTrack j.jobScope (Job.jobServiceTarget j)++scheduled :: Job.Job -> Op+scheduled j = Job.scheduledJob silent ignoreTrack ignoreTrack j [Job.OnCalendar "*-*-* 03:00:00"]++containerNode :: Quadlet.Container -> Op+containerNode = Quadlet.quadletJob silent ignoreTrack ignoreTrack++-------------------------------------------------------------------------------++{- | @systemctl --user show --timestamp=us+utc@ on systemd 255 for+'Job.runStateProperties', captured around a @start --no-block@ of a oneshot+unit that sleeps and exits 3: right after the start, and once it had failed.+-}+launching, launchedBadly :: [Text]+launching =+    [ "Result=success"+    , "ExecMainStartTimestamp=Sun 2026-10-11 08:12:59.712251 UTC"+    , "ExecMainStatus=0"+    , "LoadState=loaded"+    , "ActiveState=activating"+    , "InactiveEnterTimestamp="+    ]+launchedBadly =+    [ "Result=exit-code"+    , "ExecMainStartTimestamp=Sun 2026-10-11 08:12:59.712251 UTC"+    , "ExecMainStatus=3"+    , "LoadState=loaded"+    , "ActiveState=failed"+    , "InactiveEnterTimestamp=Sun 2026-10-11 08:13:01.716922 UTC"+    ]++-- | When 'launchedBadly' failed, and that many seconds from it.+failedAt :: UTCTime+failedAt = posixSecondsToUTCTime 1791706381.716922++sinceFailure :: Double -> UTCTime+sinceFailure seconds = addUTCTime (realToFrac seconds) failedAt++stamped :: Job.Job+stamped = backup{Job.jobStamp = Just "/var/lib/backup/stamp"}++launchedNode :: Job.Polling -> Job.Job -> Op+launchedNode = Job.launchedJob silent ignoreTrack ignoreTrack++launchTests :: [TestTree]+launchTests =+    [ testCase "the start that does not wait" $ do+        assertEqual "" ["start", "--no-block", "x.service"] (argv (Systemd.StartUnitNoBlock Systemd.System "x.service"))+        assertEqual "" ["--user", "start", "--no-block", "x.service"] (argv (Systemd.StartUnitNoBlock Systemd.User "x.service"))+    , testCase "the start that waits is the one it was" $+        assertEqual "" ["start", "x.service"] (argv (Systemd.StartUnit Systemd.System "x.service"))+    , testCase "the failure's time is read" $+        assertEqual "" (Just failedAt) (Job.parseTimestamp "Sun 2026-10-11 08:13:01.716922 UTC")+    , testCase "a run under way is running, and is left alone" $ do+        assertEqual "" Job.Running (Job.classifyRun Job.noEvidence launching)+        assertEqual "" Job.LeaveRunning (Job.launchDecision Job.Running)+        assertEqual "" Job.Pending (Job.runOutcome Job.Running)+        assertEqual "the check restarts nothing under serve" Unknown (Job.runVerdict Job.Running)+    , testCase "a run under way is running whatever its stamps say" $+        -- the running stamp is newer than the stamp for as long as a run lasts+        assertEqual "" Job.Running (Job.classifyRun (Job.RunEvidence [] (Just (before 60)) (Just ranAt)) launching)+    , testCase "a job never run is started" $ do+        assertEqual "" Job.NeverRan (Job.classifyRun Job.noEvidence forgotten)+        assertEqual "" Job.Start (Job.launchDecision Job.NeverRan)+    , testCase "a job that ran is not started again" $ do+        assertEqual "" Job.Succeeded (Job.classifyRun Job.noEvidence{Job.evidenceSucceeded = Just ranAt} forgotten)+        assertEqual "" Job.LeaveDone (Job.launchDecision Job.Succeeded)+        assertEqual "" Job.Finished (Job.runOutcome Job.Succeeded)+    , testCase "a run older than a file it stands on is started again" $ do+        let st = Job.classifyRun (Job.RunEvidence [("/etc/backup/env", after 1)] (Just ranAt) Nothing) forgotten+        assertEqual "" (Job.Outdated "/etc/backup/env") st+        assertEqual "" Job.Start (Job.launchDecision st)+    , testCase "a failed run is reported, not run again" $ do+        let st = Job.classifyRun Job.noEvidence launchedBadly+        assertEqual "" (Job.LastFailed "the last run failed: Result=exit-code, exit status 3" Nothing) st+        assertEqual "" (Job.ReportFailure "the last run failed: Result=exit-code, exit status 3") (Job.launchDecision st)+        assertEqual "" (Job.Broken "the last run failed: Result=exit-code, exit status 3") (Job.runOutcome st)+    , testCase "a failed run is not excused by files written before it failed" $+        assertEqual+            ""+            (Job.LastFailed "the last run failed: Result=exit-code, exit status 3" Nothing)+            -- written while it ran: after it started, before it failed+            (Job.classifyRun Job.noEvidence{Job.evidenceWritten = [("/x", sinceFailure (-1)), ("/y", sinceFailure (-3600))]} launchedBadly)+    , testCase "a file written since the failure has the job run again, and is named" $ do+        let st = Job.classifyRun Job.noEvidence{Job.evidenceWritten = [("/x", sinceFailure (-1)), ("/etc/backup/env", sinceFailure 0.5)]} launchedBadly+        assertEqual "" (Job.LastFailed "the last run failed: Result=exit-code, exit status 3" (Just "/etc/backup/env")) st+        assertEqual "" Job.Start (Job.launchDecision st)+        assertEqual "started, and not yet shown as running" Job.Pending (Job.runOutcome st)+    , testCase "a failure systemd forgot is reported from the running stamp" $ do+        let left = Job.RunEvidence [("/x", before 1)] Nothing (Just ranAt)+        assertEqual "" (Job.LastFailed "the last run that started did not succeed" Nothing) (Job.classifyRun left forgotten)+        assertEqual+            ""+            (Job.LastFailed "the last run that started did not succeed" (Just "/x"))+            (Job.classifyRun left{Job.evidenceWritten = [("/x", after 1)]} forgotten)+    , testCase "a failure whose time systemd does not give is reported, not retried" $ do+        let untimed = filter (not . ("InactiveEnterTimestamp" `Text.isPrefixOf`)) launchedBadly+        assertEqual+            ""+            (Job.ReportFailure "the last run failed: Result=exit-code, exit status 3")+            (Job.launchDecision (Job.classifyRun Job.noEvidence{Job.evidenceWritten = [("/x", sinceFailure 3600)]} untimed))+    , testCase "what cannot be read starts nothing" $ do+        let st = Job.classifyRun Job.noEvidence (map (Text.replace "Mon 2026-10-05 09:56:02.675581 UTC" "lundi matin") ranOk)+        assertEqual "" Job.Unreadable st+        assertEqual "" Job.CannotTell (Job.launchDecision st)+        assertEqual "" Job.Undetermined (Job.runOutcome st)+    , testCase "a unit systemd does not hold is left to systemctl to refuse" $ do+        assertEqual "" (Job.NotLoaded "the unit is not-found") (Job.classifyRun Job.noEvidence ["LoadState=not-found"])+        assertEqual "" Job.Start (Job.launchDecision (Job.NotLoaded "the unit is not-found"))+    , testCase "no wait asks once" $+        assertEqual "" [] (Job.pollDelays Job.noWait)+    , testCase "a wait is cut into pauses" $ do+        assertEqual "" [5, 5, 2] (Job.pollDelays (Job.Polling 12 5))+        assertEqual "" [1, 1, 1] (Job.pollDelays (Job.Polling 3 0))+        assertEqual "" [] (Job.pollDelays (Job.Polling (-4) 5))+    , testCase "a run that is not waited for needs a stamp" $ do+        assertBool "" (not (null (Job.launchProblems (Job.jobCompletion backup))))+        assertEqual "" [] (Job.launchProblems (Job.jobCompletion stamped))+    , testCase "a job still running says so, and not that it failed" $ do+        let said = show (Job.RunInProgress "backup.service")+        assertBool said ("in progress" `isInfixOf` said)+        assertBool said ("backup.service" `isInfixOf` said)+    , testCase "a launched job is the run's effect site, standing on the installed job" $ do+        assertEqual "" (refOf (runNode stamped)) (refOf (launchedNode Job.noWait stamped))+        assertEqual "" (refOf (completedNode stamped)) (refOf (launchedNode Job.noWait stamped))+        let dag = Dag.foldDag Dag.sameRepresentative (evalDeps (launchedNode Job.noWait stamped))+        assertEqual "" ["file-contents", "systemd-job", "systemd-job-launched", "systemd-unit-dir"] (sort [act.shorthand | act <- Map.elems (Dag.dagNodes dag)])+    , testCase "a launched job is told from a waited one, and a wait from none" $ do+        assertBool "" (helpOf (launchedNode Job.noWait stamped) /= helpOf (completedNode stamped))+        assertEqual "" (notesOf (completedNode stamped)) (notesOf (launchedNode Job.noWait stamped))+        assertEqual+            ""+            (Just ["newer than: /etc/systemd/system/backup.service", "newer than: /etc/backup/env", "stamp: /var/lib/backup/stamp", "waits: up to 600s, asking every 10s"])+            (notesOf (launchedNode (Job.Polling 600 10) stamped))+    , testCase "the waited nodes describe themselves as they did" $ do+        -- written out, not computed: a changed description restarts nothing+        -- here, but it is a changed representative under `run serve`+        assertEqual "" (Just "runs backup.service to completion") (helpOf (runNode backup))+        assertEqual "" (Just []) (notesOf (runNode backup))+        assertEqual "" (Just "has backup.service run to completion since it was last changed") (helpOf (completedNode stamped))+        assertEqual+            ""+            (Just ["newer than: /etc/systemd/system/backup.service", "newer than: /etc/backup/env", "stamp: /var/lib/backup/stamp"])+            (notesOf (completedNode stamped))+        assertEqual "" (Just (mkRef "systemd-job-run" ("backup.service" :: Text))) (refOf (completedNode stamped))+    , testCase "the existing check asks systemd what it asked" $+        assertEqual "" ["LoadState", "ActiveState", "Result", "ExecMainStatus", "ExecMainStartTimestamp"] Job.lastRunProperties+    ]+  where+    refOf o = fmap (\act -> act.extension.ref) (opAct o)+    notesOf o = fmap (\act -> act.extension.notes) (opAct o)+    helpOf o = fmap (\act -> act.extension.help) (opAct o)+    argv call = case cmdspec (prepare Systemd.callSystemctl call) of+        RawCommand _ args -> args+        ShellCommand line -> [line]++-- | A time within a quarter of an hour of 'ranAt', to the microsecond.+genTime :: Gen UTCTime+genTime = do+    micros <- choose (-900000000, 900000000 :: Integer)+    pure (addUTCTime (fromInteger micros / 1000000) ranAt)++-- | As @--timestamp=us+utc@ prints one.+printTimestamp :: UTCTime -> Text+printTimestamp = Text.pack . formatTime defaultTimeLocale "%a %Y-%m-%d %H:%M:%S%Q UTC"++genEvidence :: Gen Job.RunEvidence+genEvidence =+    Job.RunEvidence+        <$> listOf ((,) <$> elements ["/etc/backup/env", "/usr/local/bin/backup", "/etc/systemd/system/backup.service"] <*> genTime)+        <*> oneof [pure Nothing, Just <$> genTime]+        <*> oneof [pure Nothing, Just <$> genTime]++{- | What @systemctl show@ may print for 'Job.runStateProperties': each+property with one of the values systemd has for it, an empty one, an+unreadable one, or not there at all, in any order.+-}+genShow :: Gen [Text]+genShow = do+    ls <-+        sequence+            [ line "LoadState" (frequency [(6, pure "loaded"), (1, elements ["not-found", "masked", "bad-setting", ""])])+            , line "ActiveState" (frequency [(4, pure "inactive"), (2, pure "failed"), (2, pure "activating"), (1, elements ["active", "deactivating", "reloading", ""])])+            , line "Result" (frequency [(4, pure "success"), (2, elements ["exit-code", "timeout", "signal", ""])])+            , line "ExecMainStatus" (elements ["0", "1", "3", ""])+            , line "ExecMainStartTimestamp" genStamp+            , line "InactiveEnterTimestamp" genStamp+            ]+    shuffle (concat ls)+  where+    line :: Text -> Gen Text -> Gen [Text]+    line name value = frequency [(9, (\v -> [name <> "=" <> v]) <$> value), (1, pure [])]++    genStamp :: Gen Text+    genStamp = frequency [(5, printTimestamp <$> genTime), (2, pure ""), (1, pure "n/a"), (1, pure "lundi matin")]++genCompletion :: Gen Job.Completion+genCompletion =+    Job.Completion+        <$> elements [Systemd.System, Systemd.User]+        <*> ((<> ".service") <$> genWord)+        <*> listOf (path <$> genWord)+        <*> oneof [pure Nothing, Just . path <$> genWord]+  where+    path w = "/var/lib/" <> Text.unpack w++genWord :: Gen Text+genWord = Text.pack <$> listOf1 (elements (['a' .. 'z'] <> ['0' .. '9'] <> "-_@"))++genPolling :: Gen Job.Polling+genPolling = Job.Polling <$> choose (-50, 5000) <*> choose (-5, 120)++launchProperties :: [TestTree]+launchProperties =+    [ testProperty "a timestamp is read back as printed" $+        forAll genTime $ \t -> Job.parseTimestamp (printTimestamp t) === Just t+    , testProperty "the launched node's check answers what the waited node's does" $+        forAll genEvidence $ \evidence -> forAll genShow $ \ls ->+            Job.runVerdict (Job.classifyRun evidence ls) === Job.interpretLastRun evidence ls+    , testProperty "the waited node's check does not read when the unit came to rest" $+        forAll genEvidence $ \evidence -> forAll genShow $ \ls ->+            Job.interpretLastRun evidence (filter (not . ("InactiveEnterTimestamp" `Text.isPrefixOf`)) ls) === Job.interpretLastRun evidence ls+    , testProperty "the order systemd prints in does not matter" $+        forAll genEvidence $ \evidence -> forAll genShow $ \ls -> forAll (shuffle ls) $ \again ->+            Job.classifyRun evidence again === Job.classifyRun evidence ls+    , testProperty "only a job at rest whose last run does not count is started" $+        forAll genEvidence $ \evidence -> forAll genShow $ \ls ->+            let st = Job.classifyRun evidence ls+                verdict = Job.interpretLastRun evidence ls+             in counterexample (show st) $ case Job.launchDecision st of+                    Job.Start -> property (isFailure verdict)+                    Job.LeaveDone -> verdict === Completed+                    Job.LeaveRunning -> verdict === Unknown+                    Job.CannotTell -> verdict === Unknown+                    Job.ReportFailure why -> verdict === Failure why+    , testProperty "a loaded unit between states is never started" $+        forAll genEvidence $ \evidence -> forAll genShow $ \ls -> forAll (elements ["activating", "deactivating", "reloading"]) $ \active ->+            let moving = ["LoadState=loaded", "ActiveState=" <> active] <> ls+             in Job.launchDecision (Job.classifyRun evidence moving) === Job.LeaveRunning+    , testProperty "a completed job is never started" $+        forAll genEvidence $ \evidence -> forAll genShow $ \ls ->+            case Job.interpretLastRun evidence ls of+                Completed -> Job.launchDecision (Job.classifyRun evidence ls) === Job.LeaveDone+                _ -> property True+    , testProperty "with nothing written, a failed run is reported and never run again" $+        forAll genEvidence $ \evidence -> forAll genShow $ \ls ->+            case Job.classifyRun evidence{Job.evidenceWritten = []} ls of+                st@(Job.LastFailed why since) ->+                    counterexample (show st) $ since === Nothing .&&. Job.launchDecision st === Job.ReportFailure why+                _ -> property True+    , testProperty "a file is named as written since only when it is newer than the failure" $+        forAll genEvidence $ \evidence -> forAll genShow $ \ls ->+            case Job.classifyRun evidence ls of+                Job.LastFailed _ (Just path) -> property (path `elem` map fst evidence.evidenceWritten)+                Job.Outdated path -> property (path `elem` map fst evidence.evidenceWritten)+                _ -> property True+    , testProperty "a failure systemd timed is retried once a file is written after it" $+        forAll genEvidence $ \evidence -> forAll genShow $ \ls -> forAll genTime $ \at ->+            let timed = ["InactiveEnterTimestamp=" <> printTimestamp at] <> ls+                late = evidence{Job.evidenceWritten = evidence.evidenceWritten <> [("/late", addUTCTime 3600 ranAt)]}+             in case Job.classifyRun late timed of+                    st@(Job.LastFailed _ since)+                        -- the running stamp's failure is timed by the stamp+                        | "the last run failed" `Text.isPrefixOf` reason st ->+                            counterexample (show st) $ property (since /= Nothing) .&&. Job.launchDecision st === Job.Start+                    _ -> property True+    , testProperty "writing a file never turns a job that should run into one that should not" $+        forAll genEvidence $ \evidence -> forAll genShow $ \ls ->+            let late = evidence{Job.evidenceWritten = evidence.evidenceWritten <> [("/late", addUTCTime 3600 ranAt)]}+             in case Job.launchDecision (Job.classifyRun evidence ls) of+                    Job.Start -> Job.launchDecision (Job.classifyRun late ls) === Job.Start+                    _ -> property True+    , testProperty "an outcome is a finished run exactly when the check says completed" $+        forAll genEvidence $ \evidence -> forAll genShow $ \ls ->+            let st = Job.classifyRun evidence ls+             in (Job.runOutcome st == Job.Finished) === (Job.interpretLastRun evidence ls == Completed)+    , testProperty "an outcome is a failure only for a failure nobody answered" $+        forAll genEvidence $ \evidence -> forAll genShow $ \ls ->+            let st = Job.classifyRun evidence ls+             in case Job.runOutcome st of+                    Job.Broken why -> st === Job.LastFailed why Nothing+                    Job.Pending -> property (Job.launchDecision st `elem` [Job.Start, Job.LeaveRunning])+                    Job.Finished -> st === Job.Succeeded+                    Job.Undetermined -> st === Job.Unreadable+    , testProperty "the pauses add up to the wait, none longer than asked" $+        forAll genPolling $ \p ->+            let pauses = Job.pollDelays p+             in counterexample (show pauses) $+                    sum pauses === max 0 p.pollingWait+                        .&&. property (all (\d -> d >= 1 && d <= max 1 p.pollingEvery) pauses)+    , testProperty "only the last pause is a short one" $+        forAll genPolling $ \p ->+            case reverse (Job.pollDelays p) of+                [] -> property True+                (_ : earlier) -> property (all (== max 1 p.pollingEvery) earlier)+    , testProperty "a stamp is what a launched run needs, and all it needs" $+        forAll genCompletion $ \c -> null (Job.launchProblems c) === (c.completionStamp /= Nothing)+    , testProperty "the launched node is keyed as the waited ones are, whatever the wait" $+        forAll genCompletion $ \c -> forAll genPolling $ \p ->+            let launched = Job.launchedRun silent ignoreTrack p c+                waited = Job.completedRun silent ignoreTrack c+                run = Job.runJob silent ignoreTrack c.completionScope c.completionUnit+             in refOf launched === refOf waited .&&. refOf launched === refOf run .&&. refOf launched === Just (mkRef "systemd-job-run" c.completionUnit)+    , testProperty "the launched node's description is the same at every rendering" $+        forAll genCompletion $ \c -> forAll genPolling $ \p ->+            let node = Job.launchedRun silent ignoreTrack p c+             in (helpOf node, notesOf node) === (helpOf (Job.launchedRun silent ignoreTrack p c), Just (Job.launchNotes p c))+    , testProperty "with no wait, the launched node's notes are the waited node's" $+        forAll genCompletion $ \c -> forAll genPolling $ \p ->+            let quiet = p{Job.pollingWait = min 0 p.pollingWait}+             in notesOf (Job.launchedRun silent ignoreTrack quiet c) === notesOf (Job.completedRun silent ignoreTrack c)+    , testProperty "a wait shows in the notes, so a changed one is a changed declaration" $+        forAll genCompletion $ \c -> forAll genPolling $ \p ->+            p.pollingWait > 0 ==>+                Job.launchNotes p c =/= Job.launchNotes p{Job.pollingWait = p.pollingWait + 1} c+    , testProperty "the waited node describes itself as before the launched one existed" $+        forAll genCompletion $ \c ->+            let waited = Job.completedRun silent ignoreTrack c+             in helpOf waited === Just ("has " <> c.completionUnit <> " run to completion since it was last changed")+                    .&&. notesOf waited+                        === Just+                            ( ["newer than: " <> Text.pack path | path <- c.completionWritten]+                                <> ["stamp: " <> Text.pack s | Just s <- [c.completionStamp]]+                            )+    , testProperty "the two starts differ by one word" $+        forAll genCompletion $ \c ->+            let scope = Systemd.scopeArgs c.completionScope+                unit = Text.unpack c.completionUnit+             in argv (Systemd.StartUnit c.completionScope c.completionUnit) === scope <> ["start", unit]+                    .&&. argv (Systemd.StartUnitNoBlock c.completionScope c.completionUnit) === scope <> ["start", "--no-block", unit]+    ]+  where+    refOf o = fmap (\act -> act.extension.ref) (opAct o)+    notesOf o = fmap (\act -> act.extension.notes) (opAct o)+    helpOf o = fmap (\act -> act.extension.help) (opAct o)+    reason (Job.LastFailed why _) = why+    reason _ = ""+    argv call = case cmdspec (prepare Systemd.callSystemctl call) of+        RawCommand _ args -> args+        ShellCommand line -> [line]++containerTests :: [TestTree]+containerTests =+    [ testCase "a container job, key by key" $+        assertEqual+            ""+            ( Text.unlines+                [ "[Unit]"+                , "Description=job report (salmon)"+                , ""+                , "[Container]"+                , "ContainerName=report"+                , "Image=docker.io/library/alpine:3"+                , "Exec=/bin/sh -c \"echo $$HOME 100%%\""+                , ""+                , "[Service]"+                , "Type=oneshot"+                , "Restart=no"+                ]+            )+            (Quadlet.renderContainer report)+    , testCase "a service container says nothing new" $ do+        let rendered = Quadlet.renderContainer (Quadlet.container (Podman.ContainerName "web") "docker.io/library/nginx:1.27")+        assertBool "" (not ("Exec=" `Text.isInfixOf` rendered))+        assertBool "" (not ("Type=" `Text.isInfixOf` rendered))+    , testCase "a service container can be given a command too" $ do+        let c = (Quadlet.container (Podman.ContainerName "web") "img:1"){Quadlet.containerExec = ["serve", "--port", "80"]}+        assertBool "" ("Exec=serve --port 80" `elem` Text.lines (Quadlet.renderContainer c))+    , testCase "a job restarted always is refused, on failure is not" $ do+        assertBool "" (not (null (Quadlet.containerProblems report{Quadlet.containerRestart = Quadlet.RestartAlways})))+        assertEqual "" [] (Quadlet.containerProblems report{Quadlet.containerRestart = Quadlet.RestartOnFailure})+    , testCase "a line break in the command is refused" $+        assertBool "" (not (null (Quadlet.containerProblems report{Quadlet.containerExec = ["sh", "-c", "a\nImage=evil"]})))+    , testCase "a generated unit that would run the declared quadlet is installed, one made from another is not" $ do+        -- the shape `systemctl show --property=ExecStart --value` prints,+        -- written out by hand; the user-scope case reads the real one+        let shown fp = "{ path=/usr/bin/podman ; argv[]=/usr/bin/podman run --name=report --cidfile=/run/user/1000/report.cid --replace --rm --cgroups=split --label salmon.quadlet=" <> fp <> " docker.io/library/alpine:3 /bin/sh -c exit 0 ; ignore_errors=no ; start_time=[n/a] ; stop_time=[n/a] ; pid=0 ; code=(null) ; status=0/0 }\n"+        assertEqual "" Success (Quadlet.interpretGenerated "ztmC9FbSzVMf" (shown "ztmC9FbSzVMf"))+        assertBool "" (isFailure (Quadlet.interpretGenerated "ztmC9FbSzVMf" (shown "aTe0H65oUH_B")))+        assertBool "" (isFailure (Quadlet.interpretGenerated "ztmC9FbSzVMf" ""))+    , testCase "a stamped container job records its runs around the container, on the host" $+        assertEqual+            ""+            ( Text.unlines+                [ "[Unit]"+                , "Description=job report (salmon)"+                , ""+                , "[Container]"+                , "ContainerName=report"+                , "Image=docker.io/library/alpine:3"+                , "Exec=/bin/sh -c \"echo $$HOME 100%%\""+                , ""+                , "[Service]"+                , "Type=oneshot"+                , "Restart=no"+                , "ExecStartPre=touch \"/var/lib/report/last run.running\""+                , "ExecStartPost=mv -f \"/var/lib/report/last run.running\" \"/var/lib/report/last run\""+                ]+            )+            (Quadlet.renderContainer report{Quadlet.containerStamp = Just "/var/lib/report/last run"})+    , testCase "a container job with no stamp is described and keyed as before" $ do+        -- the hash of the text "a container job, key by key" pins, worked+        -- out outside this code+        assertEqual "" (Just ["image: docker.io/library/alpine:3", "quadlet: ztmC9FbSzVMf"]) (notesOf (containerNode report))+        assertEqual "" (Just (mkRef "systemd-unit" ("report.service" :: Text))) (refOf (containerNode report))+        assertEqual "" (refOf (containerNode report)) (refOf (containerNode stamped))+        assertBool "a stamp is not visible in the job's description" (notesOf (containerNode report) /= notesOf (containerNode stamped))+    , testCase "a stamp that is not an absolute path, or holds a line break, is refused" $ do+        assertEqual "" [] (Quadlet.containerProblems stamped)+        assertBool "" (not (null (Quadlet.containerProblems report{Quadlet.containerStamp = Just "stamp"})))+        assertBool "" (not (null (Quadlet.containerProblems report{Quadlet.containerStamp = Just "/a\nExecStart=/bin/evil"})))+    , testCase "a container job's run is judged against its quadlet, what it reads at start and its stamp" $+        assertEqual+            ""+            (Job.Completion Systemd.System "report.service" ["/etc/containers/systemd/report.container", "/etc/report/env", "/etc/report/conf"] (Just "/s"))+            (Quadlet.containerCompletion stamped{Quadlet.containerEnvFile = Just "/etc/report/env", Quadlet.containerWatched = ["/etc/report/conf"]})+    , testCase "a completed container job is the run's effect site, standing on the installed job" $ do+        let completed = Quadlet.completedQuadletJob silent ignoreTrack ignoreTrack stamped+            dag = Dag.foldDag Dag.sameRepresentative (evalDeps completed)+        assertEqual "" (refOf (Job.runJob silent ignoreTrack Systemd.System "report.service")) (refOf completed)+        assertEqual "" ["file-contents", "podman-quadlet-dir", "podman-quadlet-job", "systemd-job-completed"] (sort [act.shorthand | act <- Map.elems (Dag.dagNodes dag)])+        assertEqual "" 0 (length (Dag.dagConflicts dag))+        assertEqual "" (Just ["newer than: /etc/containers/systemd/report.container", "stamp: /s"]) (notesOf completed)+    ]+  where+    stamped = report{Quadlet.containerStamp = Just "/s"}+    refOf o = fmap (\act -> act.extension.ref) (opAct o)+    notesOf o = fmap (\act -> act.extension.notes) (opAct o)++-------------------------------------------------------------------------------++verifyTests :: [TestTree]+verifyTests =+    [ testCase "systemd reads the job and its timer without complaint" $ withAnalyze $ withTempDir $ \dir -> do+        let j = (Job.job "salmon-verify-job" ["/bin/sh", "-c", "echo $HOME 100%; exit 0", ""]){Job.jobScope = Systemd.User, Job.jobUnitDir = dir, Job.jobEnvironment = [("A", "b c"), ("P", "1%")]}+            tm = (Job.jobTimer j [Job.OnCalendar "Mon..Fri *-*-* 03:00:00", Job.OnBootSec "15min"]){Job.timerPersistent = True, Job.timerRandomizedDelay = Just "10min", Job.timerAccuracy = Just "1s"}+        Text.writeFile (Job.jobServicePath j) (Job.renderJobService j)+        Text.writeFile (Job.timerPath tm) (Job.renderTimer tm)+        (code, out, err) <- readProcessWithExitCode "systemd-analyze" ["verify", "--user", "--recursive-errors=no", Job.jobServicePath j, Job.timerPath tm] ""+        let said = out <> err+        assertEqual said ExitSuccess code+        assertBool said (not ("salmon-verify-job" `isInfixOf` said))+    , testCase "systemd refuses a schedule that means nothing, which is what fails up" $ withAnalyze $ withTempDir $ \dir -> do+        let j = (Job.job "salmon-verify-bad" ["/bin/true"]){Job.jobScope = Systemd.User, Job.jobUnitDir = dir}+            tm = Job.jobTimer j [Job.OnCalendar "tuesdayish"]+        assertEqual "the pure check does not judge calendar expressions" [] (Job.timerProblems tm)+        Text.writeFile (Job.jobServicePath j) (Job.renderJobService j)+        Text.writeFile (Job.timerPath tm) (Job.renderTimer tm)+        (code, out, err) <- readProcessWithExitCode "systemd-analyze" ["verify", "--user", "--recursive-errors=no", Job.timerPath tm] ""+        assertBool (out <> err) (code /= ExitSuccess)+    ]+  where+    withAnalyze :: IO () -> IO ()+    withAnalyze act = do+        found <- findExecutable "systemd-analyze"+        case found of+            Just _ -> act+            Nothing -> hPutStrLn stderr "SKIPPED: no systemd-analyze on PATH; the rendered units were not read by systemd"++generatorPath :: FilePath+generatorPath = "/usr/libexec/podman/quadlet"++generatorTests :: [TestTree]+generatorTests =+    [ testCase "podman's generator turns the job into a foreground oneshot run" $ withGenerator $ withTempDir $ \dir -> do+        let c = report{Quadlet.containerUnitDir = dir}+        Text.writeFile (Quadlet.quadletPath c) =<< Quadlet.renderQuadlet c+        environment <- getEnvironment+        (code, out, err) <-+            readCreateProcessWithExitCode+                (proc generatorPath ["--user", "--dryrun"]){env = Just (("QUADLET_UNIT_DIRS", dir) : environment)}+                ""+        let said = out <> err+            start = [l | l <- lines said, "ExecStart=" `isInfixOf` l]+        assertEqual said ExitSuccess code+        assertBool said ("---report.service---" `isInfixOf` said)+        assertBool said (not ("unsupported key" `isInfixOf` said))+        assertBool said ("Type=oneshot" `elem` lines said)+        case start of+            [l] -> do+                -- detached, `systemctl start` would return before the command ran+                assertBool l (not (" -d " `isInfixOf` l))+                assertBool l ("docker.io/library/alpine:3 /bin/sh -c \"echo $$HOME 100%%\"" `isInfixOf` l)+            other -> assertFailure ("not exactly one ExecStart: " <> show other)+    , testCase "podman's generator keeps the stamp's two commands around its own" $ withGenerator $ withTempDir $ \dir -> do+        let c = report{Quadlet.containerUnitDir = dir, Quadlet.containerStamp = Just (dir </> "last run")}+        Text.writeFile (Quadlet.quadletPath c) =<< Quadlet.renderQuadlet c+        environment <- getEnvironment+        (code, out, err) <-+            readCreateProcessWithExitCode+                (proc generatorPath ["--user", "--dryrun"]){env = Just (("QUADLET_UNIT_DIRS", dir) : environment)}+                ""+        let said = out <> err+            execs = [takeWhile (/= '=') l | l <- lines said, "ExecStart" `isInfixOf` l]+        assertEqual said ExitSuccess code+        assertBool said (not ("unsupported key" `isInfixOf` said))+        assertEqual said ["ExecStart", "ExecStartPost", "ExecStartPre"] (sort execs)+        assertBool said (("ExecStartPre=touch \"" <> dir </> "last run.running\"") `elem` lines said)+        assertBool said (("ExecStartPost=mv -f \"" <> dir </> "last run.running\" \"" <> dir </> "last run\"") `elem` lines said)+    ]+  where+    withGenerator :: IO () -> IO ()+    withGenerator act = do+        present <- doesFileExist generatorPath+        if present+            then act+            else hPutStrLn stderr ("SKIPPED: no quadlet generator at " <> generatorPath <> "; the container job was not checked against podman")++report :: Quadlet.Container+report = Quadlet.containerJob (Podman.ContainerName "report") "docker.io/library/alpine:3" ["/bin/sh", "-c", "echo $HOME 100%"]++isFailure :: CheckResult -> Bool+isFailure (Failure _) = True+isFailure _ = False++-------------------------------------------------------------------------------++userTests :: TestTree+userTests =+    sequentialTestGroup+        "Salmon.Builtin.Nodes.Systemd.Job (user scope, Layer 2)"+        AllFinish+        [ testCaseSteps "installed without running, scheduled, re-armed, removed" $ \step ->+            withUserUnits $ \unitDir -> withTempDir $ \tmp -> withFreshJob unitDir (tmp </> "out") $ \j -> lifecycle step (tmp </> "out") j+        , testCaseSteps "a run waits for the command and fails when it does" $ \step ->+            withUserUnits $ \unitDir -> withTempDir $ \tmp -> withFreshJob unitDir (tmp </> "out") $ \j -> running step (tmp </> "out") j+        , testCaseSteps "a stamped job runs once, again after a change, and fails while its last run did" $ \step ->+            withUserUnits $ \unitDir -> withTempDir $ \tmp -> withFreshJob unitDir (tmp </> "out") $ \j -> completing step tmp j+        , testCaseSteps "a job with a waiting timer is remembered by systemd alone" $ \step ->+            withUserUnits $ \unitDir -> withTempDir $ \tmp -> withFreshJob unitDir (tmp </> "out") $ \j -> remembered step (tmp </> "out") j+        , testCaseSteps "a launched job is started without being waited for, and found completed or failed later" $ \step ->+            withUserUnits $ \unitDir -> withTempDir $ \tmp -> withFreshJob unitDir (tmp </> "out") $ \j -> launched step tmp j+        ]++lifecycle :: (String -> IO ()) -> FilePath -> Job.Job -> IO ()+lifecycle step out j = do+    let tm = Job.jobTimer j [Job.OnCalendar "*-*-* 03:00:00"]+        nodeOf job' schedule = Job.scheduledJob silent ignoreTrack ignoreTrack job' [Job.OnCalendar schedule]+        node = nodeOf j++    step "first up"+    reports <- runUpCapturing (node "*-*-* 03:00:00")+    assertUp reports+    assertEqual "the timer was not applied once" 1 (count "systemd-timer" isEval reports)+    -- the job itself may be a skip already: asked about a file in its search+    -- path that it has not read yet, systemd reads it there and then+    assertEqual "the job node was not visited once" 1 (count "systemd-job" isEval reports + count "systemd-job" isSkip reports)+    assertBool "no service file" =<< doesFileExist (Job.jobServicePath j)+    assertBool "no timer file" =<< doesFileExist (Job.timerPath tm)+    assertEqual "" "loaded" =<< showProperty (Job.jobServiceTarget j) "LoadState"+    assertEqual "installing the job started it" "inactive" =<< showProperty (Job.jobServiceTarget j) "ActiveState"+    assertBool "installing the job ran it" . not =<< doesFileExist out+    assertEqual "the timer is not waiting" "active" =<< showProperty (Job.timerTarget tm) "ActiveState"+    assertEqual "the timer is not enabled" "enabled" =<< showProperty (Job.timerTarget tm) "UnitFileState"+    assertEqual "the timer triggers something else" (Job.jobServiceTarget j) =<< showProperty (Job.timerTarget tm) "Unit"+    calendar <- showProperty (Job.timerTarget tm) "TimersCalendar"+    assertBool (Text.unpack calendar) ("03:00:00" `Text.isInfixOf` calendar)++    step "second up"+    again <- runUpCapturing (node "*-*-* 03:00:00")+    assertUp again+    assertEqual "the job was not skipped" (1, 0) (count "systemd-job" isSkip again, count "systemd-job" isEval again)+    assertEqual "the timer was not skipped" (1, 0) (count "systemd-timer" isSkip again, count "systemd-timer" isEval again)++    step "a new schedule"+    -- systemd compares mtimes; a rewrite inside the same second is not a change to it+    void (readProcessWithExitCode "sleep" ["1.1"] "")+    moved <- runUpCapturing (node "*-*-* 04:30:00")+    assertUp moved+    assertEqual "the timer was not re-applied" 1 (count "systemd-timer" isEval moved)+    assertEqual "the job was touched by a change to its timer" 0 (count "systemd-job" isEval moved)+    rearmed <- showProperty (Job.timerTarget tm) "TimersCalendar"+    assertBool (Text.unpack rearmed) ("04:30:00" `Text.isInfixOf` rearmed)+    assertBool "re-arming the timer ran the job" . not =<< doesFileExist out++    step "a new command"+    -- the waiting timer keeps the job's unit loaded, so this one is a+    -- changed file systemd has to be told about+    void (readProcessWithExitCode "sleep" ["1.1"] "")+    let changed = j{Job.jobCommand = ["/bin/sh", "-c", "exit 0"]}+    recommanded <- runUpCapturing (nodeOf changed "*-*-* 04:30:00")+    assertUp recommanded+    assertEqual "the changed job was not re-installed" 1 (count "systemd-job" isEval recommanded)+    assertEqual "" "no" =<< showProperty (Job.jobServiceTarget j) "NeedDaemonReload"+    started <- showProperty (Job.jobServiceTarget j) "ExecStart"+    assertBool (Text.unpack started) ("exit 0" `Text.isInfixOf` started)+    assertBool "re-installing the job ran it" . not =<< doesFileExist out++    step "down"+    down <- runDownCapturing (nodeOf changed "*-*-* 04:30:00")+    assertBool ("down failed: " <> show (failures down)) (null (failures down))+    assertBool "the service file is still there" . not =<< doesFileExist (Job.jobServicePath j)+    assertBool "the timer file is still there" . not =<< doesFileExist (Job.timerPath tm)+    assertEqual "the job outlived its file" "not-found" =<< showProperty (Job.jobServiceTarget j) "LoadState"+    assertEqual "the timer outlived its file" "not-found" =<< showProperty (Job.timerTarget tm) "LoadState"+    assertEqual "the timer is still waiting" "inactive" =<< showProperty (Job.timerTarget tm) "ActiveState"+    linked <- doesFileExist (j.jobUnitDir </> "timers.target.wants" </> Text.unpack (Job.timerTarget tm))+    assertBool "the timer is still linked into timers.target" (not linked)+    assertBool "the shared unit directory was removed" =<< doesDirectoryExist j.jobUnitDir++    step "down again"+    twice <- runDownCapturing (nodeOf changed "*-*-* 04:30:00")+    assertBool ("a second down failed: " <> show (failures twice)) (null (failures twice))++running :: (String -> IO ()) -> FilePath -> Job.Job -> IO ()+running step out j = do+    let run job' = Job.runJob silent ignoreTrack job'.jobScope (Job.jobServiceTarget job') `inject` jobNode job'++    step "a run"+    reports <- runUpCapturing (run j)+    assertUp reports+    home <- getHomeDirectory+    wrote <- Text.readFile out+    -- $HOME is the shell's to expand, %h nobody's+    assertEqual "the command did not get its words as declared" [Text.pack home, "%h 100%"] (Text.lines wrote)+    assertEqual "" "success" =<< showProperty (Job.jobServiceTarget j) "Result"++    step "a second pass runs it again"+    removeFile out+    again <- runUpCapturing (run j)+    assertUp again+    assertBool "the job did not run" =<< doesFileExist out++    step "a failing command"+    void (readProcessWithExitCode "sleep" ["1.1"] "")+    let failing = j{Job.jobCommand = ["/bin/sh", "-c", "exit 3"]}+    failed <- runUpCapturing (run failing)+    assertEqual "the failed run was not a failed up" ["systemd-job-run"] [act.shorthand | UpDown.Failed act _ <- failed]+    assertEqual "" "3" =<< showProperty (Job.jobServiceTarget j) "ExecMainStatus"++    step "down"+    down <- runDownCapturing (run failing)+    assertBool ("down failed: " <> show (failures down)) (null (failures down))+    assertEqual "" "not-found" =<< showProperty (Job.jobServiceTarget j) "LoadState"++completing :: (String -> IO ()) -> FilePath -> Job.Job -> IO ()+completing step tmp j0 = do+    let out = tmp </> "out"+        conf = tmp </> "conf"+        stamp = tmp </> "stamp"+        j = j0{Job.jobStamp = Just stamp}+        completion job' = let c = Job.jobCompletion job' in c{Job.completionWritten = c.completionWritten <> [conf]}+        node job' = Job.completedRun silent ignoreTrack (completion job') `inject` jobNode job'+        ran = "systemd-job-completed"+    Text.writeFile conf "v1"++    step "first up runs it"+    reports <- runUpCapturing (node j)+    assertUp reports+    assertEqual "the job was not run once" 1 (count ran isEval reports)+    assertBool "the job did not run" =<< doesFileExist out+    assertBool "the run left no stamp" =<< doesFileExist stamp+    assertBool "the run left its running stamp" . not =<< doesFileExist (Job.stampRunning stamp)+    -- with nothing referring to the unit systemd has already forgotten the run+    assertEqual "" "" =<< showProperty (Job.jobServiceTarget j) "ExecMainStartTimestamp"+    assertEqual "" Completed =<< Job.checkLastRun (completion j)++    step "second up skips it"+    removeFile out+    again <- runUpCapturing (node j)+    assertUp again+    assertEqual "the job was not skipped" (1, 0) (count ran isSkip again, count ran isEval again)+    assertBool "a completed job was run again" . not =<< doesFileExist out++    step "a file it stands on is written"+    Text.writeFile conf "v2"+    assertBool "a run older than its configuration counted" . isFailure =<< Job.checkLastRun (completion j)+    rerun <- runUpCapturing (node j)+    assertUp rerun+    assertEqual "the job was not run again" 1 (count ran isEval rerun)+    assertBool "the job did not run" =<< doesFileExist out+    assertEqual "" Completed =<< Job.checkLastRun (completion j)++    step "a failing command fails the pass"+    void (readProcessWithExitCode "sleep" ["1.1"] "")+    let failing = j{Job.jobCommand = ["/bin/sh", "-c", "exit 3"]}+    failed <- runUpCapturing (node failing)+    assertEqual "the failed run was not a failed up" [ran] [act.shorthand | UpDown.Failed act _ <- failed]+    verdict <- Job.checkLastRun (completion failing)+    case verdict of+        Failure why -> assertBool (Text.unpack why) ("exit status 3" `Text.isInfixOf` why)+        other -> assertFailure ("a failed job reads as " <> show other)++    step "and every pass after it"+    stillFailed <- runUpCapturing (node failing)+    assertEqual "a failed job was reported done" [ran] [act.shorthand | UpDown.Failed act _ <- stillFailed]++    step "also once systemd has forgotten the failure"+    void (readProcessWithExitCode "systemctl" ["--user", "reset-failed", Text.unpack (Job.jobServiceTarget j)] "")+    assertEqual "" "success" =<< showProperty (Job.jobServiceTarget j) "Result"+    assertBool "the failure was forgotten with systemd's record of it" . isFailure =<< Job.checkLastRun (completion failing)++    step "a run that succeeds clears it"+    void (readProcessWithExitCode "sleep" ["1.1"] "")+    fixed <- runUpCapturing (node j)+    assertUp fixed+    assertEqual "" Completed =<< Job.checkLastRun (completion j)++    step "down"+    down <- runDownCapturing (node j)+    assertBool ("down failed: " <> show (failures down)) (null (failures down))+    assertBool "the stamp is still there" . not =<< doesFileExist stamp+    assertEqual "" "not-found" =<< showProperty (Job.jobServiceTarget j) "LoadState"+    assertBool "a job that is gone reads as run" . isFailure =<< Job.checkLastRun (completion j)++remembered :: (String -> IO ()) -> FilePath -> Job.Job -> IO ()+remembered step out j = do+    let node = Job.completedScheduledJob silent ignoreTrack ignoreTrack j [Job.OnCalendar "*-*-* 03:00:00"]+        ran = "systemd-job-completed"++    step "first up runs it"+    reports <- runUpCapturing node+    assertUp reports+    assertEqual "the job was not run once" 1 (count ran isEval reports)+    assertBool "the job did not run" =<< doesFileExist out+    started <- showProperty (Job.jobServiceTarget j) "ExecMainStartTimestamp"+    assertBool "systemd forgot a run its timer refers to" (not (Text.null started))+    assertEqual "" Completed =<< Job.checkLastRun (Job.jobCompletion j)++    step "second up skips it"+    removeFile out+    again <- runUpCapturing node+    assertUp again+    assertEqual "the job was not skipped" (1, 0) (count ran isSkip again, count ran isEval again)+    assertBool "a completed job was run again" . not =<< doesFileExist out++    step "down"+    down <- runDownCapturing node+    assertBool ("down failed: " <> show (failures down)) (null (failures down))+    assertEqual "" "not-found" =<< showProperty (Job.jobServiceTarget j) "LoadState"++launched :: (String -> IO ()) -> FilePath -> Job.Job -> IO ()+launched step tmp j0 = do+    let out = tmp </> "out"+        tries = tmp </> "tries"+        conf = tmp </> "conf"+        stamp = tmp </> "stamp"+        sh script = ["/bin/sh", "-c", script]+        j = j0{Job.jobStamp = Just stamp, Job.jobCommand = sh ("sleep 3; echo run >> " <> Text.pack out)}+        failing = j{Job.jobCommand = sh ("echo try >> " <> Text.pack tries <> "; sleep 1; exit 3")}+        completion job' = let c = Job.jobCompletion job' in c{Job.completionWritten = c.completionWritten <> [conf]}+        node polling job' = Job.launchedRun silent ignoreTrack polling (completion job') `inject` jobNode job'+        ran = "systemd-job-launched"+        unit = Job.jobServiceTarget j+        linesOf path = do+            present <- doesFileExist path+            if present then length . Text.lines <$> Text.readFile path else pure (0 :: Int)+        failedWith name reports = [act.shorthand | UpDown.Failed act e <- reports, name `isInfixOf` show e]+        hold = Job.Polling{Job.pollingWait = 30, Job.pollingEvery = 1}+    Text.writeFile conf "v1"++    step "first up starts it and returns"+    reports <- runUpCapturing (node Job.noWait j)+    assertEqual ("the pass did not say the job is in progress: " <> show (failures reports)) [ran] (failedWith "RunInProgress" reports)+    assertEqual "something else failed" 1 (length (failures reports))+    assertEqual "up waited for the job" 0 =<< linesOf out+    waitFor "the run to show" (doesFileExist (Job.stampRunning stamp))+    assertEqual "" "activating" =<< showProperty unit "ActiveState"+    assertEqual "a run under way is not a verdict" Unknown =<< Job.checkLastRun (completion j)+    assertEqual "" Job.Running =<< Job.readRunState (completion j)+    began <- getModificationTime (Job.stampRunning stamp)++    step "a pass while it runs starts nothing"+    again <- runUpCapturing (node Job.noWait j)+    assertEqual ("the pass did not say the job is in progress: " <> show (failures again)) [ran] (failedWith "RunInProgress" again)+    assertEqual "the job was started over" began =<< getModificationTime (Job.stampRunning stamp)++    step "a pass after it ended finds it completed"+    waitFor "the run to end" (doesFileExist stamp)+    done <- runUpCapturing (node Job.noWait j)+    assertUp done+    assertEqual "the job was not skipped" (1, 0) (count ran isSkip done, count ran isEval done)+    assertEqual "the job ran more than once" 1 =<< linesOf out+    assertEqual "" Completed =<< Job.checkLastRun (completion j)++    step "a bounded wait sees a run through"+    Text.writeFile conf "v2"+    waited <- runUpCapturing (node hold j)+    assertUp waited+    assertEqual "the job was not run again" 1 (count ran isEval waited)+    assertEqual "up returned before the run ended" 2 =<< linesOf out+    assertEqual "" Job.Succeeded =<< Job.readRunState (completion j)++    step "a failing job is started like any other"+    void (readProcessWithExitCode "sleep" ["1.1"] "")+    first <- runUpCapturing (node Job.noWait failing)+    assertEqual ("the pass did not say the job is in progress: " <> show (failures first)) [ran] (failedWith "RunInProgress" first)++    step "and a later pass finds it failed, without running it again"+    waitFor "the run to fail" ((== "failed") <$> showProperty unit "ActiveState")+    broken <- runUpCapturing (node Job.noWait failing)+    assertEqual ("the failed run was not reported: " <> show (failures broken)) [ran] (failedWith "RunFailed" broken)+    assertBool (show (failures broken)) (any ("exit status 3" `isInfixOf`) (failures broken))+    assertEqual "a failed job was run again" 1 =<< linesOf tries++    step "also once systemd has forgotten the failure"+    void (readProcessWithExitCode "systemctl" ["--user", "reset-failed", Text.unpack unit] "")+    forgot <- runUpCapturing (node Job.noWait failing)+    assertEqual ("the failed run was not reported: " <> show (failures forgot)) [ran] (failedWith "RunFailed" forgot)+    assertEqual "a failed job was run again" 1 =<< linesOf tries++    step "a file written since has it run again"+    Text.writeFile conf "v3"+    retried <- runUpCapturing (node hold failing)+    assertEqual ("the second failure was not reported: " <> show (failures retried)) [ran] (failedWith "RunFailed" retried)+    assertEqual "the job was not tried again" 2 =<< linesOf tries++    step "a repaired job runs and completes"+    void (readProcessWithExitCode "sleep" ["1.1"] "")+    fixed <- runUpCapturing (node hold j)+    assertUp fixed+    assertEqual "" 3 =<< linesOf out+    assertEqual "" Completed =<< Job.checkLastRun (completion j)++    step "down"+    down <- runDownCapturing (node Job.noWait j)+    assertBool ("down failed: " <> show (failures down)) (null (failures down))+    assertBool "the stamp is still there" . not =<< doesFileExist stamp+    assertEqual "" "not-found" =<< showProperty unit "LoadState"++-- | Asks every tenth of a second, for twenty seconds at most.+waitFor :: String -> IO Bool -> IO ()+waitFor what ask = go (200 :: Int)+  where+    go 0 = assertFailure ("waited twenty seconds for " <> what)+    go n = do+        there <- ask+        if there then pure () else threadDelay 100000 >> go (n - 1)++assertUp :: [UpDown.Report Extension] -> IO ()+assertUp reports = do+    assertBool ("up failed: " <> show (failures reports)) (null (failures reports))+    assertEqual "nothing was blocked" 0 (length [() | UpDown.Blocked _ <- reports])++failures :: [UpDown.Report Extension] -> [String]+failures reports = [Text.unpack act.shorthand <> ": " <> show e | UpDown.Failed act e <- reports]++count :: Text -> (UpDown.Report Extension -> Maybe (Act Extension)) -> [UpDown.Report Extension] -> Int+count name which reports = length [() | Just act <- map which reports, act.shorthand == name]++isEval, isSkip :: UpDown.Report Extension -> Maybe (Act Extension)+isEval (UpDown.Eval act) = Just act+isEval _ = Nothing+isSkip (UpDown.Skip act) = Just act+isSkip _ = Nothing++showProperty :: Systemd.UnitTarget -> String -> IO Text+showProperty target property = do+    (_, out, _) <- readProcessWithExitCode "systemctl" ["--user", "show", Text.unpack target, "--property=" <> property, "--value"] ""+    pure (Text.strip (Text.pack out))++{- | Runs the action with the user's unit directory, or skips loudly. The+directory has to be there already: making @~\/.config\/systemd\/user@ on+somebody's machine is more than a test should do.+-}+withUserUnits :: (FilePath -> IO ()) -> IO ()+withUserUnits act = do+    uid <- getRealUserID+    (managerCode, managerOut, _) <- readProcessWithExitCode "systemctl" ["--user", "is-system-running"] ""+    let manager = managerCode == ExitSuccess || Text.strip (Text.pack managerOut) `elem` ["degraded", "starting"]+    dir <- getXdgDirectory XdgConfig ("systemd" </> "user")+    present <- doesDirectoryExist dir+    case () of+        _+            | uid == 0 -> skip "running as root; this test is the user-scope one"+            | not manager -> skip "no `systemd --user` manager is answering"+            | not present -> skip (dir <> " does not exist")+            | otherwise -> act dir+  where+    skip why = hPutStrLn stderr ("SKIPPED: " <> why <> "; the job nodes were not run against a real systemd")++{- | A job under a name nothing real has, writing two lines to @out@, and a+cleanup that does not trust the nodes' own @down@.+-}+withFreshJob :: FilePath -> FilePath -> (Job.Job -> IO a) -> IO a+withFreshJob unitDir out = bracket fresh cleanup+  where+    fresh = do+        now <- getPOSIXTime+        pid <- getProcessID+        let name = Text.pack ("salmon-job-test-" <> showHex (floor (now * 1000000) :: Integer) "" <> "-" <> show pid)+            script = "echo \"$HOME\" > " <> Text.pack out <> "; echo %h 100% >> " <> Text.pack out+        pure (Job.job name ["/bin/sh", "-c", script]){Job.jobScope = Systemd.User, Job.jobUnitDir = unitDir}+    cleanup j = do+        let quiet cmd args = void (readProcessWithExitCode cmd args "")+            service = Text.unpack (Job.jobServiceTarget j)+            timerName = Text.unpack j.jobName <> ".timer"+        quiet "systemctl" ["--user", "disable", "--now", timerName]+        quiet "systemctl" ["--user", "stop", service]+        forM_ [Job.jobServicePath j, unitDir </> timerName, unitDir </> "timers.target.wants" </> timerName] $ \path -> do+            present <- doesFileExist path+            when present (removeFile path)+        quiet "systemctl" ["--user", "daemon-reload"]+        quiet "systemctl" ["--user", "reset-failed", service]
test/Test/SystemdSpec.hs view
@@ -12,18 +12,45 @@ The interesting cases are the two that are not "is it running": a unit whose file changed since systemd loaded it, and a unit part-way through a transition. Both were what made this node worth giving a check at all.++'unitDirTests' is about the unit file's directory: a real @upTree@\/@downTree@+of the unit file in a temporary directory (no systemd), graph shape, and+QuickCheck properties that the file node renders what it rendered before. -} module Test.SystemdSpec (tests) where +import Control.Exception (try)+import Data.Bits ((.&.))+import qualified Data.ByteString as ByteString+import qualified Data.ByteString.Char8 as C8+import Data.Foldable (toList)+import Data.Functor.Identity (runIdentity)+import Data.List (intercalate, nub, sort)+import qualified Data.Map.Strict as Map import Data.Text (Text) import qualified Data.Text as Text+import System.Directory (doesDirectoryExist, doesFileExist) import System.FilePath ((</>))+import System.IO (readFile')+import System.Posix.Files (fileMode, getFileStatus, setFileMode)+import Test.QuickCheck (Gen, counterexample, elements, forAll, ioProperty, listOf, listOf1, (.&&.), (===), (==>)) import Test.Tasty (TestTree, testGroup) import Test.Tasty.HUnit (assertBool, assertEqual, testCase)-import Test.Harness (withTempDir)+import Test.Tasty.QuickCheck (testProperty)+import Test.Harness (runDown, runDownCapturing, runUp, withTempDir)  import Salmon.Actions.UpDown (CheckResult (..))+import qualified Salmon.Actions.UpDown as UpDown+import Salmon.Builtin.Extension+import qualified Salmon.Builtin.Nodes.Filesystem as FS+import qualified Salmon.Builtin.Nodes.Podman.Quadlet as Quadlet import qualified Salmon.Builtin.Nodes.Systemd as Systemd+import qualified Salmon.Builtin.Nodes.Systemd.Job as Job+import Salmon.Op.Actions (Act (..))+import qualified Salmon.Op.Dag as Dag+import Salmon.Op.OpGraph (OpGraph (..))+import Salmon.Op.Ref (Ref, mkRef)+import Salmon.Reporter (silent)  tests :: TestTree tests =@@ -37,8 +64,112 @@         , testCase "a unit systemd has never heard of is not satisfied" unknownUnitIsFailure         , testCase "properties are read by name, not by position" orderIndependent         , testGroup "watched config files" watchedTests+        , testGroup "the watched files' keyed digest" keyedTests+        , testGroup "the unit directory" unitDirTests         ] +{- | The unit file stands on a 'Systemd.unitDirectory', not on+'FS.dir': the directory holds every other unit on the machine, and+'FS.dir''s @down@ refuses a non-empty directory, so tearing one service down+among several reported the directory @Failed@ (and blocked whatever stood+under it). The teardown cases run real file IO in a temporary directory and+stop at the unit file: the service node's own @down@ is @systemctl stop@,+which is not run here.+-}+unitDirTests :: [TestTree]+unitDirTests =+    [ testCase "tearing down one unit file among several is clean and leaves the others" $ withTempDir $ \dir -> do+        let other = dir </> "other.service"+            cfg = service dir "mine.service"+        writeFile other "[Unit]\nDescription=not ours\n"+        upOk <- runUp (Systemd.unitFileNode [] cfg)+        assertBool "up failed" upOk+        assertBool "the unit file was not written" =<< doesFileExist (dir </> "mine.service")+        reports <- runDownCapturing (Systemd.unitFileNode [] cfg)+        assertEqual "failed or blocked nodes" [] [shorthandOf rep | rep <- reports, isBad rep]+        assertBool "our unit file is still there" . not =<< doesFileExist (dir </> "mine.service")+        assertBool "another unit was removed" =<< doesFileExist other+        assertBool "the shared directory was removed" =<< doesDirectoryExist dir+        downAgain <- runDown (Systemd.unitFileNode [] cfg)+        assertBool "a second down is not clean" downAgain+    , testCase "the same file over Filesystem.dir is the shape that failed" $ withTempDir $ \dir -> do+        -- pins why the predecessor was swapped: this is what systemdService+        -- declared before, and its teardown is not clean.+        let other = dir </> "other.service"+            plain = FS.filecontents (FS.FileContents (dir </> "mine.service") ("[Unit]\n" :: Text))+        writeFile other "[Unit]\nDescription=not ours\n"+        _ <- runUp plain+        reports <- runDownCapturing plain+        assertEqual "" ["directory"] [shorthandOf rep | rep <- reports, isBad rep]+    , testCase "a service's graph has a unit directory and no plain directory" $ do+        let dag = Dag.foldDag Dag.sameRepresentative (evalDeps (serviceNode (service "/etc/systemd/system" "a.service")))+        assertEqual "" ["file-contents", "systemd-service", "systemd-unit-dir"] (sort [act.shorthand | act <- Map.elems (Dag.dagNodes dag)])+    , testCase "a service and a job in one directory share it without a conflict" $ do+        let both = op "both" (deps [serviceNode (service Job.systemUnitDir "a.service"), Job.jobService silent ignoreTrack ignoreTrack (Job.job "b" ["/bin/true"])]) id+            dag = Dag.foldDag Dag.sameRepresentative (evalDeps both)+        assertEqual "" 0 (length (Dag.dagConflicts dag))+        assertEqual "" 1 (length [() | act <- Map.elems (Dag.dagNodes dag), act.shorthand == "systemd-unit-dir"])+    , testProperty "the unit file renders what filecontents rendered" $+        forAll genService $ \(d, name) ->+            let cfg = service d name+                plain = FS.filecontents (FS.FileContents (d </> Text.unpack name) (Systemd.render_config cfg))+             in describe (Systemd.unitFileNode [] cfg) === describe plain+    , testProperty "the unit file stands on exactly one systemd-unit-dir, keyed on its directory" $+        forAll genService $ \(d, name) ->+            let preds = [fmap describe' (opAct o) | o <- predecessorsOf (Systemd.unitFileNode [] (service d name))]+             in preds === [Just ("systemd-unit-dir", mkRef "systemd-unit-dir" d)]+    , testProperty "any number of services in one directory share one directory node" $+        forAll genServices $ \(d, names) ->+            let root = op "all" (deps [serviceNode (service d n) | n <- names]) id+                dag = Dag.foldDag Dag.sameRepresentative (evalDeps root)+                dirs = [() | act <- Map.elems (Dag.dagNodes dag), act.shorthand == "systemd-unit-dir"]+             in counterexample (show names) (length dirs === 1 .&&. length (Dag.dagConflicts dag) === 0)+    ]+  where+    serviceNode :: Systemd.Config -> Op+    serviceNode = Systemd.systemdService silent ignoreTrack ignoreTrack++    describe :: Op -> Maybe (Text, Ref, Text, [Text])+    describe o = fmap (\act -> (act.shorthand, act.extension.ref, act.extension.help, act.extension.notes)) (opAct o)++    describe' :: Act Extension -> (Text, Ref)+    describe' act = (act.shorthand, act.extension.ref)++    predecessorsOf :: Op -> [Op]+    predecessorsOf o = toList (runIdentity o.predecessors)++    shorthandOf :: UpDown.Report Extension -> Text+    shorthandOf rep = case rep of+        UpDown.Failed act _ -> act.shorthand+        UpDown.Blocked act -> act.shorthand+        _ -> ""++    isBad :: UpDown.Report Extension -> Bool+    isBad rep = case rep of+        UpDown.Failed _ _ -> True+        UpDown.Blocked _ -> True+        _ -> False++    genDir :: Gen FilePath+    genDir = ("/" <>) . intercalate "/" <$> listOf1 (listOf1 (elements "abcxyz-_."))++    genName :: Gen Text+    genName = (\s -> Text.pack s <> ".service") <$> listOf1 (elements "abcxyz0123-_@")++    genService :: Gen (FilePath, Text)+    genService = (,) <$> genDir <*> genName++    genServices :: Gen (FilePath, [Text])+    genServices = (,) <$> genDir <*> (nub <$> listOf1 genName)++service :: FilePath -> Text -> Systemd.Config+service d name =+    Systemd.Config Systemd.System d name unit svc install+  where+    unit = Systemd.Unit "a service" "network-online.target"+    svc = Systemd.Service Systemd.Simple "nobody" "nogroup" "0022" (Systemd.Start "/bin/true" []) Systemd.OnFailure Systemd.Process "/"+    install = Systemd.Install "multi-user.target"+ {- | A service's config file leaves no trace in anything systemd knows, so 'Systemd.systemdServiceWatching' folds it into the unit file, where @NeedDaemonReload@ already notices changes. These are assertions about that@@ -82,6 +213,169 @@   where     unit :: Text     unit = "[Unit]\nDescription=a service\n\n[Service]\nExecStart=/bin/true\n"++{- | 'Systemd.systemdServiceWatchingKeyed': the same change detection as the+plain line, with nothing in the (world-readable) unit file that lets a reader+check guesses at the watched files without the directory's key. And the+opt-in itself: 'Systemd.systemdServiceWatching' renders what it rendered+before, byte for byte and node for node.+-}+keyedTests :: [TestTree]+keyedTests =+    [ testCase "the keyed line is HMAC-SHA256, and the one quadlets write" $ do+        let frames = ["/etc/app/env:17:", "PASSWORD=hunter2\n"]+        -- the vector Test.QuadletSpec pins (python's hmac): the two share it+        assertEqual+            ""+            "# salmon-watches: hmac-sha256:1gecq1WMRz2R6OrB2pYA4e6aQiTPtJ9JB4aWUWugcz0=\n"+            (Systemd.keyedWatchLine "key one" frames)+        assertEqual "" (Systemd.keyedWatchLine "key one" frames) (Quadlet.keyedWatchLine "key one" frames)+    , testCase "the keyed unit carries no plain hash of the watched file, nor its contents" $ withTempDir $ \dir -> do+        let ini = dir </> "userlist.txt"+            key = Systemd.watchKeyFile dir+        Systemd.ensureWatchKeyAt key+        writeFile ini "\"u\" \"hunter2\"\n"+        plain <- Systemd.withWatchedFingerprint [ini] ""+        keyed <- Systemd.withKeyedWatchFingerprint key [ini] unit+        assertBool (Text.unpack keyed) (unit `Text.isPrefixOf` keyed)+        assertBool (Text.unpack keyed) ("# salmon-watches: hmac-sha256:" `Text.isInfixOf` keyed)+        assertBool "the plain digest is in the unit" (not (plainDigest plain `Text.isInfixOf` keyed))+        assertBool "the secret is in the unit" (not ("hunter2" `Text.isInfixOf` keyed))+    , testCase "a changed file changes the keyed unit, an unchanged one does not" $ withTempDir $ \dir -> do+        let ini = dir </> "pgbouncer.ini"+            key = Systemd.watchKeyFile dir+        Systemd.ensureWatchKeyAt key+        writeFile ini "[databases]\nx = host=a\n"+        before <- Systemd.withKeyedWatchFingerprint key [ini] unit+        again <- Systemd.withKeyedWatchFingerprint key [ini] unit+        writeFile ini "[databases]\nx = host=b\n"+        after <- Systemd.withKeyedWatchFingerprint key [ini] unit+        assertEqual "" before again+        assertBool "the unit did not change with the config" (before /= after)+    , testCase "no key, no unit: there is no fallback to a plain hash" $ withTempDir $ \dir -> do+        let ini = dir </> "conf"+            key = Systemd.watchKeyFile dir+        writeFile ini "anything"+        rendered <- try (Systemd.withKeyedWatchFingerprint key [ini] unit)+        case rendered of+            Left (Systemd.WatchKeyUnusable path _) -> assertEqual "" key path+            Right out -> assertBool ("rendered without a key: " <> Text.unpack out) False+    , testCase "the key is made once, owner-only, and never replaced" $ withTempDir $ \dir -> do+        let key = Systemd.watchKeyFile dir+        Systemd.ensureWatchKeyAt key+        first <- readFile' key+        mode <- fileMode <$> getFileStatus key+        assertEqual "the key is readable by others" 0 (mode .&. 0o077)+        assertEqual "" Success =<< Systemd.checkWatchKeyAt key+        setFileMode key 0o644+        assertBool "a world-readable key passed its check" . isFailure =<< Systemd.checkWatchKeyAt key+        Systemd.ensureWatchKeyAt key+        second <- readFile' key+        assertEqual "the key was replaced" first second+        assertEqual "" Success =<< Systemd.checkWatchKeyAt key+    , testCase "a keyed service, up for real (no systemd): the file has the keyed line and the key is 0600" $ withTempDir $ \dir -> do+        let ini = dir </> "conf"+            cfg = service dir "mine.service"+        writeFile ini "secret: hunter2\n"+        ok <- runUp (Systemd.unitFileNodeWith Systemd.KeyedDigest [ini] cfg)+        assertBool "up failed" ok+        key <- readKey (Systemd.watchKeyFile dir)+        written <- Text.pack <$> readFile' (dir </> "mine.service")+        frames <- Systemd.watchedFrames [ini]+        assertEqual "" (Systemd.render_config cfg <> Systemd.keyedWatchLine key frames) written+        mode <- fileMode <$> getFileStatus (Systemd.watchKeyFile dir)+        assertEqual "the key is readable by others" 0 (mode .&. 0o077)+    , testCase "the default is unchanged, up for real: the plain line, and no key made" $ withTempDir $ \dir -> do+        let ini = dir </> "conf"+            cfg = service dir "mine.service"+        writeFile ini "secret: hunter2\n"+        ok <- runUp (Systemd.unitFileNode [ini] cfg)+        assertBool "up failed" ok+        written <- Text.pack <$> readFile' (dir </> "mine.service")+        expected <- Systemd.withWatchedFingerprint [ini] (Systemd.render_config cfg)+        assertEqual "" expected written+        assertBool "a key was made for the plain digest" . not =<< doesFileExist (Systemd.watchKeyFile dir)+    , testCase "a keyed service's graph adds the key, once for the directory" $ do+        let svc n = Systemd.systemdServiceWatchingKeyed ["/etc/" <> Text.unpack n <> ".conf"] silent ignoreTrack ignoreTrack (service "/etc/systemd/system" n)+            dag = Dag.foldDag Dag.sameRepresentative (evalDeps (op "both" (deps [svc "a.service", svc "b.service"]) id))+        assertEqual "" 0 (length (Dag.dagConflicts dag))+        assertEqual+            ""+            ["both", "file-contents", "file-contents", "systemd-service", "systemd-service", "systemd-unit-dir", "systemd-unit-watch-key"]+            (sort [act.shorthand | act <- Map.elems (Dag.dagNodes dag)])+    , testProperty "systemdServiceWatching renders the nodes it rendered before (the plain digest is the default)" $+        forAll genCase $ \(d, name, watched) ->+            let cfg = service d name+                path = d </> Text.unpack name+                before+                    | null watched = FS.filecontents (FS.FileContents path (Systemd.render_config cfg))+                    | otherwise = FS.filecontents (FS.FileContents path (Systemd.withWatchedFingerprint watched (Systemd.render_config cfg)))+                now = Systemd.systemdServiceWatching watched silent ignoreTrack ignoreTrack cfg+                fileOf :: Op -> [Op]+                fileOf o = [p | p <- toList (runIdentity o.predecessors), fmap (.shorthand) (opAct p) == Just "file-contents"]+             in map describeFull (fileOf now) === [describeFull before]+                    .&&. describeAll (Systemd.unitFileNode watched cfg) === [describeHead before, ("systemd-unit-dir", mkRef "systemd-unit-dir" d)]+    , testProperty "with nothing watched, keyed and plain are the same nodes and no key" $+        forAll genCase $ \(d, name, _) ->+            let cfg = service d name+             in describeAll (Systemd.unitFileNodeWith Systemd.KeyedDigest [] cfg) === describeAll (Systemd.unitFileNode [] cfg)+    , testProperty "a keyed file's node is the plain one's, standing on the key as well" $+        forAll genCase $ \(d, name, watched) ->+            let cfg = service d name+                keyed = describeAll (Systemd.unitFileNodeWith Systemd.KeyedDigest watched cfg)+             in not (null watched) ==>+                    take 1 keyed === take 1 (describeAll (Systemd.unitFileNode watched cfg))+                        .&&. sort (drop 1 keyed) === sort [("systemd-unit-dir", mkRef "systemd-unit-dir" d), ("systemd-unit-watch-key", mkRef "systemd-unit-watch-key" (d </> ".salmon-watch.key"))]+    , testProperty "the keyed line never holds the watched bytes or their plain digest, and moves with them" $+        forAll genSecret $ \(secret, other) -> ioProperty $ withTempDir $ \dir -> do+            let ini = dir </> "secrets"+                key = Systemd.watchKeyFile dir+            Systemd.ensureWatchKeyAt key+            writeFile ini ("secret:" <> secret)+            plain <- Systemd.withWatchedFingerprint [ini] ""+            keyed <- Systemd.withKeyedWatchFingerprint key [ini] ""+            again <- Systemd.withKeyedWatchFingerprint key [ini] ""+            writeFile ini ("secret:" <> other)+            moved <- Systemd.withKeyedWatchFingerprint key [ini] ""+            pure $+                counterexample (Text.unpack keyed) $+                    not (Text.pack ("secret:" <> secret) `Text.isInfixOf` keyed)+                        .&&. not (plainDigest plain `Text.isInfixOf` keyed)+                        .&&. keyed === again+                        .&&. (secret /= other ==> keyed /= moved)+    ]+  where+    unit :: Text+    unit = "[Unit]\nDescription=a service\n\n[Service]\nExecStart=/bin/true\n"++    -- the base64 of a plain line, without its comment prefix+    plainDigest :: Text -> Text+    plainDigest = Text.drop (Text.length "# salmon-watches: ") . Text.strip++    readKey :: FilePath -> IO ByteString.ByteString+    readKey path = C8.strip <$> ByteString.readFile path++    -- the node, then its predecessors, by shorthand and ref+    describeAll :: Op -> [(Text, Ref)]+    describeAll o = [describeHead x | x <- o : toList (runIdentity o.predecessors)]++    describeHead :: Op -> (Text, Ref)+    describeHead o = maybe ("", mkRef "none" ("" :: Text)) (\act -> (act.shorthand, act.extension.ref)) (opAct o)++    -- what the fold compares a representative on, short of dynamics+    describeFull :: Op -> Maybe (Text, Ref, Text, [Text])+    describeFull o = fmap (\act -> (act.shorthand, act.extension.ref, act.extension.help, act.extension.notes)) (opAct o)++    genCase :: Gen (FilePath, Text, [FilePath])+    genCase = do+        d <- ("/" <>) . intercalate "/" <$> listOf1 (listOf1 (elements "abcxyz-_."))+        name <- (\s -> Text.pack s <> ".service") <$> listOf1 (elements "abcxyz0123-_@")+        watched <- listOf (("/etc/" <>) <$> listOf1 (elements "abc.-"))+        pure (d, name, watched)++    -- ':' and ' ' are not base64, so the prefixed secret cannot show in a digest by chance+    genSecret :: Gen (String, String)+    genSecret = (,) <$> listOf1 (elements "abc xyz019") <*> listOf1 (elements "abc xyz019")  shown :: [Text] -> CheckResult shown = Systemd.interpretShow
+ test/Test/UiTailTemplateSpec.hs view
@@ -0,0 +1,37 @@+{- | The web UI's Tramaj templates: tail templates (feature 7fee51f2, the+pure contract in @salmon-ops\/ui\/tail-template.js@) and node card templates+(feature 458c16c8, @salmon-ops\/ui\/card-template.js@), evaluated with the+vendored tramaj-js, are checked by @salmon-ops\/ui-test\/*.test.mjs@ (unit+cases and seeded random properties; @SALMON_UI_TEST_SEED@ fixes the seed).+This runs those scripts under @node@, skipped loudly when @node@ is not on+PATH or the source tree is not next to the suite.+-}+module Test.UiTailTemplateSpec (tests) where++import Control.Monad (unless)+import System.Directory (doesFileExist)+import System.Exit (ExitCode (..))+import System.Process (proc, readCreateProcessWithExitCode)+import Test.Harness (requireExecutable)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertFailure, testCase)++tests :: TestTree+tests =+    testGroup+        "web UI tail templates (node)"+        [ testCase "tail-template.js against tramaj-js: unit cases and properties" (runNodeSuite "tail-template")+        , testCase "card-template.js against tramaj-js: unit cases and properties" (runNodeSuite "card-template")+        ]++runNodeSuite :: String -> IO ()+runNodeSuite name = do+    let script = "../salmon-ops/ui-test/" <> name <> ".test.mjs"+    there <- doesFileExist script+    if not there+        then putStrLn ("SKIPPED: salmon-ops/ui-test is not next to the test suite; the " <> name <> " checks need the source tree")+        else requireExecutable "node" $ do+            -- stdin is "" (a pipe closed at once), never the suite's own+            (code, out, err) <- readCreateProcessWithExitCode (proc "node" [script]) ""+            unless (code == ExitSuccess) $+                assertFailure ("node " <> script <> " failed (" <> show code <> "):\n" <> out <> err)
+ test/Test/UpDownTextSpec.hs view
@@ -0,0 +1,166 @@+{- | Layer 0 coverage for "Salmon.Reporter.UpDownText": the one-line text+reporter for @run up@\/@run down@. A golden line per 'UpDown.Report'+constructor, QuickCheck properties on 'renderReport' (one line whatever the+help or the failure text holds; the kind is the JSON one; the node's short+ref is on it), and a real 'UpDown.upTree' over three nodes printing one line+per report.+-}+module Test.UpDownTextSpec (tests) where++import Control.Exception (ErrorCall (..), toException)+import Data.Aeson (Value (..), toJSON)+import qualified Data.Aeson.KeyMap as KeyMap+import Data.Char (isControl)+import Data.Functor.Identity (runIdentity)+import Data.Text (Text)+import qualified Data.Text as Text+import qualified Data.Text.IO as Text+import System.IO (IOMode (..), hClose, withFile)+import System.IO.Temp (withSystemTempDirectory)+import Test.QuickCheck (Arbitrary (..), Gen, Property, choose, counterexample, elements, forAll, frequency, listOf, oneof, (===))+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, assertEqual, testCase)+import Test.Tasty.QuickCheck (testProperty)++import qualified Salmon.Actions.UpDown as UpDown+import Salmon.Builtin.Extension (Extension (..), Op, deps, nodeps, op)+import Salmon.Op.Actions (Act (..), Actions (..))+import qualified Salmon.Op.Mailbox as Mailbox+import Salmon.Op.OpGraph (OpGraph (..))+import Salmon.Op.Ref (Ref, mkRef, shortRef)+import Salmon.Reporter.Tagged ()+import Salmon.Reporter.UpDownText (renderKind, renderReport, reportHText)++tests :: TestTree+tests =+    testGroup+        "Salmon.Reporter.UpDownText"+        [ testGroup "golden line, one per constructor" [testCase name (assertEqual name expected (renderReport rep)) | (name, rep, expected) <- goldens]+        , testProperty "a report renders as one line" propOneLine+        , testProperty "the line starts with the report's JSON kind" propKindFirst+        , testProperty "the line names the node by #shortRef" propShortRef+        , testProperty "help without control characters is printed verbatim" propHelpVerbatim+        , testCase "run up over three nodes prints one line per report, none of them a Show" threeNodes+        ]++-------------------------------------------------------------------------------++fixtureRef :: Ref+fixtureRef = mkRef "updown-text-spec" ("fixture" :: Text)++actWith :: Text -> Text -> Ref -> Act Extension+actWith shorthand h r = actOf $ op shorthand nodeps $ \ext -> ext{help = h, ref = r}++actOf :: Op -> Act Extension+actOf o = case o.node of+    Actions act -> act+    Actionless -> error "fixture op is Actionless"++fixtureAct :: Act Extension+fixtureAct = actWith "file" "write /etc/motd" fixtureRef++otherAct :: Act Extension+otherAct = actWith "file" "write /etc/motd\nsecond line" fixtureRef++goldens :: [(String, UpDown.Report Extension, Text)]+goldens =+    [ ("Skip", UpDown.Skip fixtureAct, "skip    " <> short <> " file: write /etc/motd")+    , ("Eval", UpDown.Eval fixtureAct, "eval    " <> short <> " file: write /etc/motd")+    , ("Done", UpDown.Done fixtureAct, "done    " <> short <> " file: write /etc/motd")+    , ( "Failed"+      , UpDown.Failed fixtureAct (toException (ErrorCall "exit 1\nstderr: no space left"))+      , "failed  " <> short <> " file: write /etc/motd -- exit 1\\nstderr: no space left"+      )+    , ("Blocked", UpDown.Blocked fixtureAct, "blocked " <> short <> " file: write /etc/motd")+    , ( "Conflicting"+      , UpDown.Conflicting fixtureRef fixtureAct otherAct+      , "conflicting " <> short <> " kept file: write /etc/motd; replaced file: write /etc/motd\\nsecond line"+      )+    , ("Instructed", UpDown.Instructed fixtureAct Mailbox.Force, "instructed " <> short <> " file: write /etc/motd -- force")+    , ("DroppedInstructions", UpDown.DroppedInstructions fixtureAct 3, "dropped-instructions " <> short <> " file: write /etc/motd -- 3 dropped")+    ]+  where+    short = "#" <> shortRef fixtureRef++-------------------------------------------------------------------------------++-- | Text that often holds the characters that would break a line.+genText :: Gen Text+genText = Text.pack <$> listOf (frequency [(6, arbitrary), (1, elements "\n\r\ESC\t\v\f\0")])++genAct :: Gen (Act Extension)+genAct = do+    sh <- genText+    h <- genText+    k <- choose (0, 1000 :: Int)+    pure (actWith sh h (mkRef "updown-text-spec" k))++genReport :: Gen (UpDown.Report Extension)+genReport =+    oneof+        [ UpDown.Skip <$> genAct+        , UpDown.Eval <$> genAct+        , UpDown.Done <$> genAct+        , UpDown.Failed <$> genAct <*> (toException . ErrorCall . Text.unpack <$> genText)+        , UpDown.Blocked <$> genAct+        , (\a b -> UpDown.Conflicting a.extension.ref a b) <$> genAct <*> genAct+        , UpDown.Instructed <$> genAct <*> elements [Mailbox.Force, Mailbox.Satisfy, Mailbox.Recheck, Mailbox.Pause, Mailbox.Resume]+        , UpDown.DroppedInstructions <$> genAct <*> choose (0, 100)+        ]++reportRef :: UpDown.Report Extension -> Ref+reportRef rep = case rep of+    UpDown.Skip a -> a.extension.ref+    UpDown.Eval a -> a.extension.ref+    UpDown.Done a -> a.extension.ref+    UpDown.Failed a _ -> a.extension.ref+    UpDown.Blocked a -> a.extension.ref+    UpDown.Conflicting r _ _ -> r+    UpDown.Instructed a _ -> a.extension.ref+    UpDown.DroppedInstructions a _ -> a.extension.ref++propOneLine :: Property+propOneLine = forAll genReport $ \rep ->+    let line = renderReport rep+     in counterexample (show line) (Text.all (\c -> not (isControl c) || c == '\t') line)++propKindFirst :: Property+propKindFirst = forAll genReport $ \rep ->+    let jsonKind = case toJSON rep of+            Object o -> KeyMap.lookup "kind" o+            _ -> Nothing+     in (Just (String (renderKind rep)), take 1 (Text.words (renderReport rep)))+            === (jsonKind, [renderKind rep])++propShortRef :: Property+propShortRef = forAll genReport $ \rep ->+    let ws = Text.words (renderReport rep)+     in take 1 (drop 1 ws) === ["#" <> shortRef (reportRef rep)]++propHelpVerbatim :: Property+propHelpVerbatim = forAll ((,) <$> genText <*> genText) $ \(sh, h) ->+    let clean = Text.filter (not . isControl)+        act = actWith (clean sh) (clean h) fixtureRef+     in Text.isSuffixOf (" " <> clean sh <> ": " <> clean h) (renderReport (UpDown.Done act)) === True++-------------------------------------------------------------------------------++-- | The acceptance from the feature: three nodes, one line per report.+threeNodes :: IO ()+threeNodes = withSystemTempDirectory "updown-text" $ \dir -> do+    let path = dir <> "/out"+        leaf :: Text -> Op+        leaf n = op "leaf" nodeps $ \ext -> ext{help = "leaf " <> n, ref = mkRef "updown-text-spec" n, up = pure ()}+        root :: Op+        root = op "root" (deps [leaf "a", leaf "b"]) $ \ext -> ext{help = "the root", ref = mkRef "updown-text-spec" ("root" :: Text), up = pure ()}+    ok <- withFile path WriteMode $ \h -> do+        r <- UpDown.upTree (reportHText h) (pure . runIdentity) root+        hClose h+        pure r+    assertBool "upTree succeeded" ok+    out <- Text.readFile path+    let ls = Text.lines out+    assertEqual "two reports (eval, done) per node, one line each" 6 (length ls)+    assertEqual "eval/done in order per node" ["eval", "done", "eval", "done", "eval", "done"] (concatMap (take 1 . Text.words) ls)+    assertBool "the root's done is last" ("done" `Text.isPrefixOf` last ls && "root: the root" `Text.isSuffixOf` last ls)+    assertBool "no Show of Act or Extension" (not (any (\l -> "Act {" `Text.isInfixOf` l || "extension =" `Text.isInfixOf` l) ls))
+ test/Test/UpkeepRetentionSpec.hs view
@@ -0,0 +1,356 @@+{-# LANGUAGE OverloadedStrings #-}++{- | What a tended node costs to keep, and that keeping it cheaply changed+nothing about how it is tended.++Finding 6 of @resources\/graph-profiles.md@: a supervisor over 100 000 nodes+did not fit in a 4 GB heap. A heap profile by closure type put about 95% of+what it retained in @STACK@ objects, 32 KiB a machine: each machine's thread+overflowed its first stack chunk (1 KiB) while registering a nap timeout+(the insertion into the runtime's timer queue runs on the calling thread and+deepens with the number of timeouts outstanding), was moved onto a chunk of+@+RTS -kc@ size, and stayed there. 'Salmon.Actions.Upkeep' now registers on+a short-lived thread of its own.++The retention cases measure that directly: they clone each machine's stack+once it has settled and compare its size with a thread that has done nothing+at all, so they do not depend on what else this process is doing (the suite+runs its groups in parallel, which rules out reading the heap's residency).+The rest checks what the change must not alter: every fixture shape is+started, settled and stopped as before, a nap still ends, and, as a+property, a random graph under a random script of instructions converges+with its dependency order intact.+-}+module Test.UpkeepRetentionSpec (tests) where++import Control.Concurrent (MVar, ThreadId, forkIO, killThread, newEmptyMVar, takeMVar, yield)+import Control.Concurrent.STM (TVar, atomically, modifyTVar', newTVarIO, readTVar, readTVarIO, retry)+import Control.Monad (forM, forM_, unless, void)+import qualified Data.Map.Strict as Map+import qualified Data.Set as Set+import GHC.Conc (ThreadStatus (..), threadStatus)+import GHC.Exts.Heap.Closures (GenStackFrame (..), GenStgStackClosure (..), StgStackClosure)+import GHC.Exts.Stack.Decode (decodeStack)+import GHC.Stack.CloneStack (cloneThreadStack)+import System.Timeout (timeout)+import Test.QuickCheck (Gen, Property, choose, counterexample, elements, forAll, ioProperty, listOf, (.&&.))+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, assertEqual, testCase)+import Test.Tasty.QuickCheck (testProperty)++import Salmon.Actions.UpDown (CheckResult (..))+import qualified Salmon.Actions.UpDown as UpDown+import Salmon.Actions.Upkeep (Report (..), Standing (..), Supervisor, Tend (..), UpkeepState (..))+import qualified Salmon.Actions.Upkeep as Upkeep+-- the field selectors 'Upkeep' asks for through HasField must be in scope.+import Salmon.Builtin.Extension (Extension (..))+import Salmon.Op.Actions (Act (..))+import qualified Salmon.Op.Dag as Dag+import Salmon.Op.Mailbox (Instruction (..))+import Salmon.Op.Ref (Ref)+import Salmon.Op.Status (Direction (..), Stability (..), Status (..))+import Salmon.Reporter (ReporterM (..))++import Test.GraphFixture++tests :: TestTree+tests =+    testGroup+        "Salmon.Actions.Upkeep: what a tended node retains"+        [ testCase "a settled machine keeps the stack it started with" (stacksStaySmall Settled)+        , testCase "a machine that brought its node up keeps the stack it started with" (stacksStaySmall Unsettled)+        , testCase "every shape starts, settles and stops" everyShapeStartsSettlesStops+        , testCase "a nap still ends on its own" napsEnd+        , testProperty "a random graph under a random script converges in dependency order" prop_scriptedConvergence+        ]++-------------------------------------------------------------------------------+-- driving a supervisor++type Trace = TVar [Report Extension]++within :: Int -> IO a -> IO a+within secs act = do+    result <- timeout (secs * 1000000) act+    maybe (fail ("timed out after " <> show secs <> "s")) pure result++-- | Start a supervisor over this graph with a trace of its reports (newest+-- first), run the body, and stop it whatever the body did.+supervising ::+    Dag.Dag Extension ->+    (Ref -> Maybe Tend) ->+    (Supervisor Extension -> Trace -> IO a) ->+    IO a+supervising dag tend body = do+    trace <- newTVarIO []+    let r = ReporterM $ \rep -> atomically (modifyTVar' trace (rep :))+    Upkeep.withUpkeep r tend dag (\sup -> body sup trace)++-- | Block until the reports so far (oldest first) satisfy the predicate.+await :: Trace -> ([Report Extension] -> Bool) -> IO ()+await trace p =+    atomically $ do+        rs <- readTVar trace+        unless (p (reverse rs)) retry++seen :: Trace -> IO [Report Extension]+seen trace = reverse <$> readTVarIO trace++-- | Every machine settled, the way a dependant would read it.+awaitStable :: Supervisor Extension -> IO ()+awaitStable sup =+    forM_ (Map.elems (Upkeep.supervisorStatuses sup)) $ \var ->+        atomically $ do+            st <- readTVar var+            unless (st.statusStability == Stable && st.statusDirection == TurnUp) retry++-- | Which nodes have said they are waiting for their next look (or parked).+resting :: [Report Extension] -> Set.Set Ref+resting rs = Set.fromList [act.extension.ref | r <- rs, act <- restingAct r]+  where+    restingAct (NextLook a _ _) = [a]+    restingAct (Parked a) = [a]+    restingAct _ = []++upAt :: [Report Extension] -> [Ref]+upAt rs = [act.extension.ref | Upkeep act Up <- rs]++evaluated :: [Report Extension] -> [Ref]+evaluated rs = [act.extension.ref | Acted (UpDown.Eval act) <- rs]++retired :: [Report Extension] -> [Int]+retired rs = [n | Retired n <- rs]++-- | A fixture graph, folded by the fixture's own per-node build.+fixture :: CheckResult -> Shape -> Int -> Seed -> Dag.Dag Extension+fixture verdict shape n s = dagOf (generateWith defaultOptions{optCheck = verdict} shape n s)++-------------------------------------------------------------------------------+-- stacks++-- | Words in every chunk of a thread's stack.+stackWords :: ThreadId -> IO Int+stackWords tid = chunkWords <$> (decodeStack =<< cloneThreadStack tid)+  where+    chunkWords :: StgStackClosure -> Int+    chunkWords st =+        fromIntegral (ssc_stack_size st)+            + sum [chunkWords c | UnderflowFrame{nextChunk = c} <- ssc_stack st]++-- | What a thread that has done nothing but block holds: the runtime's first+-- chunk, whatever @+RTS -ki@ made it.+freshStackWords :: IO Int+freshStackWords = do+    never <- newEmptyMVar :: IO (MVar ())+    tid <- forkIO (takeMVar never)+    awaitBlocked [tid]+    w <- stackWords tid+    killThread tid+    pure w++-- | Until every one of these threads is blocked (or gone).+awaitBlocked :: [ThreadId] -> IO ()+awaitBlocked tids = do+    statuses <- mapM threadStatus tids+    unless (all parked statuses) (yield >> awaitBlocked tids)+  where+    parked (ThreadBlocked _) = True+    parked ThreadFinished = True+    parked ThreadDied = True+    parked ThreadRunning = False++{- | Three thousand machines of a binary tree, every one of them napping on a+timer (their check says 'Success', so they poll rather than park). Before+the change every one of them held a 32 KiB chunk from about a thousand nodes+up. One machine in a hundred is allowed a bigger stack than a fresh thread:+the first to force something every machine shares pays for evaluating it.+-}+stacksStaySmall :: Standing -> IO ()+stacksStaySmall standing = within 60 $ do+    let n = 3000+        dag = fixture Success (Tree 2) n 1+    baseline <- freshStackWords+    supervising dag (const (Just (Tend TurnUp standing))) $ \sup trace -> do+        await trace (\rs -> Set.size (resting rs) == n)+        let threads = Map.elems (Upkeep.supervisorThreads sup)+        assertEqual "one machine a node" n (length threads)+        awaitBlocked threads+        sizes <- forM threads stackWords+        let grown = length (filter (> baseline) sizes)+        assertBool+            ( "machines holding more stack than a fresh thread ("+                <> show baseline+                <> " words): "+                <> show grown+                <> " of "+                <> show n+                <> ", largest "+                <> show (maximum sizes)+                <> " words"+            )+            (grown <= n `div` 100)++-------------------------------------------------------------------------------+-- behaviour that must not have moved++shapes :: [Shape]+shapes = [Chain, Fan, Tree 2, Diamonds 2, Layered 8 2, RandomLayered 8 3, Layered 4 4]++{- | Start, settle, stop, on every shape, both standings, both kinds of+check: every node comes up, every machine settles, and stopping retires them+all and keeps none.+-}+everyShapeStartsSettlesStops :: IO ()+everyShapeStartsSettlesStops = within 120 $+    forM_ shapes $ \shape ->+        forM_ [Settled, Unsettled] $ \standing ->+            forM_ [Success, Immaterial] $ \verdict -> do+                (dag, touches) <- instrumented (fixture verdict shape 300 7)+                let n = length (Dag.dagOrder dag)+                    what = show shape <> " " <> show standing <> " " <> show verdict+                trace <- newTVarIO []+                let r = ReporterM $ \rep -> atomically (modifyTVar' trace (rep :))+                sup <- Upkeep.startUpkeep r Upkeep.noKept (const (Just (Tend TurnUp standing))) dag+                await trace (\rs -> Set.size (Set.fromList (upAt rs)) == n)+                awaitStable sup+                kept <- Upkeep.stopUpkeep sup+                rs <- seen trace+                assertEqual (what <> ": nothing kept") 0 (Map.size (Upkeep.keptHeld kept))+                assertEqual (what <> ": every machine retired") [n] (retired rs)+                case (standing, verdict) of+                    (Settled, _) -> assertEqual (what <> ": nothing applied") [] (evaluated rs)+                    (Unsettled, Success) -> assertEqual (what <> ": nothing applied") [] (evaluated rs)+                    (Unsettled, _) ->+                        assertEqual (what <> ": every node applied once") (Set.fromList (Dag.dagOrder dag)) (Set.fromList (evaluated rs))+                case standing of+                    Settled -> pure ()+                    Unsettled -> do+                        first <- firstTouches touches+                        assertEqual (what <> ": nodes acted on before a dependency") [] (outOfOrder Set.empty dag first)++{- | Wrap every node's @check@ and @up@ so that entering either is logged,+in the order it happened.++The reports cannot tell that order: a dependency settles its status and only+then says it is up, so a dependant can say so in between. The log is causal+instead. A dependant consults its check (or runs its @up@) only once its+dependency has settled, which is after the dependency's own first @check@ or+@up@ was entered.+-}+instrumented :: Dag.Dag Extension -> IO (Dag.Dag Extension, TVar [Ref])+instrumented dag = do+    touches <- newTVarIO []+    let touch aref = atomically (modifyTVar' touches (aref :))+        wrap aref act =+            let ext = act.extension+             in act{extension = ext{check = touch aref >> ext.check, up = touch aref >> ext.up}}+    pure (dag{Dag.dagNodes = Map.mapWithKey wrap (Dag.dagNodes dag)}, touches)++-- | Where each node first appears in the log.+firstTouches :: TVar [Ref] -> IO (Map.Map Ref Int)+firstTouches touches = do+    logged <- reverse <$> readTVarIO touches+    pure (Map.fromListWith min (zip logged [0 ..]))++{- | Pairs of a node and a dependency it was acted on before. A node never+acted on constrains nothing. A dependency told to 'Satisfy' is left out: it+may settle without acting and be acted on later (forced, say), after its+dependants, and both are right.+-}+outOfOrder :: Set.Set Ref -> Dag.Dag Extension -> Map.Map Ref Int -> [(Ref, Ref)]+outOfOrder satisfied dag first =+    [ (aref, d)+    | aref <- Dag.dagOrder dag+    , Just mine <- [Map.lookup aref first]+    , d <- Dag.dependenciesOf dag aref+    , not (Set.member d satisfied)+    , maybe True (>= mine) (Map.lookup d first)+    ]++{- | The timer is the one thing the change moved: a node polling on a check+must still be woken by it, with nobody telling it anything. The first nap+after settling is a second ('Upkeep.relaxed' of the floor).+-}+napsEnd :: IO ()+napsEnd = within 30 $ do+    let dag = fixture Success Chain 5 3+        n = length (Dag.dagOrder dag)+    supervising dag (const (Just (Tend TurnUp Settled))) $ \_ trace -> do+        await trace $ \rs ->+            let looks = Map.fromListWith (+) [(act.extension.ref, 1 :: Int) | NextLook act _ _ <- rs]+             in Map.size looks == n && all (>= 2) (Map.elems looks)++-------------------------------------------------------------------------------+-- the property++data Script = Script+    { scriptShape :: Shape+    , scriptSize :: Int+    , scriptSeed :: Seed+    , scriptVerdict :: CheckResult+    , scriptStanding :: Standing+    , scriptSteps :: [(Int, Instruction)]+    }+    deriving (Show)++genScript :: Gen Script+genScript =+    Script+        <$> elements shapes+        <*> choose (1, 40)+        <*> (fromIntegral <$> choose (0 :: Int, 1000000))+        <*> elements [Success, Immaterial]+        <*> elements [Settled, Unsettled]+        <*> listOf ((,) <$> choose (0, 1000) <*> elements [Recheck, Force, Satisfy, Pause, Resume])++{- | Whatever is said to whichever machines while they start, once every one+of them is told to 'Resume' they all come up and settle; from scratch they+come up in dependency order; a node whose check is satisfied is applied only+if it was forced; and stopping retires every machine and keeps none.++Instructions go out as the machines start, so they land in every state a+machine passes through on the way: waiting on dependencies, applying,+napping, parked. Nothing here compares against the code before the change+(there is one implementation); it pins the behaviour that change had to+keep.+-}+prop_scriptedConvergence :: Property+prop_scriptedConvergence = forAll genScript $ \script -> ioProperty $ do+    let dag = fixture script.scriptVerdict script.scriptShape script.scriptSize script.scriptSeed+        order = Dag.dagOrder dag+        n = length order+        pick i = order !! (i `mod` n)+        told = [(pick i, ins) | (i, ins) <- script.scriptSteps]+        forced = Set.fromList [aref | (aref, Force) <- told]+    (dag', touches) <- instrumented dag+    trace <- newTVarIO []+    let r = ReporterM $ \rep -> atomically (modifyTVar' trace (rep :))+    outcome <- timeout (20 * 1000000) $ do+        sup <- Upkeep.startUpkeep r Upkeep.noKept (const (Just (Tend TurnUp script.scriptStanding))) dag'+        forM_ told $ \(aref, ins) -> void (Upkeep.instruct sup aref ins)+        forM_ order $ \aref -> void (Upkeep.instruct sup aref Resume)+        await trace (\rs -> Set.fromList (upAt rs) == Set.fromList order)+        awaitStable sup+        kept <- Upkeep.stopUpkeep sup+        rs <- seen trace+        pure (Map.size (Upkeep.keptHeld kept), rs)+    case outcome of+        Nothing -> do+            rs <- seen trace+            pure $+                counterexample+                    ("did not converge; nodes up: " <> show (Set.size (Set.fromList (upAt rs))) <> " of " <> show n)+                    False+        Just (keptCount, rs) -> do+            first <- firstTouches touches+            let misordered = case script.scriptStanding of+                    Settled -> []+                    Unsettled -> outOfOrder (Set.fromList [aref | (aref, Satisfy) <- told]) dag first+                unforced = Set.fromList (evaluated rs) `Set.difference` forced+            pure $+                counterexample ("kept " <> show keptCount) (keptCount == 0)+                    .&&. counterexample ("retired " <> show (retired rs)) (retired rs == [n])+                    .&&. counterexample ("up before a dependency: " <> show misordered) (null misordered)+                    .&&. counterexample+                        ("applied without being forced: " <> show (Set.toList unforced))+                        (script.scriptVerdict /= Success || Set.null unforced)
test/Test/UpkeepSpec.hs view
@@ -23,12 +23,13 @@ module Test.UpkeepSpec (tests) where  import Control.Concurrent (threadDelay)-import Control.Concurrent.MVar (newEmptyMVar, putMVar, takeMVar)-import Control.Exception (bracket)+import Control.Concurrent.MVar (newEmptyMVar, putMVar, takeMVar, tryPutMVar)+import Control.Exception (ErrorCall (..), bracket, finally, fromException, throwIO) import Control.Concurrent.STM (TVar, atomically, modifyTVar', newTVarIO, readTVar, readTVarIO, retry) import Control.Monad (unless, void) import Data.Dynamic (toDyn) import Data.IORef (IORef, atomicModifyIORef', newIORef, readIORef, writeIORef)+import Data.List (findIndex) import qualified Data.Map.Strict as Map import qualified Data.Set as Set import Data.Text (Text)@@ -45,17 +46,18 @@ -- HasField for fields whose selector is in scope, and 'Upkeep' asks for -- several this module never mentions by name. import Salmon.Builtin.Extension (Extension, Op, check, deps, down, dynamics, evalDeps, help, managed, nodeps, notes, op, opAct, ref, up)+import Salmon.Builtin.Guarded (Guarding (..), Precondition (..), PreconditionUnmet (..), defaultGuarding, guarded) import qualified Salmon.Builtin.Nodes.Filesystem as FS import Salmon.Op.Actions (Act (..)) import qualified Salmon.Op.Dag as Dag import Salmon.Op.Mailbox (Instruction (..)) import Salmon.Op.Ref (Ref, mkRef) import Salmon.Op.Status (Direction (..))-import Salmon.Op.Supervision (Restart (..), Strategy (..), Supervision (..), defaultSupervision, millis, seconds, supervised)+import Salmon.Op.Supervision (Restart (..), Strategy (..), Supervision (..), defaultSupervision, millis, seconds, supervised, supervisionOf) import Salmon.Reporter (ReporterM (..)) import System.Directory (doesDirectoryExist, removeDirectory) import System.FilePath ((</>))-import Test.Harness (withTempDir)+import Test.Harness (runUpCapturing, withTempDir)  tests :: TestTree tests =@@ -123,6 +125,19 @@             [ testCase "a policy-only change is not adopted, and the process restarts" changedPolicyIsNotAdopted             , testCase "an unchanged policy is adopted, and the process is not restarted" unchangedPolicyIsAdopted             ]+        , testGroup+            "a guarded operation (Salmon.Builtin.Guarded)"+            [ testCase "an unmet precondition stays Unknown and starts nothing" guardedWaitsForPreconditions+            , testCase "a refusal at up time is reported and not counted as a failure" guardedRefusalIsNotCounted+            , testCase "a failing up parks after its attempts and does not restart on its own" guardedParksOnFailure+            , testCase "siblings in the hold set are paused for the duration" guardedHoldsSiblings+            , testCase "a sibling in the middle of its own up is waited for" guardedWaitsForSiblingInFlight+            , testCase "a parked operation lets its siblings go" guardedReleasesOnPark+            , testCase "a sibling the operator paused is left paused" guardedLeavesOperatorPauseAlone+            , testCase "two operations holding each other both get to run" guardedMutualHoldsDoNotDeadlock+            , testCase "a one-shot pass refuses rather than acts" guardedOneShotRefuses+            , testCase "the node keeps its own supervision, and a changed hold set is a changed node" guardedDeclaration+            ]         ]  -------------------------------------------------------------------------------@@ -408,7 +423,7 @@         -- the effect disappears behind salmon's back         writeIORef there False         void (Upkeep.instruct sup (refOf "svc") Recheck)-        await trace (\rs -> length (evals rs) >= 2)+        await trace (\rs -> donesOf "svc" rs >= 2)         assertEqual "and was put back" 2 =<< readIORef ran  neverLeavesItAlone :: IO ()@@ -451,7 +466,7 @@         await trace (\rs -> not (null (reached Up rs)))         assertEqual "a Completed check skipped it on the way in" ["job"] . skips =<< seen trace         void (Upkeep.instruct sup (refOf "job") Recheck)-        await trace (\rs -> not (null (evals rs)))+        await trace (\rs -> donesOf "job" rs >= 1)         assertEqual "but Always ran it anyway" 1 =<< readIORef ran  dependantWaits :: IO ()@@ -464,7 +479,7 @@         await trace (\rs -> "dep" `elem` evals rs)         assertEqual "the dependant has not run" 0 =<< readIORef ran         putMVar gate ()-        await trace (\rs -> "top" `elem` evals rs)+        await trace (\rs -> donesOf "top" rs >= 1)         assertEqual "and now it has" 1 =<< readIORef ran  {- | The sharpest difference from the one-shot drivers. There, a node whose@@ -491,7 +506,7 @@         assertEqual "and is not reported Blocked" [] (blockeds rs)         -- skip the backoff rather than sleeping through it         void (Upkeep.instruct sup (refOf "flaky") Recheck)-        await trace (\rs -> "onflaky" `elem` evals rs)+        await trace (\rs -> donesOf "onflaky" rs >= 1)         assertEqual "it proceeds once the dependency recovers" 1 =<< readIORef ran  {- | A node nobody is tending will never move, so waiting for it to move@@ -569,7 +584,7 @@         await trace (\rs -> "gate" `elem` [act.shorthand | Acted (UpDown.Done act) <- rs])         assertEqual "the paused node stayed put" 0 =<< readIORef ran         void (Upkeep.instruct sup (refOf "held") Resume)-        await trace (\rs -> "held" `elem` evals rs)+        await trace (\rs -> donesOf "held" rs >= 1)         assertEqual "and moved once resumed" 1 =<< readIORef ran  {- | The watchdog is a node author saying what their node's silence would@@ -1062,7 +1077,7 @@         await trace (\rs -> reachedBy "svc" WaitUp rs >= 2)         assertEqual "waiting, not acting" 1 =<< readIORef svcRan         putMVar gate ()-        await trace (\rs -> evalsOf "svc" rs >= 2)+        await trace (\rs -> donesOf "svc" rs >= 2)         assertEqual "and only once the dependency was back" 2 =<< readIORef svcRan  {- | A demoted node is itself no longer up, which is all a dependant of /it/@@ -1245,7 +1260,7 @@         rs0 <- seen trace         assertEqual "never parked, since it opted out of that" 0 (length [() | Parked{} <- rs0])         void (Upkeep.instruct sup (refOf "cheap-dir") Recheck)-        await trace (\rs -> evalsOf "cheap-dir" rs >= 2)+        await trace (\rs -> donesOf "cheap-dir" rs >= 2)         assertEqual "reapplied rather than merely looked at" 2 =<< readIORef ran  {- | A successful reapply is not a restart: it must not go back through@@ -1427,3 +1442,335 @@     assertEqual "nothing was released" 0 (length [() | Released _ <- rs])     kept2 <- Upkeep.stopUpkeep sup2     void (Upkeep.releaseKept r (const False) kept2)++-------------------------------------------------------------------------------+-- guarded operations++{- | A risky operation as a node: not done until its @up@ has run, and its+@up@ counts how often that was. The preconditions are whatever the 'IORef'+says at the moment they are asked.+-}+riskyNode :: Text -> Guarding -> IORef Precondition -> IORef Bool -> IO () -> Op+riskyNode name g pre done act =+    node name $ \x ->+        guarded+            g+            (readIORef pre)+            x+                { check = do+                    ok <- readIORef done+                    pure (if ok then Success else Failure "not done")+                , up = act >> writeIORef done True+                }++-- | A sibling: something with a real check, so its machine polls rather+-- than parks and a pause is a change in what it does.+siblingNode :: Text -> IO () -> Op+siblingNode name act =+    node name $ \x -> x{check = pure (Failure "always wants putting back"), up = act}++unknownLooksAt :: Text -> [Report Extension] -> Int+unknownLooksAt name rs = length [() | NextLook a Unknown _ <- rs, a.shorthand == name]++indexOf :: (Report Extension -> Bool) -> [Report Extension] -> Maybe Int+indexOf = findIndex++isPausedOf, isResumedOf, isEvalOf, isDoneOf :: Text -> Report Extension -> Bool+isPausedOf name (Paused a) = a.shorthand == name+isPausedOf _ _ = False+isResumedOf name (Resumed a) = a.shorthand == name+isResumedOf _ _ = False+isEvalOf name (Acted (UpDown.Eval a)) = a.shorthand == name+isEvalOf _ _ = False+isDoneOf name (Acted (UpDown.Done a)) = a.shorthand == name+isDoneOf _ _ = False++{- | Rule one. While the preconditions are unmet the node's check answers+'Unknown', and for a guarded node that means "keep looking": its @up@ never+runs, it never says it is moving, and a node standing on it keeps waiting.+The moment they are met it goes, with nobody re-declaring anything.+-}+guardedWaitsForPreconditions :: IO ()+guardedWaitsForPreconditions = within 10 $ do+    pre <- newIORef (Unmet "a replica is lagging")+    done <- newIORef False+    (ran, bump) <- counter+    (after, bumpAfter) <- counter+    let upgrade = riskyNode "upgrade" defaultGuarding pre done bump+        top = nodeOn "after" [upgrade] $ \x -> x{up = bumpAfter}+    supervising (dagOf top) allUp $ \sup trace -> do+        await trace (\rs -> unknownLooksAt "upgrade" rs >= 1)+        -- look again, at once, rather than waiting out a nap to prove it+        void (Upkeep.instruct sup (refOf "upgrade") Recheck)+        await trace (\rs -> unknownLooksAt "upgrade" rs >= 2)+        rs <- seen trace+        assertEqual "nothing started" 0 =<< readIORef ran+        assertEqual "it never said it was running anything" [] (evals rs)+        assertEqual "nor that it was moving" 0 (reachedBy "upgrade" Upping rs)+        assertEqual "and what stands on it waited" 0 =<< readIORef after+        assertEqual "no failure was counted" [] [n | GaveUp _ n <- rs]+        writeIORef pre Met+        void (Upkeep.instruct sup (refOf "upgrade") Recheck)+        await trace (\rs -> donesOf "after" rs >= 1)+        assertEqual "ran once the preconditions were met" 1 =<< readIORef ran++{- | The preconditions can stop holding between the check and the @up@. The+@up@ asks again and refuses, and a refusal is not the operation failing:+with a limit of one failure, counting it would park an operation that never+started.+-}+guardedRefusalIsNotCounted :: IO ()+guardedRefusalIsNotCounted = within 10 $ do+    asked <- newIORef (0 :: Int)+    always <- newIORef False+    done <- newIORef False+    (ran, bump) <- counter+    -- met the first time it is asked (the check), unmet after that (the up)+    let pre = do+            n <- atomicModifyIORef' asked (\k -> (k + 1, k))+            ok <- readIORef always+            pure (if ok || n == 0 then Met else Unmet "the leader moved")+        o =+            node "upgrade" $ \x ->+                guarded+                    defaultGuarding+                    pre+                    x+                        { check = do+                            ok <- readIORef done+                            pure (if ok then Success else Failure "not done")+                        , up = bump >> writeIORef done True+                        }+    supervising (dagOf o) allUp $ \sup trace -> do+        await trace (\rs -> unknownLooksAt "upgrade" rs >= 1)+        rs <- seen trace+        assertEqual "the operation itself never ran" 0 =<< readIORef ran+        assertEqual+            "the refusal was said, as what it is"+            [True]+            [ case fromException e of+                Just (PreconditionUnmet _) -> True+                Nothing -> False+            | Acted (UpDown.Failed _ e) <- rs+            ]+        assertEqual "and did not park the node" [] [n | GaveUp _ n <- rs]+        writeIORef always True+        void (Upkeep.instruct sup (refOf "upgrade") Recheck)+        await trace (\rs -> donesOf "upgrade" rs >= 1)+        assertEqual "it ran once it could" 1 =<< readIORef ran++{- | Rule two. A real failure is counted, and at the limit the node parks: no+retry on a timer, however long it is left. 'Force' is the operator clearing+the latch.+-}+guardedParksOnFailure :: IO ()+guardedParksOnFailure = within 20 $ do+    pre <- newIORef Met+    done <- newIORef False+    (ran, bump) <- counter+    let o =+            riskyNode+                "upgrade"+                defaultGuarding{guardingAttempts = 2}+                pre+                done+                (bump >> throwIO (ErrorCall "pg_upgrade exited 1"))+    supervising (dagOf o) allUp $ \sup trace -> do+        await trace (\rs -> not (null [n | GaveUp _ n <- rs]))+        assertEqual "tried exactly as often as it was allowed" 2 =<< readIORef ran+        -- nothing to await for a non-event. Three times the retry floor: an+        -- unparked node would have had another go well inside it.+        threadDelay 1500000+        assertEqual "and did not start over on its own" 2 =<< readIORef ran+        rs <- seen trace+        assertEqual "parked once" [2] [n | GaveUp _ n <- rs]+        void (Upkeep.instruct sup (refOf "upgrade") Force)+        await trace (\rs -> length [n | GaveUp _ n <- rs] >= 2)+        n <- readIORef ran+        assertBool "an operator starts it over" (n >= 3)++{- | Rule three. The siblings named in the hold set are paused before the+operation's @up@ starts and resumed after it has finished, and in between+they do nothing even when told to look.+-}+guardedHoldsSiblings :: IO ()+guardedHoldsSiblings = within 10 $ do+    pre <- newIORef (Unmet "not yet")+    done <- newIORef False+    gate <- newEmptyMVar+    (sibling, bumpSibling) <- counter+    let replica = siblingNode "replica" bumpSibling+        upgrade =+            riskyNode+                "upgrade"+                defaultGuarding{guardingHolds = [refOf "replica"]}+                pre+                done+                (takeMVar gate)+        top = nodeOn "top" [replica, upgrade] id+    supervising (dagOf top) allUp $ \sup trace -> do+        -- the sibling is up and being tended before the operation starts+        await trace (\rs -> reachedBy "replica" Up rs >= 1)+        writeIORef pre Met+        void (Upkeep.instruct sup (refOf "upgrade") Recheck)+        await trace (any (isEvalOf "upgrade"))+        before <- seen trace+        assertBool+            "the sibling was paused before the operation started"+            (maybe False (\i -> Just i < indexOf (isEvalOf "upgrade") before) (indexOf (isPausedOf "replica") before))+        held <- readIORef sibling+        -- a tended node would look, find its effect missing and re-apply+        void (Upkeep.instruct sup (refOf "replica") Recheck)+        await trace (\rs -> not (null [() | Acted (UpDown.Instructed a Recheck) <- rs, a.shorthand == "replica"]))+        assertEqual "held still while the operation ran" held =<< readIORef sibling+        during <- seen trace+        assertBool "and not resumed yet" (not (any (isResumedOf "replica") during))+        putMVar gate ()+        await trace (any (isResumedOf "replica"))+        after <- seen trace+        assertBool+            "resumed once the operation was done"+            (indexOf (isDoneOf "upgrade") after < indexOf (isResumedOf "replica") after)++{- | Posting a 'Pause' is not the sibling having stopped: one in the middle+of its own @up@ only reads its mailbox when that returns. The operation+waits for it.+-}+guardedWaitsForSiblingInFlight :: IO ()+guardedWaitsForSiblingInFlight = within 10 $ do+    -- unmet until the sibling is known to be inside its up: met from the+    -- start, the operation could claim the sibling before its machine had+    -- begun, pause it in 'WaitUp', run, and release it into the blocking up+    pre <- newIORef (Unmet "not yet")+    done <- newIORef False+    gate <- newEmptyMVar+    (ran, bump) <- counter+    first <- newIORef True+    -- blocks the first time only: once resumed it is re-applied again, and+    -- must not sit in an up nobody will ever release.+    let slowOnce = do+            block <- atomicModifyIORef' first (\was -> (False, was))+            if block then takeMVar gate else pure ()+    let replica = siblingNode "replica" slowOnce+        upgrade = riskyNode "upgrade" defaultGuarding{guardingHolds = [refOf "replica"]} pre done bump+        top = nodeOn "top" [replica, upgrade] id+    -- the gate is opened on the way out whatever happens: a failing+    -- assertion must not leave the sibling's up blocked, or the teardown+    -- waits on it and the failure is reported as a timeout instead+    supervising (dagOf top) allUp $ \sup trace -> flip finally (tryPutMVar gate ()) $ do+        await trace (any (isEvalOf "replica"))+        writeIORef pre Met+        void (Upkeep.instruct sup (refOf "upgrade") Recheck)+        await trace (\rs -> reachedBy "upgrade" Upping rs >= 1)+        -- nothing to await for a non-event: time it could have used to start+        threadDelay 300000+        assertEqual "not started while the sibling is mid-up" 0 =<< readIORef ran+        putMVar gate ()+        await trace (\rs -> donesOf "upgrade" rs >= 1)+        rs <- seen trace+        assertBool+            "started only after the sibling had stopped"+            (maybe False (\i -> Just i < indexOf (isEvalOf "upgrade") rs) (indexOf (isPausedOf "replica") rs))++-- | The other way a hold ends: the operation is parked, so it is not+-- running, so nothing is held.+guardedReleasesOnPark :: IO ()+guardedReleasesOnPark = within 10 $ do+    pre <- newIORef Met+    done <- newIORef False+    let replica = siblingNode "replica" (pure ())+        upgrade =+            riskyNode+                "upgrade"+                defaultGuarding{guardingHolds = [refOf "replica"]}+                pre+                done+                (throwIO (ErrorCall "failed halfway"))+        top = nodeOn "top" [replica, upgrade] id+    supervising (dagOf top) allUp $ \_ trace -> do+        await trace (\rs -> not (null [() | GaveUp{} <- rs]) && any (isResumedOf "replica") rs)+        rs <- seen trace+        assertBool "it had been paused" (any (isPausedOf "replica") rs)++{- | A sibling an operator paused is not this operation's to resume: it was+standing still before, and it is standing still after.+-}+guardedLeavesOperatorPauseAlone :: IO ()+guardedLeavesOperatorPauseAlone = within 10 $ do+    pre <- newIORef (Unmet "not yet")+    done <- newIORef False+    let replica = siblingNode "replica" (pure ())+        upgrade = riskyNode "upgrade" defaultGuarding{guardingHolds = [refOf "replica"]} pre done (pure ())+        top = nodeOn "top" [replica, upgrade] id+    supervising (dagOf top) allUp $ \sup trace -> do+        await trace (\rs -> reachedBy "replica" Up rs >= 1)+        void (Upkeep.instruct sup (refOf "replica") Pause)+        await trace (any (isPausedOf "replica"))+        writeIORef pre Met+        void (Upkeep.instruct sup (refOf "upgrade") Recheck)+        -- the hold is let go before the operation says it is up+        await trace (\rs -> reachedBy "upgrade" Up rs >= 1)+        rs <- seen trace+        assertEqual "the operation ran" 1 (donesOf "upgrade" rs)+        assertBool "and the operator's pause outlived it" (not (any (isResumedOf "replica") rs))++{- | Two operations each naming the other. Without the claim being one+transaction they would each pause the other and wait forever.+-}+guardedMutualHoldsDoNotDeadlock :: IO ()+guardedMutualHoldsDoNotDeadlock = within 20 $ do+    pre <- newIORef Met+    doneA <- newIORef False+    doneB <- newIORef False+    running <- newIORef (0 :: Int)+    overlap <- newIORef False+    let body = do+            n <- atomicModifyIORef' running (\k -> (k + 1, k + 1))+            unless (n == 1) (writeIORef overlap True)+            threadDelay 100000+            atomicModifyIORef' running (\k -> (k - 1, ()))+        a = riskyNode "a" defaultGuarding{guardingHolds = [refOf "b"]} pre doneA body+        b = riskyNode "b" defaultGuarding{guardingHolds = [refOf "a"]} pre doneB body+        top = nodeOn "top" [a, b] id+    supervising (dagOf top) allUp $ \_ trace -> do+        await trace (\rs -> donesOf "a" rs >= 1 && donesOf "b" rs >= 1)+        assertEqual "and never at the same time" False =<< readIORef overlap++{- | @run up@ maps 'Unknown' to "run @up@", so the check alone would not hold+a one-shot pass back. The guarded @up@ does: it throws, the node is reported+failed, and what stands on it is blocked.+-}+guardedOneShotRefuses :: IO ()+guardedOneShotRefuses = do+    pre <- newIORef (Unmet "a replica is lagging")+    done <- newIORef False+    (ran, bump) <- counter+    let upgrade = riskyNode "upgrade" defaultGuarding pre done bump+        top = nodeOn "after" [upgrade] id+    refused <- runUpCapturing top+    assertEqual "the operation did not run" 0 =<< readIORef ran+    assertEqual "it is a failure of the pass" ["upgrade"] [a.shorthand | UpDown.Failed a _ <- refused]+    assertEqual "and its dependant is blocked" ["after"] [a.shorthand | UpDown.Blocked a <- refused]+    writeIORef pre Met+    allowed <- runUpCapturing top+    assertEqual "met, it runs" 1 =<< readIORef ran+    assertEqual "and is done" ["upgrade", "after"] [a.shorthand | UpDown.Done a <- allowed]++guardedDeclaration :: IO ()+guardedDeclaration = do+    let declared holds =+            node "upgrade" $ \x ->+                guarded+                    defaultGuarding{guardingHolds = holds}+                    (pure Met)+                    x{dynamics = [supervised defaultSupervision{supWatchdog = Just (seconds 30)}]}+        actOf o = Dag.representativeOf (dagOf o) (refOf "upgrade")+    case (actOf (declared [refOf "a"]), actOf (declared [refOf "a"]), actOf (declared [refOf "b"])) of+        (Just one, Just same, Just other) -> do+            assertEqual+                "its own policy, with only the give-up limit set"+                (defaultSupervision{supWatchdog = Just (seconds 30), supGiveUpAfter = Just 1}, [])+                (supervisionOf one.extension)+            assertBool "the same declaration is the same node" (Dag.sameRepresentative one same)+            assertBool "a different hold set is not" (not (Dag.sameRepresentative one other))+        _ -> assertBool "the node is in its own dag" False
test/Test/WindowSpec.hs view
@@ -1,3 +1,4 @@+{-# LANGUAGE OverloadedRecordDot #-} {-# LANGUAGE OverloadedStrings #-}  -- | Layer 0 coverage for "Salmon.Op.Window": membership (midnight, weekly,@@ -5,18 +6,38 @@ -- node until an injected clock passes into the window. module Test.WindowSpec (tests) where +import Control.Concurrent (forkIO, threadDelay)+import Control.Concurrent.MVar (newEmptyMVar, putMVar, readMVar, takeMVar)+import Control.Concurrent.STM (TVar, atomically, modifyTVar', newTVarIO, readTVar, readTVarIO, retry, writeTChan)+import Control.Exception (bracket)+import Control.Monad (unless, when) import Data.Functor.Identity (runIdentity) import Data.IORef (modifyIORef', newIORef, readIORef, writeIORef)+import qualified Data.Map.Strict as Map+import Data.Maybe (isJust) import Data.Text (Text)+import qualified Data.Text as Text import Data.Time (UTCTime) import Data.Time.Format (defaultTimeLocale, parseTimeOrError)+import System.Timeout (timeout) import Test.Tasty (TestTree, testGroup)-import Test.Tasty.HUnit (assertBool, assertEqual, testCase)+import Test.Tasty.HUnit (assertBool, assertEqual, assertFailure, testCase) -import Salmon.Actions.UpDown (upTreeWith)-import Salmon.Builtin.Extension (Extension (..), nodeps, op)+import qualified Salmon.Actions.Serve as Serve+import Salmon.Actions.UpDown (CheckResult (..), upTreeWith)+import qualified Salmon.Actions.UpDown as UpDown+import qualified Salmon.Actions.Upkeep as Upkeep+import Salmon.Builtin.Extension (Extension (..), Op, Track', deps, evalDeps, nodeps, noop, op, opAct)+import Salmon.Op.Actions (Act (..))+import Salmon.Op.Configure (Configure (..))+import qualified Salmon.Op.Dag as Dag+import Salmon.Op.Mailbox (Instruction (..)) import Salmon.Op.Ref (mkRef)+import Salmon.Op.Status (Direction (..))+import Salmon.Op.Supervision (millis)+import Salmon.Op.Track (Track (..)) import Salmon.Op.Window+import Salmon.Reporter (ReporterM (..))  import Test.Harness (capture) @@ -77,7 +98,181 @@                 ["", "02:00", "02:00-02:00", "Xyz:02:00-03:00", "25:00-03:00", "02:00-03:60", "02:00-03:00@Mars", "2:0-3:0"]             assertEqual "roundtrip" "Sun:02:00-04:00@+02:00" (renderWindow (win "Sun:02:00-04:00@+02:00"))         , testCase "a held node is not applied, then is once the clock enters the window" gateHolds+        , testCase "holdOf: free without windows, for a plain node, and inside a window" holdOfCases+        , testGroup+            "Upkeep under windows"+            [ testCase "a due disruptive node waits, says Held once, and goes ahead when the window opens" upkeepWaitsForWindow+            , testCase "a disruptive node already in place is not held" upkeepSatisfiedNotHeld+            , testCase "Force goes through a closed window" upkeepForceGoesThrough+            ]+        , testCase "run serve: a pass holds a disruptive node, status says until when, tending applies it in the window" serveHoldsThenApplies         ]++holdOfCases :: IO ()+holdOfCases = do+    clock <- newIORef (at "2026-09-26 10:00")+    let ws = Windows [win "02:00-04:00"] (readIORef clock) (millis 20)+        plain = maybe (error "noop has an action") (\a -> a.extension) (opAct (noop "plain"))+        restart = disruptive plain+    assertEqual "no windows" Free =<< holdOf noWindows restart+    assertEqual "not disruptive" Free =<< holdOf ws plain+    assertEqual "held" (HeldUntil (Just (at "2026-09-27 02:00"))) =<< holdOf ws restart+    writeIORef clock (at "2026-09-27 03:00")+    assertEqual "open" Free =<< holdOf ws restart++-------------------------------------------------------------------------------+-- Upkeep++type Trace = TVar [Upkeep.Report Extension]++awaitTrace :: Trace -> ([Upkeep.Report Extension] -> Bool) -> IO ()+awaitTrace trace p = atomically (readTVar trace >>= \rs -> unless (p (reverse rs)) retry)++within :: Int -> IO a -> IO a+within secs act = timeout (secs * 1000000) act >>= maybe (assertFailure "timed out" >> error "unreachable") pure++-- | One node, tended up from scratch under these windows.+tendingOne :: Windows -> Op -> (Upkeep.Supervisor Extension -> Trace -> IO a) -> IO a+tendingOne ws o body = do+    trace <- newTVarIO []+    let r = ReporterM $ \rep -> atomically (modifyTVar' trace (rep :))+        dag = Dag.foldDag Dag.sameRepresentative (evalDeps o)+    bracket+        (Upkeep.startUpkeepWith ws r Upkeep.noKept (const (Just (Upkeep.Tend TurnUp Upkeep.Unsettled))) dag)+        Upkeep.stopUpkeep+        (\sup -> body sup trace)++heldReports :: [Upkeep.Report Extension] -> [Maybe UTCTime]+heldReports rs = [next | Upkeep.Held _ next <- rs]++dones :: [Upkeep.Report Extension] -> Int+dones rs = length [() | Upkeep.Acted (UpDown.Done _) <- rs]++upkeepWaitsForWindow :: IO ()+upkeepWaitsForWindow = within 10 $ do+    applied <- newIORef (0 :: Int)+    clock <- newIORef (at "2026-09-26 10:00")+    let ws = Windows [win "02:00-04:00"] (readIORef clock) (millis 20)+        o = op "restart" nodeps $ \x ->+            disruptive x{ref = mkRef "restart" ("r" :: Text), check = pure (Failure "due"), up = modifyIORef' applied (+ 1)}+    tendingOne ws o $ \_ trace -> do+        awaitTrace trace (not . null . heldReports)+        -- several rechecks go by while held+        threadDelay 150000+        assertEqual "not applied while held" 0 =<< readIORef applied+        rs <- reverse <$> readTVarIO trace+        assertEqual "said once, with the next opening" [Just (at "2026-09-27 02:00")] (heldReports rs)+        writeIORef clock (at "2026-09-27 02:10")+        awaitTrace trace ((>= 1) . dones)+        assertEqual "applied once the window opened" 1 =<< readIORef applied++upkeepSatisfiedNotHeld :: IO ()+upkeepSatisfiedNotHeld = within 10 $ do+    applied <- newIORef (0 :: Int)+    let ws = Windows [win "02:00-04:00"] (pure (at "2026-09-26 10:00")) (millis 20)+        o = op "restart" nodeps $ \x ->+            disruptive x{ref = mkRef "restart" ("r" :: Text), check = pure Success, up = modifyIORef' applied (+ 1)}+    tendingOne ws o $ \_ trace -> do+        awaitTrace trace (\rs -> not (null [() | Upkeep.Upkeep _ Upkeep.Up <- rs]))+        rs <- reverse <$> readTVarIO trace+        assertEqual "never held" [] (heldReports rs)+        assertEqual "never applied" 0 =<< readIORef applied++upkeepForceGoesThrough :: IO ()+upkeepForceGoesThrough = within 10 $ do+    applied <- newIORef (0 :: Int)+    let ws = Windows [win "02:00-04:00"] (pure (at "2026-09-26 10:00")) (millis 20)+        r = mkRef "restart" ("r" :: Text)+        o = op "restart" nodeps $ \x ->+            disruptive x{ref = r, check = pure (Failure "due"), up = modifyIORef' applied (+ 1)}+    tendingOne ws o $ \sup trace -> do+        awaitTrace trace (not . null . heldReports)+        assertEqual "held" 0 =<< readIORef applied+        _ <- Upkeep.instruct sup r Force+        awaitTrace trace ((>= 1) . dones)+        assertEqual "forced through" 1 =<< readIORef applied++-------------------------------------------------------------------------------+-- run serve++serveHoldsThenApplies :: IO ()+serveHoldsThenApplies = within 20 $ do+    restartUps <- newIORef (0 :: Int)+    plainUps <- newIORef (0 :: Int)+    clock <- newIORef (at "2026-09-26 10:00")+    reports <- newTVarIO ([] :: [Serve.Report])+    inbox <- newEmptyMVar+    worldVar <- newEmptyMVar+    let ws = Windows [win "02:00-04:00"] (readIORef clock) (millis 20)+        restartRef = mkRef "restart" ("r" :: Text)+        plainRef = mkRef "plain" ("p" :: Text)+        program :: Track' [String]+        program = Track $ \_ ->+            op "root" (deps [restartOp, plainOp]) $ \x -> x{ref = mkRef "root" ("root" :: Text)}+        restartOp = op "restart" nodeps $ \x -> disruptive x{ref = restartRef, up = modifyIORef' restartUps (+ 1)}+        plainOp = op "plain" nodeps $ \x -> x{ref = plainRef, up = modifyIORef' plainUps (+ 1)}+        serveR = ReporterM $ \rep -> atomically (modifyTVar' reports (Serve.attributed rep :))+        silentR = ReporterM $ \_ -> pure ()+        -- lines are handed over one at a time, so the loop goes idle (and+        -- tends) in between+        producer = Serve.Producer $ \chan ->+            let go = do+                    next <- takeMVar inbox+                    case next of+                        Just l -> atomically (writeTChan chan (Serve.Line Serve.Stdin l)) >> go+                        Nothing -> atomically (writeTChan chan (Serve.Eof Serve.Stdin))+             in go+        nodeOf rf = do+            readWorld <- readMVar worldVar+            w <- readWorld+            pure (Map.lookup rf w.worldNodes)+        awaitNode rf p = do+            st <- nodeOf rf+            unless (maybe False p st) (threadDelay 10000 >> awaitNode rf p)+    done <- newEmptyMVar+    _ <- forkIO $ do+        _ <-+            Serve.serveObservedWith+                ws+                (putMVar worldVar)+                []+                Nothing+                True+                serveR+                silentR+                (Right :: [String] -> Either Text [String])+                (Configure pure)+                program+                Nothing+                [producer]+        putMVar done ()+    putMVar inbox (Just "up x")+    awaitNode restartRef (isJust . Serve.nodeHeld)+    Just st <- nodeOf restartRef+    assertEqual "held until the next opening" (Just (Just (at "2026-09-27 02:00"))) st.nodeHeld+    assertBool "not counted converged" (st.nodeConvergence /= Serve.Converged)+    assertEqual "not applied" 0 =<< readIORef restartUps+    assertEqual "a node with no opinion is applied" 1 =<< readIORef plainUps+    putMVar inbox (Just "status")+    let statusLines = do+            rs <- readTVarIO reports+            pure (concat [Serve.renderReport rep | rep@Serve.StatusReport{} <- rs])+        awaitStatus = statusLines >>= \ls -> when (null ls) (threadDelay 10000 >> awaitStatus)+    awaitStatus+    ls <- statusLines+    assertBool+        ("status says until when: " <> show ls)+        (any ("[held by maintenance window until 2026-09-27 02:00:00 UTC]" `Text.isInfixOf`) ls)+    rs <- readTVarIO reports+    assertBool "the pass said Held" (not (null [() | Serve.Tended (Upkeep.Held _ _) <- rs]))+    -- the window opens; the loop is idle, so the node's machine applies it+    writeIORef clock (at "2026-09-27 02:10")+    awaitNode restartRef (\s -> s.nodeConvergence == Serve.Converged)+    Just st' <- nodeOf restartRef+    assertEqual "no longer held" Nothing st'.nodeHeld+    assertEqual "applied once, in the window" 1 =<< readIORef restartUps+    putMVar inbox Nothing+    takeMVar done  gateHolds :: IO () gateHolds = do
+ test/Test/WireGuardMeshSpec.hs view
@@ -0,0 +1,179 @@+{-# LANGUAGE OverloadedStrings #-}++{- | Layer 0 for "SreBox.WireGuardMesh": a table of seeds to per-host views+(full mesh, a peer without an endpoint, a router, an exit node, policies) and+the refusals. No Op, no IO.+-}+module Test.WireGuardMeshSpec (tests) where++import Data.Aeson (decode, encode)+import Data.Either (isLeft)+import Data.Text (Text)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, assertEqual, assertFailure, testCase)++import SreBox.WireGuardMesh++peerD :: Text -> Text -> Maybe Text -> [Text] -> PeerDecl+peerD n a ep gs = PeerDecl n a (n <> "-key=") ep gs++seed :: MeshSeed+seed =+    MeshSeed+        { mesh_subnet = "10.66.0.0/24"+        , mesh_iface = "wg0"+        , mesh_port = 51820+        , mesh_privkey_path = "/etc/wireguard/wg0.key"+        , mesh_peers =+            [ peerD "a" "10.66.0.1" (Just "a.example:51820") ["web"]+            , peerD "b" "10.66.0.2" (Just "b.example:51820") ["db"]+            , peerD "c" "10.66.0.3" Nothing ["web"]+            ]+        , mesh_policies = []+        , mesh_routers = []+        }++host :: MeshSeed -> Text -> HostSpec+host s n = either (error . show) id (genHost s n)++linkNames :: HostSpec -> [Text]+linkNames h = link_name <$> host_peers h++link :: HostSpec -> Text -> PeerLink+link h n = case [l | l <- host_peers h, link_name l == n] of+    (l : _) -> l+    [] -> error ("no link " <> show n)++tests :: TestTree+tests =+    testGroup+        "SreBox.WireGuardMesh"+        [ testGroup+            "fan-out"+            [ testCase "hosts with endpoints peer with everyone, as /32s" $ do+                let a = host seed "a"+                assertEqual "" ["b", "c"] (linkNames a)+                assertEqual "" ["10.66.0.2/32"] (link_allowed_ips (link a "b"))+                assertEqual "" (Just "b.example:51820") (link_endpoint (link a "b"))+                assertEqual "" "10.66.0.1/24" (host_addr a)+            , testCase "a host with an endpoint does not keepalive, and does not dial a peer without one" $ do+                let a = host seed "a"+                assertEqual "" Nothing (link_endpoint (link a "c"))+                assertEqual "" Nothing (link_keepalive (link a "c"))+            , testCase "a peer with no endpoint dials out and keeps alive" $ do+                let c = host seed "c"+                assertEqual "" ["a", "b"] (linkNames c)+                assertEqual "" (Just 25) (link_keepalive (link c "a"))+            , testCase "two peers without endpoints have no link" $ do+                let s = seed{mesh_peers = mesh_peers seed <> [peerD "d" "10.66.0.4" Nothing []]}+                assertEqual "" ["a", "b"] (linkNames (host s "d"))+                assertEqual "" ["a", "b"] (linkNames (host s "c"))+            , testCase "genAll covers every peer, in order" $+                assertEqual "" (Right ["a", "b", "c"]) (fmap (fmap host_name) (genAll seed))+            , testCase "adding a peer changes nobody else's address" $ do+                let s = seed{mesh_peers = mesh_peers seed <> [peerD "d" "10.66.0.9" (Just "d.example:1") []]}+                assertEqual "" (host_addr (host seed "b")) (host_addr (host s "b"))+            ]+        , testGroup+            "routers"+            [ testCase "a router's network rides its allowed-ips on the others, and they route it" $ do+                let s = seed{mesh_routers = [RouterDecl "a" "192.168.5.0/24"]}+                    b = host s "b"+                assertEqual "" ["10.66.0.1/32", "192.168.5.0/24"] (link_allowed_ips (link b "a"))+                assertEqual "" ["192.168.5.0/24"] (host_routes b)+                assertEqual "" (Just "10.66.0.0/24") (host_forwards (host s "a"))+                assertEqual "" Nothing (host_forwards b)+                assertEqual "" [] (host_routes (host s "a"))+            , testCase "an exit node: split default routes and the endpoint pinned" $ do+                let s =+                        seed+                            { mesh_peers = peerD "a" "10.66.0.1" (Just "203.0.113.7:51820") ["web"] : drop 1 (mesh_peers seed)+                            , mesh_routers = [RouterDecl "a" "0.0.0.0/0"]+                            }+                    b = host s "b"+                assertEqual "" ["0.0.0.0/1", "128.0.0.0/1"] (host_routes b)+                assertEqual "" ["203.0.113.7"] (host_exit_pins b)+                assertEqual "" ["10.66.0.1/32", "0.0.0.0/0"] (link_allowed_ips (link b "a"))+            ]+        , testGroup+            "policies"+            [ testCase "a rule per policy on the hosts in its destination groups" $ do+                let s = seed{mesh_policies = [Policy ["web"] ["db"] Tcp [PortRange 5432 5432]]}+                assertEqual "" [] (host_input_rules (host s "a"))+                assertEqual+                    ""+                    [NftRuleSpec ["iifname", "\"wg0\"", "ip", "saddr", "{ 10.66.0.1, 10.66.0.3 }", "tcp", "dport", "5432", "accept"]]+                    (host_input_rules (host s "b"))+            , testCase "ranges and several ports render as a set" $ do+                let s = seed{mesh_policies = [Policy ["db"] ["web"] Udp [PortRange 53 53, PortRange 8000 8100]]}+                assertEqual+                    ""+                    [NftRuleSpec ["iifname", "\"wg0\"", "ip", "saddr", "10.66.0.2", "udp", "dport", "{ 53, 8000-8100 }", "accept"]]+                    (host_input_rules (host s "a"))+            , testCase "icmp and any" $ do+                let s = seed{mesh_policies = [Policy ["db"] ["web"] Icmp [], Policy ["db"] ["web"] AnyProto []]}+                assertEqual+                    ""+                    [ NftRuleSpec ["iifname", "\"wg0\"", "ip", "saddr", "10.66.0.2", "ip", "protocol", "icmp", "accept"]+                    , NftRuleSpec ["iifname", "\"wg0\"", "ip", "saddr", "10.66.0.2", "accept"]+                    ]+                    (host_input_rules (host s "a"))+            ]+        , testGroup+            "refusals"+            [ refused "two peers with one address" (seed{mesh_peers = mesh_peers seed <> [peerD "d" "10.66.0.1" Nothing []]}) $ \es ->+                DuplicateAddress "10.66.0.1" ["a", "d"] `elem` es+            , refused "a peer outside the subnet" (seed{mesh_peers = mesh_peers seed <> [peerD "d" "10.67.0.1" Nothing []]}) $ \es ->+                AddressOutsideSubnet "d" "10.67.0.1" `elem` es+            , refused "an address that is not IPv4" (seed{mesh_peers = mesh_peers seed <> [peerD "d" "10.66.0.300" Nothing []]}) $ \es ->+                BadAddress "d" "10.66.0.300" `elem` es+            , refused "a policy naming an unknown group" (seed{mesh_policies = [Policy ["web"] ["nope"] Tcp []]}) $ \es ->+                UnknownGroup 1 "nope" `elem` es+            , refused "ports on icmp" (seed{mesh_policies = [Policy ["web"] ["db"] Icmp [PortRange 1 2]]}) $ \es ->+                any isBadPorts es+            , refused "a reversed port range" (seed{mesh_policies = [Policy ["web"] ["db"] Tcp [PortRange 9 1]]}) $ \es ->+                any isBadPorts es+            , refused "a router that is no peer" (seed{mesh_routers = [RouterDecl "zz" "10.9.0.0/16"]}) $ \es ->+                UnknownRouterPeer "zz" `elem` es+            , refused "two exit nodes" (seed{mesh_routers = [RouterDecl "a" "0.0.0.0/0", RouterDecl "b" "0.0.0.0/0"]}) $ \es ->+                any isSeveralExit es+            , refused "an exit node endpoint that is a name" (seed{mesh_routers = [RouterDecl "a" "0.0.0.0/0"]}) $ \es ->+                ExitEndpointNotLiteral "a" `elem` es+            , refused "a duplicate public key" (seed{mesh_peers = [p{peer_pubkey = "same="} | p <- mesh_peers seed]}) $ \es ->+                any isDupKey es+            , refused "a bad subnet" (seed{mesh_subnet = "10.66.0.0"}) $ \es ->+                BadSubnet "10.66.0.0" `elem` es+            , testCase "an unknown host" $+                assertEqual "" (Left [UnknownHost "zz"]) (genHost seed "zz")+            , testCase "refusals are reported together" $+                case genAll seed{mesh_subnet = "x", mesh_policies = [Policy ["web"] ["nope"] Tcp []]} of+                    Left es -> assertBool (show es) (length es >= 2)+                    Right _ -> assertFailure "accepted"+            ]+        , testGroup+            "pure ipv4"+            [ testCase "parse" $ do+                assertEqual "" (Just 0x0A420001) (parseIpv4 "10.66.0.1")+                assertEqual "" Nothing (parseIpv4 "10.66.0")+                assertEqual "" Nothing (parseIpv4 "10.66.0.256")+            , testCase "membership" $ do+                assertBool "in" (maybe False (\n -> maybe False (inSubnet n) (parseIpv4 "10.66.0.200")) (parseCidr "10.66.0.0/24"))+                assertBool "out" (not (maybe True (\n -> maybe True (inSubnet n) (parseIpv4 "10.66.1.1")) (parseCidr "10.66.0.0/24")))+            ]+        , testGroup+            "delivery"+            [ testCase "a host spec survives JSON" $+                assertEqual "" (Just (host seed "a")) (decode (encode (host seed "a")))+            , testCase "a seed survives JSON" $+                assertEqual "" (Just seed) (decode (encode seed))+            , testCase "a refused seed produces no documents" $+                assertBool "" (isLeft (genAll seed{mesh_subnet = "x"}))+            ]+        ]+  where+    refused name s p = testCase ("refuses " <> name) $ case genAll s of+        Left es -> assertBool (show es) (p es)+        Right _ -> assertFailure "accepted"+    isBadPorts e = case e of BadPorts _ _ -> True; _ -> False+    isSeveralExit e = case e of SeveralExitRouters _ -> True; _ -> False+    isDupKey e = case e of DuplicatePubkey _ -> True; _ -> False
+ test/Test/WrapperOrderingSpec.hs view
@@ -0,0 +1,266 @@+{-# LANGUAGE OverloadedStrings #-}++{- | Layer 0: what is ordered before the nodes that do the work, rather than+before a node grouping them.++A node built with @op name (deps [...]) id@ (or one that only sets its+@help@ and @ref@) has no effect of its own. 'Salmon.Op.OpGraph.inject' onto+it adds a predecessor to that node, and the fold reads all of a node's+predecessors as "depends on", unordered among themselves: what was injected+is then a /sibling/ of the work, which a one-shot pass happens to visit in+listed order and @run serve@ converges concurrently. Each test here names a+recipe that used to do that, and asserts on the folded 'Dag.Dag' that every+working node now has the prerequisite below it. Graph shape only: nothing+here runs a node.+-}+module Test.WrapperOrderingSpec (tests) where++import Data.Aeson (Value (String))+import qualified Data.Map.Strict as Map+import qualified Data.Set as Set+import Data.Text (Text)+import qualified Data.Text as Text+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, assertEqual, testCase)++import Salmon.Builtin.Extension (Extension (..), Op, evalDeps, ignoreTrack)+import qualified Salmon.Builtin.Nodes.Filesystem as FS+import qualified Salmon.Builtin.Nodes.Gcp.ArtifactRegistry as ArtifactRegistry+import qualified Salmon.Builtin.Nodes.Gcp.CloudRun as CloudRun+import qualified Salmon.Builtin.Nodes.Gcp.Core as Core+import qualified Salmon.Builtin.Nodes.Git as Git+import qualified Salmon.Builtin.Nodes.Podman as Podman+import qualified Salmon.Builtin.Nodes.Postgres as Postgres+import qualified Salmon.Builtin.Nodes.Self as Self+import qualified Salmon.Builtin.Nodes.WireGuard as WG+import Salmon.Op.Actions (Act (..))+import qualified Salmon.Op.Dag as Dag+import Salmon.Op.Ref (Ref)+import Salmon.Reporter (silent)+import qualified SreBox.Gcp.PostgrestCloudRun as PCR+import qualified SreBox.PostgresInit as PGInit+import qualified SreBox.PostgresTls as Tls+import qualified SreBox.Postgrest as Postgrest+import qualified SreBox.WireGuardMesh as Mesh+import qualified SreBox.WireGuardVpn as Vpn++tests :: TestTree+tests =+    testGroup+        "inject lands on the working nodes, not on a grouping node (Layer 0)"+        [ testCase "PostgresInit.setupPG: grants and ownership wait for the database, roles for the groups" setupPGOrder+        , testCase "Postgrest.postgrestMigratedApi: the migration waits for the init, the service for the migration" postgrestOrder+        , testCase "PostgresTls.postgresClientCertAuth: the TLS settings and the reload wait for the material" serverTlsOrder+        , testCase "WireGuardMesh.hostOp: every peer waits for the interface's key and port" meshPeersOrder+        , testCase "WireGuardVpn.serverVpn: every peer waits for the interface's key and port" vpnPeersOrder+        , testCase "Gcp.PostgrestCloudRun: the build waits for the entrypoint, the deploy for secrets and grants" cloudRunOrder+        ]++-------------------------------------------------------------------------------++dagOf :: Op -> Dag.Dag Extension+dagOf = Dag.foldDag Dag.sameRepresentative . evalDeps++type Pick = Act Extension -> Bool++short :: Text -> Pick+short s a = a.shorthand == s++helpHas :: Text -> Pick+helpHas t a = t `Text.isInfixOf` help a.extension++(&&&) :: Pick -> Pick -> Pick+(&&&) p q a = p a && q a++picked :: Pick -> Dag.Dag Extension -> [Ref]+picked p dag = [rf | (rf, a) <- Map.toList (Dag.dagNodes dag), p a]++-- | Everything a node waits for, however far down.+below :: Dag.Dag Extension -> Ref -> Set.Set Ref+below dag = go Set.empty . Dag.dependenciesOf dag+  where+    go seen [] = seen+    go seen (r : rest)+        | r `Set.member` seen = go seen rest+        | otherwise = go (Set.insert r seen) (Dag.dependenciesOf dag r <> rest)++{- | There are nodes picked by each, and every node picked by the first has+one picked by the second below it.+-}+assertAllWaitFor :: String -> Dag.Dag Extension -> Pick -> Pick -> IO ()+assertAllWaitFor what dag from to = do+    let starts = picked from dag+        targets = Set.fromList (picked to dag)+    assertBool (what <> ": no node to start from") (not (null starts))+    assertBool (what <> ": no node to wait for") (not (Set.null targets))+    mapM_+        (\s -> assertBool (what <> ": " <> show s) (not (Set.null (Set.intersection targets (below dag s)))))+        starts++assertNoConflicts :: Dag.Dag Extension -> IO ()+assertNoConflicts dag = assertEqual "no declaration collides with another" 0 (length (Dag.dagConflicts dag))++-------------------------------------------------------------------------------++setupPGOrder :: IO ()+setupPGOrder = do+    let dag =+            dagOf . PGInit.setupPG silent $+                PGInit.InitSetup+                    (Postgres.Database "appdb")+                    (Postgres.User "owner", FS.PreExisting "/secrets/owner")+                    [(Postgres.User "alice", FS.PreExisting "/secrets/alice", [Postgres.CONNECT], [Postgres.Group "readers"])]+                    [(Postgres.Group "readers", [Postgres.CONNECT])]+        db = short "pg-database" &&& helpHas "create db appdb"+    assertAllWaitFor "every grant waits for the database" dag (short "pg-grant") db+    assertAllWaitFor "the ownership waits for the database" dag (helpHas "appdb ownership to owner") db+    assertAllWaitFor "a role waits for the database" dag (helpHas "set user password for alice") db+    assertAllWaitFor "a role waits for the groups" dag (helpHas "set user password for alice") (short "pg-group")+    assertNoConflicts dag++postgrestOrder :: IO ()+postgrestOrder = do+    let dag = dagOf node+        migration = short "opaque-migrate"+        calls = picked (short "ssh:call") dag+        migrations = picked migration dag+    assertEqual "one migration" 1 (length migrations)+    -- the service's call is the one with the migration below it; the init's+    -- call is below the migration+    let callsAbove = [c | c <- calls, any (`Set.member` below dag c) migrations]+        callsBelow = [c | m <- migrations, c <- calls, c `Set.member` below dag m]+    assertBool "the service's remote call waits for the migration" (not (null callsAbove))+    assertBool "the migration waits for the init's remote call" (not (null callsBelow))+    assertBool "two different calls" (all (`notElem` callsBelow) callsAbove)+    assertNoConflicts dag+  where+    node :: Op+    node =+        Postgrest.postgrestMigratedApi+            silent+            ignoreTrack+            -- three directives that differ, or the three remote calls are+            -- one site+            (const (String "init"))+            (const (String "migrate"))+            (const (String "postgrest"))+            (Self.SelfPath "/usr/local/bin/salmon")+            (Self.Remote "salmon" "db.example")+            ignoreTrack+            "/secrets/jwt"+            Postgrest.defaultFilePaths+            Postgrest.PostgrestMigratedApiConfig+                { Postgrest.pma_serviceName = "api"+                , Postgrest.pma_apiPort = 3000+                , Postgrest.pma_repo = Git.Repo "/src" "app" (Git.Remote "https://git.example/app.git") (Git.Branch "main")+                , Postgrest.pma_migrationTip = "migrations/tip.sql"+                , Postgrest.pma_migrationsPrefix = "migrations"+                , Postgrest.pma_migrate_connstring = conn "migrator" "/secrets/migrator"+                , Postgrest.pma_setrole_connstring = conn "authenticator" "/secrets/authenticator"+                , Postgrest.pma_fallback_role = Postgres.Group "anon"+                , Postgrest.pma_token_protected_roles = [Postgres.Group "user"]+                }+    conn u p = Postgres.ConnString (Postgres.Server "localhost" 5432) (Postgres.User u) p (Postgres.Database "appdb")++serverTlsOrder :: IO ()+serverTlsOrder = do+    let dag =+            dagOf $+                Tls.postgresClientCertAuth+                    silent+                    ignoreTrack+                    ignoreTrack+                    ignoreTrack+                    Tls.PostgresClientCertConfig+                        { Tls.pgcc_cluster = "main"+                        , Tls.pgcc_port = 5432+                        , Tls.pgcc_tls = Postgres.ServerTls "/etc/pg/server.pem" "/etc/pg/server.key" "/etc/pg/ca.pem"+                        , Tls.pgcc_owner = "postgres"+                        , Tls.pgcc_clients = [Tls.ClientAccess "api" "appdb" "0.0.0.0/0"]+                        }+        key = short "file-ownership" &&& helpHas "server.key"+    assertAllWaitFor "every TLS setting waits for the key's ownership" dag (short "pg-alter-system") key+    assertAllWaitFor "the reload waits for the key's ownership" dag (short "pg-reload-conf") key+    assertNoConflicts dag++binaries :: Vpn.Binaries+binaries = Vpn.Binaries ignoreTrack ignoreTrack ignoreTrack ignoreTrack++meshPeersOrder :: IO ()+meshPeersOrder = do+    let seed =+            Mesh.MeshSeed+                { Mesh.mesh_subnet = "10.66.0.0/24"+                , Mesh.mesh_iface = "wg0"+                , Mesh.mesh_port = 51820+                , Mesh.mesh_privkey_path = "/etc/wireguard/wg0.key"+                , Mesh.mesh_peers =+                    [ Mesh.PeerDecl "a" "10.66.0.1" "a-key=" (Just "a.example:51820") []+                    , Mesh.PeerDecl "b" "10.66.0.2" "b-key=" (Just "b.example:51820") []+                    , Mesh.PeerDecl "c" "10.66.0.3" "c-key=" Nothing []+                    ]+                , Mesh.mesh_policies = []+                , Mesh.mesh_routers = []+                }+        h = either (error . show) id (Mesh.genHost seed "a")+        dag = dagOf (Mesh.hostOp silent binaries h)+    assertAllWaitFor "every peer waits for the interface's key and port" dag (short "wireguard-peer") (short "wireguard-server")+    assertNoConflicts dag++vpnPeersOrder :: IO ()+vpnPeersOrder = do+    let dag =+            dagOf . Vpn.serverVpn silent binaries $+                Vpn.ServerVpnSpec+                    { Vpn.server_wg_iface = "wg0"+                    , Vpn.server_wg_port = 51820+                    , Vpn.server_wg_privkey_path = "/etc/wireguard/wg0.key"+                    , Vpn.server_vpn_net = WG.Ipv4Cidr "10.0.3.1" 24+                    , Vpn.server_clients =+                        [ Vpn.ClientPeer (WG.Ipv4Cidr "10.0.3.2" 32) "/etc/wireguard/client-a.pub"+                        , Vpn.ClientPeer (WG.Ipv4Cidr "10.0.3.3" 32) "/etc/wireguard/client-b.pub"+                        ]+                    }+    assertAllWaitFor "every peer waits for the interface's key and port" dag (short "wireguard-peer") (short "wireguard-server")+    assertNoConflicts dag++cloudRunOrder :: IO ()+cloudRunOrder = do+    let dag = dagOf (PCR.postgrestCloudRun silent ignoreTrack ignoreTrack cfg)+        deploy = helpHas "deploys CloudRun service api"+    assertAllWaitFor "the build waits for the entrypoint it copies" dag (short "podman-build") (helpHas "/tmp/work/run.sh")+    assertAllWaitFor "the deploy waits for every secret's upload" dag deploy (helpHas "to secret")+    mapM_+        (\name -> assertAllWaitFor ("the deploy waits for " <> Text.unpack name) dag deploy (helpHas ("to secret " <> name)))+        [PCR.certSecretName cfg, PCR.keySecretName cfg, PCR.caSecretName cfg, PCR.jwtSecretName cfg]+    assertAllWaitFor "the deploy waits for the grants" dag deploy (helpHas "grants roles/secretmanager.secretAccessor")+    assertNoConflicts dag+  where+    cfg :: PCR.PostgrestCloudRunConfig+    cfg =+        PCR.PostgrestCloudRunConfig+            { PCR.pcr_service = "api"+            , PCR.pcr_project = Core.Project "acme"+            , PCR.pcr_region = Core.Region "europe-west1"+            , PCR.pcr_serviceAccount = "api-sa@acme.iam.gserviceaccount.com"+            , PCR.pcr_repo = ArtifactRegistry.ArtifactRepo "repo" (Core.Project "acme") (Core.Region "europe-west1") ArtifactRegistry.Docker+            , PCR.pcr_image = "europe-west1-docker.pkg.dev/acme/repo/postgrest:v1"+            , PCR.pcr_authFile = Podman.AuthFile "/tmp/auth.json"+            , PCR.pcr_workDir = "/tmp/work"+            , PCR.pcr_postgrestImage = "postgrest/postgrest:v12.2.1"+            , PCR.pcr_db = Postgres.Server "db.example" 5432+            , PCR.pcr_database = "api_db"+            , PCR.pcr_role = "api_authenticator"+            , PCR.pcr_anonRole = "api_anonymous"+            , PCR.pcr_jwtRoleClaimKey = Nothing+            , PCR.pcr_schemas = Nothing+            , PCR.pcr_sslMode = Tls.VerifyCa+            , PCR.pcr_clientMaterial = ("/certs/api_authenticator.pem", "/certs/api_authenticator.key", "/certs/ca.pem")+            , PCR.pcr_jwtSecretFile = Just "/secrets/jwt"+            , PCR.pcr_ingress = CloudRun.All+            , PCR.pcr_maxInstances = Just 1+            , PCR.pcr_cpu = Nothing+            , PCR.pcr_memory = Nothing+            , PCR.pcr_concurrency = Nothing+            , PCR.pcr_allowUnauthenticated = False+            }