feat: minimal IHP scaffold — T01-T05, T08 of IRP-WP-0001

- flake.nix adapted from inter-hub: appName=ihp-railiance-probe, stripped to
  core packages, GHC 9.10.3 Bug 1+2 overlays carried verbatim (pname check
  updated to ihp-railiance-probe-models)
- IHP project scaffold: Main.hs, Config.hs, App.cabal, Setup.hs, Makefile
- Schema: probes table (id, name, created_at)
- Health endpoint: GET /healthz → "ok" (HealthController)
- Probes CRUD: ProbesController + 4 views (Index, New, Show, Edit)
- Hspec test suite: Test/ProbeControllerSpec covers /probes and /healthz
- Helm chart in chart/: deployment, service, ingress, secret templates
- devenv.nix, devenv.yaml, .ghci, tailwind config

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-03 12:57:28 +02:00
parent b818866c7f
commit e372a0c9ce
35 changed files with 645 additions and 0 deletions

7
.ghci Normal file
View File

@@ -0,0 +1,7 @@
:set -XNoImplicitPrelude
:def loadFromIHP \file -> (System.Environment.getEnv "IHP_LIB") >>= (\ihpLib -> readFile (ihpLib <> "/" <> file))
:loadFromIHP applicationGhciConfig
:set -j1
:set -fkeep-going
:set -fwrite-interface
import IHP.Prelude

57
App.cabal Normal file
View File

@@ -0,0 +1,57 @@
-- Stub cabal file — only for HLS/tooling support. Not used by the build.
-- See flake.nix for actual package management.
name: App
version: 0.1.0.0
license: AllRightsReserved
license-file: LICENSE
author: Developers
maintainer: developers@example.com
build-type: Simple
cabal-version: >=1.10
executable App
main-is: Main.hs
build-depends:
ihp,
base,
wai,
text,
hspec,
ihp-hspec
hs-source-dirs: .
default-language: Haskell2010
extensions:
OverloadedStrings
, NoImplicitPrelude
, ImplicitParams
, Rank2Types
, DisambiguateRecordFields
, NamedFieldPuns
, DuplicateRecordFields
, OverloadedLabels
, FlexibleContexts
, TypeSynonymInstances
, FlexibleInstances
, QuasiQuotes
, TypeFamilies
, PackageImports
, ScopedTypeVariables
, RecordWildCards
, TypeApplications
, DataKinds
, InstanceSigs
, DeriveGeneric
, MultiParamTypeClasses
, TypeOperators
, DeriveDataTypeable
, MultiWayIf
, UndecidableInstances
, BlockArguments
, PartialTypeSignatures
, LambdaCase
, DefaultSignatures
, EmptyDataDeriving
, BangPatterns
, FunctionalDependencies
, StandaloneDeriving
, DerivingVia

1
Application/Fixtures.sql Normal file
View File

@@ -0,0 +1 @@
-- No fixtures for probe.

7
Application/Schema.sql Normal file
View File

@@ -0,0 +1,7 @@
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE TABLE probes (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT now() NOT NULL
);

9
Config/Config.hs Normal file
View File

@@ -0,0 +1,9 @@
module Config where
import IHP.Prelude
import IHP.Environment
import IHP.FrameworkConfig
config :: ConfigBuilder
config = do
pure ()

21
Main.hs Normal file
View File

@@ -0,0 +1,21 @@
module Main where
import IHP.Prelude
import Config
import qualified IHP.Server
import IHP.RouterSupport
import IHP.FrameworkConfig
import IHP.Job.Types
import Web.FrontController
import Web.Types
instance FrontController RootApplication where
controllers =
[ mountFrontController WebApplication
]
instance Worker RootApplication where
workers _ = []
main :: IO ()
main = IHP.Server.run config

16
Makefile Normal file
View File

