diff --git a/.ghci b/.ghci new file mode 100644 index 0000000..5ed7a92 --- /dev/null +++ b/.ghci @@ -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 diff --git a/App.cabal b/App.cabal new file mode 100644 index 0000000..152e688 --- /dev/null +++ b/App.cabal @@ -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 diff --git a/Application/Fixtures.sql b/Application/Fixtures.sql new file mode 100644 index 0000000..7fb5689 --- /dev/null +++ b/Application/Fixtures.sql @@ -0,0 +1 @@ +-- No fixtures for probe. diff --git a/Application/Schema.sql b/Application/Schema.sql new file mode 100644 index 0000000..561d8e4 --- /dev/null +++ b/Application/Schema.sql @@ -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 +); diff --git a/Config/Config.hs b/Config/Config.hs new file mode 100644 index 0000000..0366ac0 --- /dev/null +++ b/Config/Config.hs @@ -0,0 +1,9 @@ +module Config where + +import IHP.Prelude +import IHP.Environment +import IHP.FrameworkConfig + +config :: ConfigBuilder +config = do + pure () diff --git a/Main.hs b/Main.hs new file mode 100644 index 0000000..5a5f082 --- /dev/null +++ b/Main.hs @@ -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 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e4f35f3 --- /dev/null +++ b/Makefile @@ -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 diff --git a/PIPELINE_LOG.md b/PIPELINE_LOG.md new file mode 100644 index 0000000..83d953f --- /dev/null +++ b/PIPELINE_LOG.md @@ -0,0 +1,4 @@ +# Pipeline Validation Log + +| Date | SHA | Build | Push | Deploy | Smoke | +|------|-----|-------|------|--------|-------| diff --git a/Setup.hs b/Setup.hs new file mode 100644 index 0000000..9a994af --- /dev/null +++ b/Setup.hs @@ -0,0 +1,2 @@ +import Distribution.Simple +main = defaultMain diff --git a/Test/Main.hs b/Test/Main.hs new file mode 100644 index 0000000..ced3c97 --- /dev/null +++ b/Test/Main.hs @@ -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 diff --git a/Test/ProbeControllerSpec.hs b/Test/ProbeControllerSpec.hs new file mode 100644 index 0000000..6a3ce05 --- /dev/null +++ b/Test/ProbeControllerSpec.hs @@ -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 diff --git a/Web/Controller/Health.hs b/Web/Controller/Health.hs new file mode 100644 index 0000000..ac6c83c --- /dev/null +++ b/Web/Controller/Health.hs @@ -0,0 +1,7 @@ +module Web.Controller.Health where + +import Web.Controller.Prelude +import Web.Types + +instance Controller HealthController where + action HealthAction = renderPlain "ok" diff --git a/Web/Controller/Prelude.hs b/Web/Controller/Prelude.hs new file mode 100644 index 0000000..152730d --- /dev/null +++ b/Web/Controller/Prelude.hs @@ -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 () diff --git a/Web/Controller/Probes.hs b/Web/Controller/Probes.hs new file mode 100644 index 0000000..5438834 --- /dev/null +++ b/Web/Controller/Probes.hs @@ -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 diff --git a/Web/FrontController.hs b/Web/FrontController.hs new file mode 100644 index 0000000..5d25110 --- /dev/null +++ b/Web/FrontController.hs @@ -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| + + + + + + ihp-railiance-probe + + + {inner} + + +|] diff --git a/Web/Routes.hs b/Web/Routes.hs new file mode 100644 index 0000000..294e6aa --- /dev/null +++ b/Web/Routes.hs @@ -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 diff --git a/Web/Types.hs b/Web/Types.hs new file mode 100644 index 0000000..ee2189a --- /dev/null +++ b/Web/Types.hs @@ -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) diff --git a/Web/View/Prelude.hs b/Web/View/Prelude.hs new file mode 100644 index 0000000..2640e7e --- /dev/null +++ b/Web/View/Prelude.hs @@ -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 () diff --git a/Web/View/Probes/Edit.hs b/Web/View/Probes/Edit.hs new file mode 100644 index 0000000..dde6c7a --- /dev/null +++ b/Web/View/Probes/Edit.hs @@ -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| +

