From 22266108fbd72a978b0a4c5d55279e39d8a3d644 Mon Sep 17 00:00:00 2001 From: Matthieu Moquet Date: Thu, 7 Nov 2024 19:57:20 +0100 Subject: [PATCH] chore: build docker image --- .github/workflows/docker.yaml | 98 +++++++++++++++++++++ Dockerfile | 70 +++++++++++++++ next.config.ts | 2 +- public/humans.txt | 3 + src/app/alerts/[handle]/page.tsx | 6 -- src/app/api/clusters/[slug]/alerts/route.ts | 3 +- src/app/api/config/route.ts | 13 ++- src/app/layout.tsx | 7 +- src/components/alerts/template.tsx | 6 +- src/config/client.ts | 2 +- src/config/index.ts | 38 +++++--- src/config/server.ts | 3 + src/contexts/config.tsx | 33 ++++++- 13 files changed, 258 insertions(+), 26 deletions(-) create mode 100644 .github/workflows/docker.yaml create mode 100644 Dockerfile create mode 100644 public/humans.txt create mode 100644 src/config/server.ts diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml new file mode 100644 index 0000000..baf4e9c --- /dev/null +++ b/.github/workflows/docker.yaml @@ -0,0 +1,98 @@ +name: Docker + +on: + push: + branches: + - main + paths: + - 'src/**' + - '*.ts' + - '*.json' + - '*.yaml' + - Dockerfile + - .github/workflows/docker.yaml + pull_request: + branches: + - main + paths: + - 'src/**' + - '*.ts' + - '*.json' + - '*.yaml' + - Dockerfile + - .github/workflows/docker.yaml + release: + types: + - published + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + PLATFORMS: ${{ github.event_name == 'pull_request' && 'linux/amd64' || 'linux/amd64,linux/arm64' }} + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + + - name: "Generate Build ID (main)" + if: github.ref == 'refs/heads/main' && github.event_name == 'push' + run: | + branch=${GITHUB_REF##*/} + sha=${GITHUB_SHA::8} + ts=$(date +%s) + echo "BUILD_ID=${branch}-${ts}-${sha}" >> $GITHUB_ENV + + - name: "Generate Build ID (PR)" + if: github.event_name == 'pull_request' + run: | + echo "BUILD_ID=pr-${{ github.event.number }}-$GITHUB_RUN_ID" >> $GITHUB_ENV + + - name: "Generate Build ID (Release)" + if: github.event_name == 'release' + run: | + echo "BUILD_ID=${GITHUB_REF##*/}" >> $GITHUB_ENV + + - name: Log in to the Container registry + uses: docker/login-action@v2 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v4 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=pr + type=ref,event=branch + type=raw,value=${{ env.BUILD_ID }} + type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }} + + - name: Build and push Docker image + uses: docker/build-push-action@v4 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + platforms: ${{ env.PLATFORMS }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..443a6ab --- /dev/null +++ b/Dockerfile @@ -0,0 +1,70 @@ +# syntax=docker.io/docker/dockerfile:1 + +FROM node:22-alpine AS base + +# Install dependencies only when needed +FROM base AS deps +# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. +RUN apk add --no-cache libc6-compat +WORKDIR /app + +# Install dependencies based on the preferred package manager +COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./ +RUN \ + if [ -f yarn.lock ]; then yarn --frozen-lockfile; \ + elif [ -f package-lock.json ]; then npm ci; \ + elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \ + else echo "Lockfile not found." && exit 1; \ + fi + + +# Rebuild the source code only when needed +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +# Next.js collects completely anonymous telemetry data about general usage. +# Learn more here: https://nextjs.org/telemetry +# Uncomment the following line in case you want to disable telemetry during the build. +ENV NEXT_TELEMETRY_DISABLED=1 + +RUN \ + if [ -f yarn.lock ]; then yarn run build; \ + elif [ -f package-lock.json ]; then npm run build; \ + elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \ + else echo "Lockfile not found." && exit 1; \ + fi + +# Production image, copy all the files and run next +FROM base AS runner +WORKDIR /app + +ENV NODE_ENV=production +# Uncomment the following line in case you want to disable telemetry during runtime. +# ENV NEXT_TELEMETRY_DISABLED=1 + +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +COPY --from=builder /app/public ./public + +# Set the correct permission for prerender cache +RUN mkdir .next +RUN chown nextjs:nodejs .next + +# Automatically leverage output traces to reduce image size +# https://nextjs.org/docs/advanced-features/output-file-tracing +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs + +EXPOSE 3000 + +ENV PORT=3000 + +# server.js is created by next build from the standalone output +# https://nextjs.org/docs/pages/api-reference/next-config-js/output +ENV HOSTNAME="0.0.0.0" +CMD ["node", "server.js"] diff --git a/next.config.ts b/next.config.ts index e9ffa30..68a6c64 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + output: "standalone", }; export default nextConfig; diff --git a/public/humans.txt b/public/humans.txt new file mode 100644 index 0000000..e8de72d --- /dev/null +++ b/public/humans.txt @@ -0,0 +1,3 @@ +/* TEAM */ +Name: Matthieu Moquet +Contact: matthieu [at] moquet [dot] net diff --git a/src/app/alerts/[handle]/page.tsx b/src/app/alerts/[handle]/page.tsx index c3af1cd..32d13de 100644 --- a/src/app/alerts/[handle]/page.tsx +++ b/src/app/alerts/[handle]/page.tsx @@ -1,6 +1,4 @@ import { AlertsTemplate } from "@/components/alerts/template" -import { config } from "@/config" -import { notFound } from "next/navigation" type Props = { params: Promise<{ handle: string }> @@ -9,10 +7,6 @@ type Props = { export default async function AlertsViewPage(props: Props) { const { handle } = await props.params - if (!config.views[handle]) { - return notFound() - } - return ( ) diff --git a/src/app/api/clusters/[slug]/alerts/route.ts b/src/app/api/clusters/[slug]/alerts/route.ts index 5814330..991663c 100644 --- a/src/app/api/clusters/[slug]/alerts/route.ts +++ b/src/app/api/clusters/[slug]/alerts/route.ts @@ -1,10 +1,11 @@ -import { config } from "@/config"; +import { getConfig } from "@/config"; export async function GET( _: Request, { params }: { params: Promise<{ slug: string }> } ) { const cluster = (await params).slug + const config = await getConfig() const endpoint = config.clusters.find((c) => c.name === cluster)?.endpoint if (!endpoint) { diff --git a/src/app/api/config/route.ts b/src/app/api/config/route.ts index 25e9306..2a0b85c 100644 --- a/src/app/api/config/route.ts +++ b/src/app/api/config/route.ts @@ -1,6 +1,17 @@ -import { clientConfig } from "@/config/client"; +import { getConfig } from "@/config"; export async function GET(_: Request) { + const config = await getConfig() + + // Remove the endpoints from the client config + const clientConfig = { + ...config, + clusters: config.clusters.map((c) => ({ + ...c, + endpoint: "" + })), + } + return new Response(JSON.stringify(clientConfig), { headers: { "Content-Type": "application/json" }, }); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index e36a020..f1ee913 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,12 +1,13 @@ import type { Metadata } from "next"; import localFont from "next/font/local"; import "./globals.css"; -import { clientConfig } from "@/config/client"; import { ConfigProvider } from "@/contexts/config"; import { ThemeProvider } from "next-themes"; import { AppLayout } from "@/components/layout/app-layout"; import { AlertsProvider } from "@/contexts/alerts"; +export const dynamic = "force-dynamic"; + const geistSans = localFont({ src: "./fonts/GeistVF.woff", variable: "--font-geist-sans", @@ -23,7 +24,7 @@ export const metadata: Metadata = { description: "Dashboard for AlertManager Prometheus", }; -export default function RootLayout({ +export default async function RootLayout({ children, }: Readonly<{ children: React.ReactNode; @@ -39,7 +40,7 @@ export default function RootLayout({ enableSystem disableTransitionOnChange > - + {children} diff --git a/src/components/alerts/template.tsx b/src/components/alerts/template.tsx index a7f01ec..ccee57e 100644 --- a/src/components/alerts/template.tsx +++ b/src/components/alerts/template.tsx @@ -10,7 +10,7 @@ import { AlertModal } from './alert-modal' import { LabelFilter, Group } from './types' import { useConfig } from '@/contexts/config' import { alertFilter, alertSort } from './utils' - +import { notFound } from 'next/navigation' type Props = { view: string @@ -22,6 +22,10 @@ export function AlertsTemplate(props: Props) { const { alerts, loading, errors, refreshAlerts } = useAlerts() const [selectedAlert, setSelectedAlert] = useState(null) + if (!config.views[view]) { + return notFound() + } + const { filters, groupBy } = config.views[view] // Flatten alerts diff --git a/src/config/client.ts b/src/config/client.ts index 78b359e..bdea11e 100644 --- a/src/config/client.ts +++ b/src/config/client.ts @@ -1,4 +1,4 @@ -import { config } from "." +import { config } from "./server" // Remove the endpoints from the client config export const clientConfig = { diff --git a/src/config/index.ts b/src/config/index.ts index 50c4645..4d1b581 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -1,4 +1,4 @@ -import { parseConfigFile, resolveConfigFile, resolveEnvVars} from './utils' +import { parseConfigFile, resolveConfigFile, resolveEnvVars } from './utils' import { Config, ConfigSchema } from './types' const defaultConfigFiles = [ @@ -7,16 +7,34 @@ const defaultConfigFiles = [ 'config.yml', ] -const configPath = process.env.APP_CONFIG || await resolveConfigFile(defaultConfigFiles, process.cwd()) +/* eslint-disable @typescript-eslint/no-explicit-any */ +const memoize = Promise>(fn: T) => { + const cache = new Map>() + return async function(...args: Parameters): Promise> { + const key = JSON.stringify(args) + if (cache.has(key)) { + return cache.get(key) as ReturnType + } + const result = await fn(...args) + cache.set(key, result) + return result + } +} -console.log(`Using config file: ${configPath}`) +export const getConfig = memoize(async function() { + const configPath = process.env.APP_CONFIG || await resolveConfigFile(defaultConfigFiles, process.cwd()) -const parsedConfig = await parseConfigFile(configPath) -const resolvedConfig = resolveEnvVars(parsedConfig) + console.log(`Using config file: ${configPath}`) -const validatedConfig = ConfigSchema.safeParse(resolvedConfig) -if (!validatedConfig.success) { - throw new Error('Invalid config file: ' + validatedConfig.error.errors) -} + const parsedConfig = await parseConfigFile(configPath) + const resolvedConfig = resolveEnvVars(parsedConfig) + + const validatedConfig = ConfigSchema.safeParse(resolvedConfig) + if (!validatedConfig.success) { + throw new Error('Invalid config file: ' + validatedConfig.error.errors) + } + + const config: Config = validatedConfig.data -export const config: Config = validatedConfig.data + return config +}) diff --git a/src/config/server.ts b/src/config/server.ts new file mode 100644 index 0000000..5e9bdbb --- /dev/null +++ b/src/config/server.ts @@ -0,0 +1,3 @@ +import { getConfig } from "." + +export const config = await getConfig() diff --git a/src/contexts/config.tsx b/src/contexts/config.tsx index 05fb3ba..3cc9112 100644 --- a/src/contexts/config.tsx +++ b/src/contexts/config.tsx @@ -1,6 +1,6 @@ 'use client' -import React, { createContext, useContext, ReactNode } from 'react' +import React, { createContext, useContext, ReactNode, useState, useEffect } from 'react' import { Config } from '@/config/types' interface ConfigContextProps { @@ -9,7 +9,36 @@ interface ConfigContextProps { const ConfigContext = createContext(undefined) -export const ConfigProvider = ({ children, config }: { children: ReactNode, config: Config }) => { +export const ConfigProvider = ({ children }: { children: ReactNode }) => { + const [config, setConfig] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + const fetchConfig = async () => { + try { + const response = await fetch('/api/config') + if (!response.ok) { + throw new Error('Network response was not ok') + } + const data = await response.json() + setConfig(data) + } catch (error) { + console.error('Failed to fetch config:', error) + setError("Failed to fetch config") + } + } + + fetchConfig() + }, []) + + if (error !== null) { + return
{error}
+ } + + if (config === null) { + return
Loading...
+ } + return ( {children}