@@ -0,0 +1,16 @@
CSS_FILES += ${IHP}/static/vendor/bootstrap.min.css
CSS_FILES += ${IHP}/static/vendor/flatpickr.min.css
CSS_FILES += static/app.css
JS_FILES += ${IHP}/static/vendor/jquery-3.6.0.slim.min.js
JS_FILES += ${IHP}/static/vendor/timeago.js
JS_FILES += ${IHP}/static/vendor/popper.min.js
JS_FILES += ${IHP}/static/vendor/bootstrap.min.js
JS_FILES += ${IHP}/static/vendor/flatpickr.js
JS_FILES += ${IHP}/static/helpers.js
JS_FILES += ${IHP}/static/vendor/morphdom-umd.min.js
JS_FILES += ${IHP}/static/vendor/turbolinks.js
JS_FILES += ${IHP}/static/vendor/turbolinksInstantClick.js
JS_FILES += ${IHP}/static/vendor/turbolinksMorphdom.js
include ${IHP}/Makefile.dist

4
PIPELINE_LOG.md Normal file
View File

@@ -0,0 +1,4 @@
# Pipeline Validation Log
| Date | SHA | Build | Push | Deploy | Smoke |
|------|-----|-------|------|--------|-------|

2
Setup.hs Normal file
View File

@@ -0,0 +1,2 @@
import Distribution.Simple
main = defaultMain

13
Test/Main.hs Normal file
View File

@@ -0,0 +1,13 @@
module Main where
import Test.Hspec
import IHP.Prelude
import qualified Test.ProbeControllerSpec as ProbeController
main :: IO ()
main = hspec do
describe "Sanity" do
it "1 + 1 = 2" do
1 + 1 `shouldBe` (2 :: Int)
ProbeController.spec

View File

@@ -0,0 +1,14 @@
module Test.ProbeControllerSpec where
import Test.Hspec
import IHP.HSpec
spec :: Spec
spec = describe "ProbeController" do
it "GET /probes returns 200" do
response <- get "/probes"
response `shouldRespondWith` 200
it "GET /healthz returns 200" do
response <- get "/healthz"
response `shouldRespondWith` 200

7
Web/Controller/Health.hs Normal file
View File

@@ -0,0 +1,7 @@
module Web.Controller.Health where
import Web.Controller.Prelude
import Web.Types
instance Controller HealthController where
action HealthAction = renderPlain "ok"

12
Web/Controller/Prelude.hs Normal file
View File

@@ -0,0 +1,12 @@
module Web.Controller.Prelude
( module Web.Types
, module Generated.Types
, module IHP.Prelude
, module IHP.ControllerPrelude
) where
import Web.Types
import Generated.Types
import IHP.Prelude
import IHP.ControllerPrelude
import Web.Routes ()

42
Web/Controller/Probes.hs Normal file
View File

@@ -0,0 +1,42 @@
module Web.Controller.Probes where
import Web.Controller.Prelude
import Web.View.Probes.Index
import Web.View.Probes.New
import Web.View.Probes.Show
import Web.View.Probes.Edit
instance Controller ProbesController where
action ProbesAction = do
probes <- query @Probe |> fetch
render IndexView { .. }
action NewProbeAction = do
let probe = newRecord @Probe
render NewView { .. }
action ShowProbeAction { probeId } = do
probe <- fetch probeId
render ShowView { .. }
action CreateProbeAction = do
newRecord @Probe
|> fill @'["name"]
|> createRecord
redirectTo ProbesAction
action EditProbeAction { probeId } = do
probe <- fetch probeId
render EditView { .. }
action UpdateProbeAction { probeId } = do
probe <- fetch probeId
probe
|> fill @'["name"]
|> updateRecord
redirectTo ProbesAction
action DeleteProbeAction { probeId } = do
probe <- fetch probeId
deleteRecord probe
redirectTo ProbesAction

36
Web/FrontController.hs Normal file
View File

@@ -0,0 +1,36 @@
module Web.FrontController where
import IHP.RouterPrelude
import IHP.ControllerPrelude
import IHP.ViewPrelude (Html, hsx, Layout)
import Generated.Types
import Web.Types
import Web.Routes ()
import Web.Controller.Health ()
import Web.Controller.Probes ()
instance FrontController WebApplication where
controllers =
[ parseRoute @HealthController
, parseRoute @ProbesController
]
instance InitControllerContext WebApplication where
initContext = do
setLayout defaultLayout
defaultLayout :: (?context :: ControllerContext, ?request :: Request) => Layout
defaultLayout inner = [hsx|
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>ihp-railiance-probe</title>
</head>
<body>
{inner}
</body>
</html>
|]

