diff --git a/.env.sample b/.env.sample index e6c835f..5c7a3b8 100644 --- a/.env.sample +++ b/.env.sample @@ -3,6 +3,10 @@ GITHUB_OAUTH_CLIENT_ID= GITHUB_OAUTH_CLIENT_SECRET= GITHUB_OAUTH_VALID_EMAILS= -# Use a Docker PAT, Docker OAT, or Github PAT -PULL_SECRET_USER= -PULL_SECRET_TOKEN= +# Use a Docker PAT, Docker OAT, or Github PAT IF NECESSARY (for private repos) +# PULL_SECRET_USER= +# PULL_SECRET_TOKEN= + +# Development profile: path to local ICC/machinist repository +# ICC_REPO=/work/workspaces/workspace-platformatic/icc3 +# MACHINIST_REPO=/work/workspaces/workspace-platformatic/machinist diff --git a/README.md b/README.md index f17b9b1..8b49a6a 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,26 @@ to: ## Examples +### Development Profile + +The `development` profile enables hot reloading for ICC and Machinist services using local repositories. + +First uncomment/set the `ICC_REPO` and `MACHINIST_REPO` variables on `.env`, then: + +```sh +desk cluster up --profile development +``` + +This profile: +- Mounts your local ICC and Machinist repositories into the Kubernetes cluster +- Runs services with `pnpm run dev` for hot reloading +- Sets `DEV_K8S=true` to enable Platformatic DB service file watching +- Uses the same base image (`node:22.20.0-alpine`) as production for native module compatibility + +When code changes are made in the local repositories, the services will automatically reload. + +### Testing ICC Installation Script + Test out the installation script from ICC: ```sh diff --git a/charts/v4/config.yaml b/charts/v4/config.yaml index de9516e..e4a0991 100644 --- a/charts/v4/config.yaml +++ b/charts/v4/config.yaml @@ -27,7 +27,7 @@ dependencies: platformatic/helm: location: 'oci://ghcr.io/platformatic/helm' releaseName: 'platformatic' - version: '4.0.0' + version: '4.0.2-alpha3' namespace: 'platformatic' prometheus-community/prometheus-adapter: diff --git a/cli/cluster.js b/cli/cluster.js index a42a2a4..ce6bae4 100644 --- a/cli/cluster.js +++ b/cli/cluster.js @@ -20,7 +20,7 @@ export default async function cli (argv) { }) const [cmd] = args._ - const context = await loadContext(args.profile) + const context = await loadContext(args.profile, { command: cmd }) debug.extend('cluster')(context.cluster) if (cmd === 'up') { diff --git a/docker/Dockerfile.dev b/docker/Dockerfile.dev new file mode 100644 index 0000000..5f07e1b --- /dev/null +++ b/docker/Dockerfile.dev @@ -0,0 +1,14 @@ +# Using Debian-based slim image instead of Alpine because native addons +# (like sodium-native used by @fastify/secure-session) have prebuilt binaries +# for glibc (Debian) but not musl (Alpine), avoiding rebuild issues +FROM node:22.20.0-slim + +ENV PNPM_HOME=/home/pnpm +ENV PATH=$PNPM_HOME:$PATH + +RUN mkdir $PNPM_HOME +RUN npm i pnpm@10 --location=global + +WORKDIR /app + +CMD sh -c 'find . -name ".env" -type f -exec mv {} {}.backup-env \;' && pnpm run dev diff --git a/lib/cluster/k3d.js b/lib/cluster/k3d.js index 96d3159..f3db925 100644 --- a/lib/cluster/k3d.js +++ b/lib/cluster/k3d.js @@ -1,7 +1,10 @@ -import { join } from 'node:path' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' import { spawn, clusterName } from '../utils.js' import * as kubectl from '../kubectl.js' +const __dirname = dirname(fileURLToPath(import.meta.url)) + export async function startCluster ({ provider, chartDir, name, platformatic }) { const { config } = provider @@ -26,13 +29,39 @@ export async function startCluster ({ provider, chartDir, name, platformatic }) '--wait' ] - const volumes = Object.entries(platformatic) - .filter(([, config]) => config.hotReload && config.local?.path) - .map(([name, config]) => `--volume=${config.local.path}:/data/local/${name}@server:0`) - + const volumes = [] + let needsNodeImage = false + if (platformatic.services) { + Object.entries(platformatic.services) + .filter(([, config]) => config.hotReload && config.localRepo) + .forEach(([name, config]) => { + volumes.push(`--volume=${config.localRepo}:/data/local/${name}@server:0`) + needsNodeImage = true + }) + } args = args.concat(volumes) await spawn('k3d', args) + + if (needsNodeImage) { + const dockerDir = join(__dirname, '..', '..', 'docker') + const devDockerfile = join(dockerDir, 'Dockerfile.dev') + + // Build and import dev images for services with hot reload + if (platformatic.services?.icc?.hotReload) { + await spawn('docker', ['build', '-t', 'platformatic/icc:dev', '-f', devDockerfile, dockerDir]) + await spawn('k3d', ['image', 'import', 'platformatic/icc:dev', '-c', clusterName(name)]) + } + + if (platformatic.services?.machinist?.hotReload) { + try { + await spawn('docker', ['build', '-t', 'platformatic/machinist:dev', '-f', devDockerfile, dockerDir]) + await spawn('k3d', ['image', 'import', 'platformatic/machinist:dev', '-c', clusterName(name)]) + } catch (err) { + // Image might already exist, continue + } + } + } } export async function stopCluster ({ name }) { diff --git a/lib/context.js b/lib/context.js index 62a1aa5..d219d66 100644 --- a/lib/context.js +++ b/lib/context.js @@ -1,17 +1,17 @@ import { join, resolve, isAbsolute } from 'node:path' import Deepmerge from '@fastify/deepmerge' import dotenv from 'dotenv' -import { clusterName, debug, loadYamlFile, warn } from './utils.js' +import { clusterName, debug, loadYamlFile, spawn, warn } from './utils.js' import { createRunDir } from './run-directory.js' import { parseProfile } from '../schemas/profile.js' import { parseConfig } from '../schemas/config.js' import { CHART_NAME as PLT_CHART_NAME } from './platformatic.js' const deepmerge = Deepmerge() -const userSecrets = {} +const userSecrets = { ...process.env } dotenv.config({ quiet: true, processEnv: userSecrets }) -export async function loadContext (profileNameOrPath) { +export async function loadContext (profileNameOrPath, options = {}) { let profilePath let profileName @@ -54,6 +54,40 @@ export async function loadContext (profileNameOrPath) { debug({ context }) + // Validate hot reload configuration for development profile + // Only validate when running 'cluster up' command, not 'cluster down' or 'cluster status' + if (options.command === 'up' && profileName === 'development' && context.platformatic.services) { + const requiredVars = [] + if (context.platformatic.services.icc?.hotReload) { + if (!userSecrets.ICC_REPO) { + requiredVars.push('ICC_REPO') + } + } + if (context.platformatic.services.machinist?.hotReload) { + if (!userSecrets.MACHINIST_REPO) { + requiredVars.push('MACHINIST_REPO') + } + } + + if (requiredVars.length > 0) { + throw new Error( + `Development profile, but the following environment variable(s) are not set: ${requiredVars.join(', ')}\n` + + 'Please set them before running the cluster:\n' + + requiredVars.map(v => ` export ${v}=/path/to/${v.toLowerCase().replace('_repo', '')}`).join('\n') + '\n' + + ` desk cluster up --profile ${profileName}` + ) + } + + // run if arch is not intel + debug({ arch: process.arch }) + if (process.arch !== 'x64') { + const iccRepo = userSecrets.ICC_REPO + const pnpmInstallCommand = `NPM_CONFIG_PLATFORM=linux NPM_CONFIG_ARCH=arm64 pnpm install --force` + // run it in iccRepo directory + await spawn('bash', ['-c', pnpmInstallCommand], { cwd: iccRepo }) + } + } + return context } diff --git a/lib/helm.js b/lib/helm.js index 55cc459..380e4e0 100644 --- a/lib/helm.js +++ b/lib/helm.js @@ -43,6 +43,7 @@ export async function apply (releaseName, chart, valueFilePaths = [], version = return parseNotes(stdout) } catch (err) { debug({ err }) + warn(`Helm error output: ${err.output}`) const errorDetail = parseHelmError(err.output) warn(`Applying Helm for release '${errorDetail.releaseName}' failed. Waiting for '${errorDetail.apiVersion}/${errorDetail.kind}' to be ready`) const crdName = await getCrdName(errorDetail.kind, errorDetail.apiVersion) diff --git a/lib/platformatic.js b/lib/platformatic.js index d67f5f9..a728083 100644 --- a/lib/platformatic.js +++ b/lib/platformatic.js @@ -1,7 +1,7 @@ import { join } from 'node:path' import Deepmerge from '@fastify/deepmerge' import { getClusterStatus } from './cluster/index.js' -import { loadYamlFile } from './utils.js' +import { loadYamlFile, spawn } from './utils.js' const deepmerge = Deepmerge() @@ -18,17 +18,92 @@ export async function createChartConfig (values, { context }) { VALKEY_ICC_CONNECTION_STRING: clusterStatus.valkey.connectionString, PROMETHEUS_URL: clusterStatus.prometheus.url, PUBLIC_URL: 'https://icc.plt', - ICC_IMAGE_REPO: apps.icc.image.repository, - ICC_IMAGE_TAG: apps.icc.image.tag, - MACHINIST_IMAGE_REPO: apps.machinist.image.repository, - MACHINIST_IMAGE_TAG: apps.machinist.image.tag, PLT_NAMESPACES: context.cluster.namespaces } + + // Only add image values if they're defined + if (apps.icc.image?.repository) { + substitutions.ICC_IMAGE_REPO = apps.icc.image.repository + } + if (apps.icc.image?.tag) { + substitutions.ICC_IMAGE_TAG = apps.icc.image.tag + } + if (apps.machinist.image?.repository) { + substitutions.MACHINIST_IMAGE_REPO = apps.machinist.image.repository + } + if (apps.machinist.image?.tag) { + substitutions.MACHINIST_IMAGE_TAG = apps.machinist.image.tag + } const overrideValues = await loadYamlFile(join(context.chartDir, CHART_NAME, 'overrides.yaml'), substitutions) const deskValues = { ...values } delete deskValues.chart const overrides = deepmerge(overrideValues, deskValues) + + if (apps.icc.hotReload && apps.icc.localRepo) { + overrides.services.icc.image = { + repository: 'platformatic/icc', + tag: 'dev', + pullPolicy: 'IfNotPresent' + } + overrides.services.icc.podSecurityContext = { + runAsUser: 1000, + runAsGroup: 1000, + fsGroup: 1000 + } + overrides.services.icc.securityContext = { + runAsNonRoot: true, + runAsUser: 1000 + } + overrides.services.icc.volumes = [{ + name: 'icc-local-repo', + hostPath: { + path: '/data/local/icc', + type: 'Directory' + } + }] + overrides.services.icc.volumeMounts = [{ + name: 'icc-local-repo', + mountPath: '/app' + }] + overrides.services.icc.env = [{ + name: 'DEV_K8S', + value: 'true' + }] + } + + if (apps.machinist.hotReload && apps.machinist.localRepo) { + overrides.services.machinist.image = { + repository: 'platformatic/machinist', + tag: 'dev', + pullPolicy: 'IfNotPresent' + } + overrides.services.machinist.podSecurityContext = { + runAsUser: 1000, + runAsGroup: 1000, + fsGroup: 1000 + } + overrides.services.machinist.securityContext = { + runAsNonRoot: true, + runAsUser: 1000 + } + overrides.services.machinist.volumes = [{ + name: 'machinist-local-repo', + hostPath: { + path: '/data/local/machinist', + type: 'Directory' + } + }] + overrides.services.machinist.volumeMounts = [{ + name: 'machinist-local-repo', + mountPath: '/app' + }] + overrides.services.machinist.env = [{ + name: 'DEV_K8S', + value: 'true' + }] + } + const chartConfig = { ...values.chart, overrides } return { [CHART_NAME]: chartConfig } diff --git a/lib/utils.js b/lib/utils.js index 4a0b5a0..04df10a 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -36,9 +36,9 @@ export function error (msg, ...args) { } } -export function spawn (cmd, args = []) { +export function spawn (cmd, args = [], options = {}) { debug(`Executing CLI: ${cmd} ${args.join(' ')}`) - return nanospawn(cmd, args) + return nanospawn(cmd, args, options) } export async function spawnWithOutput (cmd, args = []) { diff --git a/profiles/development.yaml b/profiles/development.yaml new file mode 100644 index 0000000..e298e38 --- /dev/null +++ b/profiles/development.yaml @@ -0,0 +1,69 @@ +version: 4 + +description: | + This setup creates special images for icc and machinist which have volume mounts + mapped to local repositories. Essentially the same as running `cib dev` + +cluster: + namespaces: + - platformatic + + k3d: + nodes: 1 + +dependencies: + prometheus-community/prometheus-adapter: + plt_defaults: true + prometheus-community/kube-prometheus-stack: + plt_defaults: true + cloudpirates/postgres: + plt_defaults: true + cloudpirates/valkey: + plt_defaults: true + local/traefik: + plt_defaults: false + +platformatic: + services: + icc: + hotReload: true + localRepo: "{{ ICC_REPO }}" + + features: + cache_recommendations: + enable: false + risk_service_dump: + enable: false + ffc: + enable: false + icc_jobs: + enable: true + + login_methods: + google: + enable: false + client_id: "" + client_secret: "" + valid_emails: "" + github: + enable: true + client_id: "{{ GITHUB_OAUTH_CLIENT_ID }}" + client_secret: "{{ GITHUB_OAUTH_CLIENT_SECRET }}" + valid_emails: "{{ GITHUB_OAUTH_VALID_EMAILS }}" + + log_level: debug + + secrets: + icc_session: "nqS4bQDlFNZfd1PtLwbkCDEgJiozzxRuyslNPtSSdeQ=" + control_plane_keys: "iUIz122f2Kh49Q2PvGxJWuajJRm8B0TZ7orfGbf29LA=" + user_manager_session: "XnlPIbATw2x/7xIX304esO9qKuCZLR3HOa8wTF6O3pc=" + + machinist: + hotReload: true + localRepo: "{{ MACHINIST_REPO }}" + + features: + event_export: + enable: false + + log_level: debug diff --git a/schemas/v4/profile.js b/schemas/v4/profile.js index 4c1a87f..167c0bb 100644 --- a/schemas/v4/profile.js +++ b/schemas/v4/profile.js @@ -6,21 +6,14 @@ const InfraComponentSchema = Type.Object({ plt_defaults: Type.Boolean() }) -// Schema for platformatic service configuration with image -const PlatformaticServiceWithImageSchema = Type.Object({ - image: Type.Object({ +// Schema for platformatic service configuration +const PlatformaticServiceSchema = Type.Object({ + hotReload: Type.Optional(Type.Boolean()), + localRepo: Type.Optional(Type.String()), + image: Type.Optional(Type.Object({ tag: Type.String(), repository: Type.String() - }) -}, { -}) - -// Schema for platformatic service configuration with local -const PlatformaticServiceWithLocalSchema = Type.Object({ - hotReload: Type.Optional(Type.Boolean()), - local: Type.Object({ - path: Type.String() - }) + })) }, { }) @@ -29,11 +22,6 @@ const PlatformaticSkipSchema = Type.Object({ }, { }) -const PlatformaticServiceSchema = Type.Union([ - PlatformaticServiceWithImageSchema, - PlatformaticServiceWithLocalSchema -]) - const K3dRegistry = Type.Object({ address: Type.String(), configPath: Type.String(),