From 21e856810823e54307c7939d1ecdacfb396f6d54 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Tue, 15 Sep 2026 14:45:23 +0700 Subject: [PATCH] feat: CI + Nix deploy + prod sectors-hackaton.asepharyana.my.id - Backend serve web dist (WEB_DIST_DIR + SPA fallback, /api 404 JSON) - flake.nix: single flowsight derivation (go 1.25 + pnpm build) - deploy/flowsight.service: systemd on :4022 via bws-exec - .github/workflows/ci.yml: go test + tsc gate, nix build, attic, SSH deploy --- .github/workflows/ci.yml | 161 ++++++++++++++++++++++++++++++ .gitignore | 5 + backend/internal/api/server.go | 3 + backend/internal/api/static.go | 39 ++++++++ backend/internal/config/config.go | 3 + deploy/flowsight.service | 25 +++++ flake.lock | 61 +++++++++++ flake.nix | 81 +++++++++++++++ 8 files changed, 378 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 backend/internal/api/static.go create mode 100644 deploy/flowsight.service create mode 100644 flake.lock create mode 100644 flake.nix diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..62de0f0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,161 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +env: + VPS_HOST: ${{ secrets.SSH_DEPLOY_HOST }} + VPS_USER: ${{ secrets.SSH_DEPLOY_USER }} + +permissions: + contents: read + +jobs: + test: + name: go test + web typecheck + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: "1.25" + + - name: Go vet + test + working-directory: backend + run: | + go vet ./... + go test ./... + + - name: Set up Node + pnpm + uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Enable corepack pnpm + run: corepack enable + + - name: Web typecheck + build + working-directory: web + run: | + pnpm install --frozen-lockfile + pnpm build + + build-and-deploy: + name: build + deploy (Nix) — flowsight + needs: test + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@v22 + with: + determinate: false + extra-conf: | + sandbox = false + accept-flake-config = true + extra-substituters = https://attic.asepharyana.my.id/asepharyana https://attic.asepharyana.my.id/gmw + extra-trusted-public-keys = asepharyana:zBpY6vNI1nDJ4mU6W4q880BB0JB1qb3gRKkO/tGSMiQ= gmw:Fq2Anzuhkb+T/hftWnPcveHSi21/RzIgIOeG8pCJa88= + + - name: Cache Nix + uses: DeterminateSystems/magic-nix-cache-action@v14 + with: + use-flakehub: false + + - name: Build flowsight + id: build + run: | + nix build .#flowsight --impure --option sandbox false --print-build-logs + STORE_PATH=$(readlink result) + echo "store-path=$STORE_PATH" >> "$GITHUB_OUTPUT" + echo "Build OK flowsight: $STORE_PATH" + + - name: Setup SSH key + env: + SSH_KEY: ${{ secrets.SSH_DEPLOY_KEY }} + run: | + mkdir -p ~/.ssh + echo "$SSH_KEY" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + sed -i 's/\r$//' ~/.ssh/id_ed25519 + ssh-keygen -y -f ~/.ssh/id_ed25519 >/dev/null 2>&1 || { echo "SSH key invalid"; exit 1; } + ssh-keyscan -H "$VPS_HOST" >> ~/.ssh/known_hosts 2>/dev/null + chmod 600 ~/.ssh/known_hosts + + - name: Push to Attic cache + env: + ATTIC_TOKEN: ${{ secrets.ATTIC_TOKEN }} + STORE_PATH: ${{ steps.build.outputs.store-path }} + run: | + ATTIC_DIR="/nix/store/fygyy3yk4rqdknxkiwkqambpnhyax0k4-attic-0.1.0" + mkdir -p "$HOME/.config/attic" + cat > "$HOME/.config/attic/config.toml" </dev/null; then + ATTIC_BIN="$ATTIC_DIR/bin/attic" + fi + + push_ok="" + if [ -n "$ATTIC_BIN" ] && [ -x "$ATTIC_BIN" ]; then + for attempt in 1 2 3; do + if "$ATTIC_BIN" push pub:asepharyana "$STORE_PATH" --jobs 4 --ignore-upstream-cache-filter; then + echo "Pushed $STORE_PATH to Attic" + push_ok=1 + break + fi + echo "Attic push attempt $attempt/3 failed; retrying in 10s..." + sleep 10 + done + fi + + if [ -z "$push_ok" ]; then + echo "Attic push failed; copying store path directly to VPS" + nix copy --to "ssh://$VPS_USER@${VPS_HOST}" "$STORE_PATH" + fi + + - name: Deploy on VPS + env: + STORE_PATH: ${{ steps.build.outputs.store-path }} + run: | + REALISE_RESULT=$(ssh "$VPS_USER@$VPS_HOST" \ + "sudo /nix/var/nix/profiles/default/bin/nix-store --realise '$STORE_PATH' 2>&1 || true") + echo "$REALISE_RESULT" + + ssh "$VPS_USER@$VPS_HOST" \ + "sudo /nix/var/nix/profiles/default/bin/nix-env \ + --profile /nix/var/nix/profiles/flowsight \ + --set '$STORE_PATH' && \ + (cd /home/code/flowsight && git fetch origin main && git reset --hard origin/main || true) && \ + sudo mkdir -p /var/lib/flowsight/data && sudo chown -R code:code /var/lib/flowsight && \ + (sudo cp /home/code/flowsight/deploy/flowsight.service /etc/systemd/system/flowsight.service && sudo systemctl daemon-reload || true) && \ + sudo systemctl enable --now flowsight && \ + sudo systemctl restart flowsight && \ + sleep 3 && \ + sudo systemctl status flowsight --no-pager --no-legend | head -5" + + - name: Verify service + run: | + ssh "$VPS_USER@$VPS_HOST" \ + "curl -s -o /dev/null -w 'local:%{http_code}\n' http://localhost:4022/api/health && \ + curl -sk -o /dev/null -w 'public:%{http_code}\n' https://sectors-hackaton.asepharyana.my.id/api/health" diff --git a/.gitignore b/.gitignore index 0dad852..72d4716 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,8 @@ data/ # OS / editor noise .DS_Store Thumbs.db + +# planning-with-files (local session state) +task_plan.md +findings.md +progress.md diff --git a/backend/internal/api/server.go b/backend/internal/api/server.go index 0bad1de..d0556da 100644 --- a/backend/internal/api/server.go +++ b/backend/internal/api/server.go @@ -90,6 +90,9 @@ func (s *Server) Router() http.Handler { r.Get("/accuracy", s.Accuracy) r.Post("/chat", s.Chat) }) + if s.Cfg.StaticDir != "" { + r.NotFound(s.spaHandler()) + } return r } diff --git a/backend/internal/api/static.go b/backend/internal/api/static.go new file mode 100644 index 0000000..07bca28 --- /dev/null +++ b/backend/internal/api/static.go @@ -0,0 +1,39 @@ +// Package api serves the FlowSight REST API (docs/API.md) on chi. +package api + +import ( + "net/http" + "os" + "path/filepath" + "strings" +) + +// spaHandler serves the prebuilt web dist (WEB_DIST_DIR) with an SPA fallback: +// existing files (assets, index.html) are served directly, unknown paths fall +// back to index.html so client-side routes (/routines, /report/:ticker, ...) +// resolve. Only registered when StaticDir is set; API-only mode otherwise. +func (s *Server) spaHandler() http.HandlerFunc { + root := s.Cfg.StaticDir + fileSrv := http.FileServer(http.Dir(root)) + index := filepath.Join(root, "index.html") + return func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/api/") || r.URL.Path == "/api" { + writeErr(w, http.StatusNotFound, "not found") + return + } + if r.URL.Path == "/" || r.URL.Path == "/index.html" { + http.ServeFile(w, r, index) + return + } + clean := filepath.Clean(strings.TrimPrefix(r.URL.Path, "/")) + if strings.Contains(clean, "..") { + http.NotFound(w, r) + return + } + if fi, err := os.Stat(filepath.Join(root, clean)); err == nil && !fi.IsDir() { + fileSrv.ServeHTTP(w, r) + return + } + http.ServeFile(w, r, index) + } +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 696cdb2..45ae05e 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -27,6 +27,8 @@ type Config struct { DiscordWebhookURL string CreditCapPerCycle int Watchlist []string + // StaticDir serves the prebuilt web dist (WEB_DIST_DIR). Empty = API only. + StaticDir string } // HasSectorsKey reports whether live Sectors API calls are possible. @@ -83,5 +85,6 @@ func Load() Config { DiscordWebhookURL: os.Getenv("DISCORD_WEBHOOK_URL"), CreditCapPerCycle: getenvInt("CREDIT_CAP_PER_CYCLE", 120), Watchlist: tickers, + StaticDir: os.Getenv("WEB_DIST_DIR"), } } diff --git a/deploy/flowsight.service b/deploy/flowsight.service new file mode 100644 index 0000000..5001a9a --- /dev/null +++ b/deploy/flowsight.service @@ -0,0 +1,25 @@ +[Unit] +Description=FlowSight — Sectors hackathon screener (Go API + SolidJS web, single service) +After=network-online.target nix-daemon.service +Wants=network-online.target + +[Service] +Type=simple +WorkingDirectory=/var/lib/flowsight +# Secrets dari Bitwarden Secrets Manager (project flowsight): +# flowsight_sectors_api_key -> SECTORS_API_KEY +# flowsight_llm_api_key -> LLM_API_KEY +# flowsight_llm_base_url -> LLM_BASE_URL +# flowsight_llm_model_triage/synth -> LLM_MODEL_TRIAGE/SYNTH +# WEB_DIST_DIR menunjuk ke dist frontend di store path aktif (dibuat saat deploy). +Environment=PORT=4022 +Environment=DB_PATH=/var/lib/flowsight/data/flowsight.db +Environment=WEB_DIST_DIR=/nix/var/nix/profiles/flowsight/share/flowsight-web/dist +ExecStart=/usr/local/bin/bws-exec flowsight --project ca75de0f-058b-4a56-9073-b4c600572817 -- /nix/var/nix/profiles/flowsight/bin/flowsight +Restart=always +RestartSec=3 +User=code +Group=code + +[Install] +WantedBy=multi-user.target diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..0796952 --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1789286504, + "narHash": "sha256-eiEK7cKZORNEvX0GeF3RtNEF/JXhgf2RqSp3230q13E=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "ef34387ddd751e1ab8857adf4676492d32eb24ec", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..360bc55 --- /dev/null +++ b/flake.nix @@ -0,0 +1,81 @@ +{ + description = "FlowSight — Sectors hackathon screener (Go chi backend + SolidJS web, single service)"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachSystem [ "x86_64-linux" ] (system: + let + pkgs = import nixpkgs { + inherit system; + config.allowUnfree = true; + }; + + version = "0.1.0"; + + # pnpm install runs in CI sandbox with network; no prefetch pinning needed. + flowsight = pkgs.stdenvNoCC.mkDerivation { + pname = "flowsight"; + inherit version; + src = ./.; + + nativeBuildInputs = with pkgs; [ + cacert curl gcc gnumake openssl pkg-config + go_1_25 nodejs_22 corepack_22 + ]; + + SSL_CERT_FILE = "${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt"; + NIX_ENFORCE_PURITY = "0"; + COREPACK_ENABLE_DOWNLOAD_PROMPT = "0"; + + phases = [ "unpackPhase" "buildPhase" "installPhase" ]; + + buildPhase = '' + export HOME="$TMPDIR" GOCACHE="$TMPDIR/gocache" GOMODCACHE="$TMPDIR/gomodcache" + export COREPACK_HOME="$TMPDIR/corepack" PNPM_HOME="$TMPDIR/pnpm-home" + export PATH="$PNPM_HOME:$PATH" + + echo "=== Building backend ===" + cd backend + go build -trimpath -o $TMPDIR/flowsight ./cmd/server + cd .. + + echo "=== Building web ===" + cd web + corepack pnpm install --frozen-lockfile --offline 2>/dev/null \ + || corepack pnpm install --frozen-lockfile + corepack pnpm build + cd .. + + echo "=== Bundle fixtures (offline seed at first boot) ===" + mkdir -p $TMPDIR/fixtures + cp backend/tests/fixtures/*.json $TMPDIR/fixtures/ + ''; + + installPhase = '' + mkdir -p $out/bin $out/share/flowsight-web $out/share/flowsight + cp $TMPDIR/flowsight $out/bin/flowsight + cp -r web/dist $out/share/flowsight-web/dist + cp $TMPDIR/fixtures/*.json $out/share/flowsight/ + ''; + }; + in + { + packages = { + inherit flowsight; + default = flowsight; + }; + + apps.flowsight = { + type = "app"; + program = "${flowsight}/bin/flowsight"; + }; + + devShells.default = pkgs.mkShell { + buildInputs = with pkgs; [ go_1_25 nodejs_22 corepack_22 ]; + }; + }); +}