16
Web/Routes.hs Normal file
View File

@@ -0,0 +1,16 @@
module Web.Routes where
import IHP.RouterPrelude
import Generated.Types
import Web.Types
instance CanRoute HealthController where
parseRoute' = do
pathPrefix "/healthz"
endOfInput
pure HealthAction
instance HasPath HealthController where
pathTo HealthAction = "/healthz"
instance AutoRoute ProbesController

21
Web/Types.hs Normal file
View File

@@ -0,0 +1,21 @@
module Web.Types where
import IHP.Prelude
import IHP.ModelSupport
import Generated.Types
data WebApplication = WebApplication deriving (Eq, Show)
data HealthController
= HealthAction
deriving (Eq, Show, Data)
data ProbesController
= ProbesAction
| NewProbeAction
| ShowProbeAction { probeId :: !(Id Probe) }
| CreateProbeAction
| EditProbeAction { probeId :: !(Id Probe) }
| UpdateProbeAction { probeId :: !(Id Probe) }
| DeleteProbeAction { probeId :: !(Id Probe) }
deriving (Eq, Show, Data)

12
Web/View/Prelude.hs Normal file
View File

@@ -0,0 +1,12 @@
module Web.View.Prelude
( module Web.Types
, module Generated.Types
, module IHP.Prelude
, module IHP.ViewPrelude
) where
import Web.Types
import Generated.Types
import IHP.Prelude
import IHP.ViewPrelude
import Web.Routes ()

17
Web/View/Probes/Edit.hs Normal file
View File