Edit Probe

+ {renderForm probe} + |] + +renderForm :: Probe -> Html +renderForm probe = formFor probe [hsx| + {textField #name} + {submitButton} +|] diff --git a/Web/View/Probes/Index.hs b/Web/View/Probes/Index.hs new file mode 100644 index 0000000..1ad2cb1 --- /dev/null +++ b/Web/View/Probes/Index.hs @@ -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| +

Probes

+ New Probe + + |] + +renderProbe :: Probe -> Html +renderProbe probe = [hsx| +
  • + {probe.name} + Delete +
  • +|] diff --git a/Web/View/Probes/New.hs b/Web/View/Probes/New.hs new file mode 100644 index 0000000..72b5882 --- /dev/null +++ b/Web/View/Probes/New.hs @@ -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| +

    New Probe

    + {renderForm probe} + |] + +renderForm :: Probe -> Html +renderForm probe = formFor probe [hsx| + {textField #name} + {submitButton} +|] diff --git a/Web/View/Probes/Show.hs b/Web/View/Probes/Show.hs new file mode 100644 index 0000000..7b63f1d --- /dev/null +++ b/Web/View/Probes/Show.hs @@ -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| +

    {probe.name}

    + Back + |] diff --git a/chart/Chart.yaml b/chart/Chart.yaml new file mode 100644 index 0000000..8eeba0d --- /dev/null +++ b/chart/Chart.yaml @@ -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" diff --git a/chart/templates/deployment.yaml b/chart/templates/deployment.yaml new file mode 100644 index 0000000..c2f2bea --- /dev/null +++ b/chart/templates/deployment.yaml @@ -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 diff --git a/chart/templates/ingress.yaml b/chart/templates/ingress.yaml new file mode 100644 index 0000000..3761156 --- /dev/null +++ b/chart/templates/ingress.yaml @@ -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 }} diff --git a/chart/templates/secret.yaml b/chart/templates/secret.yaml new file mode 100644 index 0000000..527acf7 --- /dev/null +++ b/chart/templates/secret.yaml @@ -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 }}" diff --git a/chart/templates/service.yaml b/chart/templates/service.yaml new file mode 100644 index 0000000..06822c6 --- /dev/null +++ b/chart/templates/service.yaml @@ -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 }} diff --git a/chart/values.yaml b/chart/values.yaml new file mode 100644 index 0000000..a67424b --- /dev/null +++ b/chart/values.yaml @@ -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 diff --git a/default.nix b/default.nix new file mode 100644 index 0000000..8bd1d4a --- /dev/null +++ b/default.nix @@ -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 diff --git a/devenv.nix b/devenv.nix new file mode 100644 index 0000000..3848f88 --- /dev/null +++ b/devenv.nix @@ -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"; +} diff --git a/devenv.yaml b/devenv.yaml new file mode 100644 index 0000000..0ec6d9f --- /dev/null +++ b/devenv.yaml @@ -0,0 +1,5 @@ +inputs: + ihp: + url: github:digitallyinduced/ihp/df3922d1a7166b131674efa3d3555ed7195ddf70 + overlays: + - default diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..fdee8d1 --- /dev/null +++ b/flake.nix @@ -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=" + ]; + }; +} diff --git a/static/.gitkeep b/static/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tailwind/app.css b/tailwind/app.css new file mode 100644 index 0000000..b5c61c9 --- /dev/null +++ b/tailwind/app.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/tailwind/tailwind.config.js b/tailwind/tailwind.config.js new file mode 100644 index 0000000..1f57fae --- /dev/null +++ b/tailwind/tailwind.config.js @@ -0,0 +1,11 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: [ + "./Web/View/**/*.hs", + "./Web/FrontController.hs", + ], + theme: { + extend: {}, + }, + plugins: [], +}