@@ -0,0 +1,17 @@
module Web.View.Probes.Edit where
import Web.View.Prelude
data EditView = EditView { probe :: Probe }
instance View EditView where
html EditView { .. } = [hsx|
<h1>Edit Probe</h1>
{renderForm probe}
|]
renderForm :: Probe -> Html
renderForm probe = formFor probe [hsx|
{textField #name}
{submitButton}
|]

22
Web/View/Probes/Index.hs Normal file
View File

@@ -0,0 +1,22 @@
module Web.View.Probes.Index where
import Web.View.Prelude
data IndexView = IndexView { probes :: [Probe] }
instance View IndexView where
html IndexView { .. } = [hsx|
<h1>Probes</h1>
<a href={NewProbeAction}>New Probe</a>
<ul>
{forEach probes renderProbe}
</ul>
|]
renderProbe :: Probe -> Html
renderProbe probe = [hsx|
<li>
<a href={ShowProbeAction probe.id}>{probe.name}</a>
<a href={DeleteProbeAction probe.id} class="js-delete">Delete</a>
</li>
|]

17
Web/View/Probes/New.hs Normal file
View File

@@ -0,0 +1,17 @@
module Web.View.Probes.New where
import Web.View.Prelude
data NewView = NewView { probe :: Probe }
instance View NewView where
html NewView { .. } = [hsx|
<h1>New Probe</h1>
{renderForm probe}
|]
renderForm :: Probe -> Html
renderForm probe = formFor probe [hsx|
{textField #name}
{submitButton}
|]

11
Web/View/Probes/Show.hs Normal file
View File

@@ -0,0 +1,11 @@
module Web.View.Probes.Show where
import Web.View.Prelude
data ShowView = ShowView { probe :: Probe }
instance View ShowView where
html ShowView { .. } = [hsx|
<h1>{probe.name}</h1>
<a href={ProbesAction}>Back</a>
|]

6
chart/Chart.yaml Normal file
View File

@@ -0,0 +1,6 @@
apiVersion: v2
name: ihp-railiance-probe
description: Minimal IHP probe app for Railiance01 pipeline validation
type: application
version: 0.1.0
appVersion: "0.1.0"

View File

@@ -0,0 +1,38 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Release.Name }}
labels:
app: {{ .Release.Name }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
app: {{ .Release.Name }}
template:
metadata:
labels:
app: {{ .Release.Name }}
spec:
containers:
- name: {{ .Release.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- containerPort: 8000
envFrom:
- secretRef:
name: {{ .Values.secretName }}
resources:
limits:
memory: {{ .Values.resources.limits.memory }}
cpu: {{ .Values.resources.limits.cpu }}
requests:
memory: {{ .Values.resources.requests.memory }}
cpu: {{ .Values.resources.requests.cpu }}
livenessProbe:
httpGet:
path: /healthz
port: 8000
initialDelaySeconds: 30
periodSeconds: 10

View File

@@ -0,0 +1,20 @@
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ .Release.Name }}
annotations:
kubernetes.io/ingress.class: traefik
spec:
rules:
- host: {{ .Values.ingress.host }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: {{ .Release.Name }}
port:
number: {{ .Values.service.port }}
{{- end }}

View File

@@ -0,0 +1,11 @@
apiVersion: v1
kind: Secret
metadata:
name: {{ .Values.secretName }}
annotations:
"helm.sh/resource-policy": keep
type: Opaque
stringData:
IHP_SESSION_SECRET: "REPLACE_ME"
DATABASE_URL: "REPLACE_ME"
IHP_BASEURL: "http://{{ .Values.ingress.host }}"

View File

@@ -0,0 +1,11 @@
apiVersion: v1
kind: Service
metadata:
name: {{ .Release.Name }}
spec:
type: {{ .Values.service.type }}
selector:
app: {{ .Release.Name }}
ports:
- port: {{ .Values.service.port }}
targetPort: {{ .Values.service.targetPort }}

25
chart/values.yaml Normal file
View File

@@ -0,0 +1,25 @@
image:
repository: 92.205.130.254:32166/coulomb/ihp-railiance-probe
tag: latest
pullPolicy: Always
replicaCount: 1
service:
type: ClusterIP
port: 80
targetPort: 8000
ingress:
enabled: true
host: probe.coulomb.social
resources:
limits:
memory: 256Mi
cpu: 200m
requests:
memory: 128Mi
cpu: 50m
secretName: ihp-railiance-probe-env

9
default.nix Normal file
View File

@@ -0,0 +1,9 @@
# For backwards compatibility using flake.nix
(import
(
fetchTarball {
url = "https://github.com/edolstra/flake-compat/archive/35bb57c0c8d8b62bbfd284272c928ceb64ddbde9.tar.gz";
sha256 = "sha256:1prd9b1xx8c0sfwnyzkspplh30m613j42l1k789s521f4kv4c2z2";
}
)
{ src = ./.; }).defaultNix

24
devenv.nix Normal file
View File

@@ -0,0 +1,24 @@
{ pkgs, lib, config, inputs, ... }:
{
env.GHCRTS = "-A32m -M2g";
env.IHP_LIB = "${inputs.ihp.packages.${pkgs.stdenv.system}.ihp-env-var-backwards-compat}";
env.IHP = "${inputs.ihp.packages.${pkgs.stdenv.system}.ihp-env-var-backwards-compat}";
languages.haskell.enable = true;
languages.haskell.package = pkgs.ghc.ghc.withPackages (p: with p; [
p.ihp
cabal-install
hlint
hspec
p.ihp-hspec
]);
languages.haskell.lsp.enable = false;
packages = [
pkgs.ghc.ghcid
pkgs.nodePackages.tailwindcss
];
processes.tailwind.exec = "tailwindcss -c tailwind/tailwind.config.js -i ./tailwind/app.css -o static/app.css --watch=always";
}

5
devenv.yaml Normal file
View File

@@ -0,0 +1,5 @@
inputs:
ihp:
url: github:digitallyinduced/ihp/df3922d1a7166b131674efa3d3555ed7195ddf70
overlays:
- default

118
flake.nix Normal file
View File

@@ -0,0 +1,118 @@
{
inputs = {
ihp.url = "github:digitallyinduced/ihp/v1.5";
nixpkgs.follows = "ihp/nixpkgs";
nixpkgs-nixos.follows = "ihp/nixpkgs-nixos";
flake-parts.follows = "ihp/flake-parts";
devenv.follows = "ihp/devenv";
systems.follows = "ihp/systems";
devenv-root = {
url = "file+file:///dev/null";
flake = false;
};
};
outputs = inputs@{ self, nixpkgs, nixpkgs-nixos, ihp, flake-parts, systems, ... }:
flake-parts.lib.mkFlake { inherit inputs; } {
systems = import systems;
imports = [ ihp.flakeModules.default ];
perSystem = { pkgs, config, lib, ... }: {
ihp = {
appName = "ihp-railiance-probe";
enable = true;
projectPath = ./.;
packages = with pkgs; [
tailwindcss
];
haskellPackages = p: with p; [
p.ihp
base
wai
text
];
devHaskellPackages = p: with p; [
cabal-install
hlint
hspec
ihp-hspec
];
withHoogle = false;
};
# OCI container image for Kubernetes deployment.
# Build: nix build .#docker
# Push: skopeo copy docker-archive:result docker://92.205.130.254:32166/coulomb/ihp-railiance-probe:SHA
packages.docker = config.packages.unoptimized-docker-image;
devenv.shells.default = {
overlays = lib.mkAfter [
(final: prev: {
ghc = prev.ghc.extend (hfinal: hprev: {
mkDerivation = args:
let drv = hprev.mkDerivation args;
in if (args.pname or "") == "ihp-railiance-probe-models"
then drv.overrideAttrs (old: {
# GHC 9.10.3 Bug 1: Generated.ActualTypes.hi overflow.
# Bug 2: libHSghc-9.10.3-5702.a truncated — use ghc-iserv-dyn.
configureFlags = (old.configureFlags or []) ++ [
"--ghc-option=-O0"
"--ghc-option=-fomit-interface-pragmas"
"--disable-split-sections"
"--ghc-option=-j1"
"--ghc-option=-fexternal-interpreter"
"--ghc-option=-pgmi"
"--ghc-option=${hprev.ghc}/lib/ghc-9.10.3/bin/ghc-iserv-dyn"
];
postUnpack = (old.postUnpack or "") + ''
_actual="$sourceRoot/build/Generated/ActualTypes.hs"
_types=$(
{
awk '/^data [A-Z]|^newtype [A-Z]/{print $2"(..)"}
/^type [A-Z]/{print $2}' \
"$sourceRoot/build/Generated/Enums.hs"
find "$sourceRoot/build/Generated/ActualTypes" -name "*.hs" | \
sort | while IFS= read -r _m; do
awk '/^data [A-Z]|^newtype [A-Z]/{print $2"(..)"}
/^type [A-Z]/{print $2}' "$_m"
done
} | sort -u
)
_exports=$(echo "$_types" | \
awk 'NR==1{printf " %s", $0; next} {printf "\n , %s", $0} END{printf "\n"}')
_imports=$(awk '/^import Generated\./{print}' "$_actual")
{
printf 'module Generated.ActualTypes\n ( %s ) where\n' "$_exports"
printf '%s\n' "$_imports"
} > "$_actual.new" && mv "$_actual.new" "$_actual"
'';
})
else drv;
});
})
];
env.GHCRTS = "-A32m -M2g";
processes = {
tailwind.exec = "tailwindcss -c tailwind/tailwind.config.js -i ./tailwind/app.css -o static/app.css --watch=always";
};
};
};
};
nixConfig = {
extra-substituters = [
"https://devenv.cachix.org"
"https://cachix.cachix.org"
"https://digitallyinduced.cachix.org"
];
extra-trusted-public-keys = [
"devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw="
"cachix.cachix.org-1:eWNHQldwUO7G2VkjpnjDbWwy4KQ/HNxht7H4SSoMckM="
"digitallyinduced.cachix.org-1:y+wQvrnxQ+PdEsCt91rmvv39qRCYzEgGQaldK26hCKE="
];
};
}

0
static/.gitkeep Normal file
View File

3
tailwind/app.css Normal file
View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./Web/View/**/*.hs",
"./Web/FrontController.hs",
],
theme: {
extend: {},
},
plugins: [],
}