diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..23f49a0 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,126 @@ +# Changelog + +All notable changes to ServiceMaker will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +#### Node.js/Foxx Service Support +- **Node.js Base Image**: Added `Dockerfile.node22base` for Node.js 22 base image with pre-installed packages + - Installs Node.js 22 from NodeSource + - Pre-installs ArangoDB packages: `@arangodb/node-foxx`, `@arangodb/node-foxx-launcher`, `@arangodb/arangodb` + - Pre-installs standard packages with version pinning: `lodash`, `dayjs`, `uuid`, `dotenv`, `axios`, `joi`, `winston`, `async`, `jsonwebtoken`, `bcrypt`, `semver` + - Creates base `node_modules` at `/home/user/node_modules` with checksums for dependency tracking + - Base image is immutable and pre-scanned for security vulnerabilities + - Added to `baseimages/imagelist.txt` as `node22base` + +- **Node.js Dockerfile Template**: Created `Dockerfile.nodejs.template` for building Node.js/Foxx service images + - Copies service directory directly to `/project/{service-name}/` + - Configures working directory and user permissions + - Sets NODE_PATH environment variable for module resolution + - Executes `prepareproject-nodejs.sh` for dependency management + +- **Dependency Management Script**: Added `scripts/prepareproject-nodejs.sh` and `scripts/check-base-dependencies.js` + - Base `node_modules` at `/home/user/node_modules` is immutable and never copied + - Pre-install check: `check-base-dependencies.js` analyzes project dependencies against base packages + - Version compatibility: Uses `semver` to verify if base package versions satisfy project requirements + - Avoids duplication: Only installs packages that are missing or have incompatible versions + - Uses NODE_PATH for module resolution (project first, then base) + - Verifies `node-foxx` binary accessibility from either location + - Keeps base image immutable for security scanning + - Results in smaller project `node_modules` and `project.tar.gz` files + +- **Project Type Detection**: Extended `detect_project_type()` to support: + - `python`: Projects with `pyproject.toml` + - `foxx`: Multi-service projects with `package.json` and `services.json` (both required) + - `foxx-service`: Single service directory with `package.json` only (auto-generates `services.json`) + - Execution stops with error if `services.json` is missing for Node.js projects + +- **Service Structure Generation**: Simplified structure for single service directories + - Copies service directory directly to `/project/{service-name}/` (no wrapper folder) + - Generates `services.json` automatically with mount path "/" and basePath "." in the service directory + - `package.json` and `services.json` are in the same directory where `node_modules` will be created + +- **Services JSON Generation**: Added `generate_services_json()` function + - Automatically generates `services.json` for single service directories (`foxx-service` type) + - Configures mount path as "/" (routing handled by Helm chart at deployment) + - Sets basePath to "." (relative to WORKDIR where `node-foxx` runs) + +- **Package.json Support**: Added functions to read Node.js project metadata + - `read_name_from_package_json()`: Extracts project name from `package.json` + - `read_service_info_from_package_json()`: Extracts name and version for Helm charts + +- **Entrypoint Enhancement**: Updated `baseimages/scripts/entrypoint.sh` to support Node.js/Foxx services + - Detects Foxx services by checking for both `package.json` and `services.json` + - Automatically runs `node-foxx` for Foxx services (checks project `node_modules` first, then base) + - Only supports Foxx services (requires both `package.json` and `services.json`) + - Maintains backward compatibility with Python services + +- **Test Service**: Added `itzpapalotl-node` test service in `testprojects/` + - Example Node.js/Foxx service for testing ServiceMaker functionality + - Demonstrates service structure generation and dependency management + +### Changed + +- **Main Application Logic**: Extended `src/main.rs` to support Node.js/Foxx projects + - Added project type detection requiring both `package.json` and `services.json` for `foxx` type + - Error handling: execution stops if `services.json` is missing for Node.js projects + - Simplified file copying: projects are copied as-is (no wrapper structure generation) + - Base image default handling: Introduced compile-time constants (`DEFAULT_PYTHON_BASE_IMAGE`, `DEFAULT_NODEJS_BASE_IMAGE`) + - Explicit user intent tracking: Changed `base_image` to `Option` to detect explicit user choices + - Smart defaults: Only sets project-type-specific defaults when user hasn't explicitly set base image + - Modified Dockerfile generation to use Node.js template for Foxx projects + - Updated Helm chart generation to support Node.js/Foxx projects + - Added `prepareproject-nodejs.sh` and `check-base-dependencies.js` to embedded scripts list + - No entrypoint required for Foxx services (uses `node-foxx` from base image) + +- **Entrypoint Script**: Enhanced `baseimages/scripts/entrypoint.sh` to support Node.js/Foxx services + - Added service type detection based on project files + - Maintains backward compatibility with Python services + +- **Base Image List**: Updated `baseimages/imagelist.txt` to include `node22base` + +- **File Copying Logic**: Updated `copy_dir_recursive()` to skip `node_modules` directories + - Prevents copying local `node_modules` which should be installed in Docker build + - Ensures only project source code is copied, dependencies are installed fresh in Docker + +### Fixed + +- **Windows Compatibility**: Fixed Windows build issues in `src/main.rs` + - Added conditional compilation for Unix-specific file permissions (`#[cfg(unix)]`) + - Windows builds now skip `set_mode()` calls that are Unix-only + +### Technical Details + +For detailed information about base image structure, service architecture, and module resolution, see [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). + +## [0.9.2] - Previous Release + +### Existing Features +- Python service support with `pyproject.toml` +- Base image management for Python 3.13 +- Docker image building and pushing +- Helm chart generation +- Project tar.gz creation +- Virtual environment management with `uv` + +--- + +## Version History + +- **0.9.2**: Initial release with Python support +- **Unreleased**: Added Node.js/Foxx service support + +--- + +## Notes + +- All changes maintain backward compatibility with existing Python projects +- Node.js support is additive and does not affect Python service functionality +- Base images must be built separately using `baseimages/build.sh` +- Windows users should use WSL or Linux environment for building base images + diff --git a/Dockerfile.nodejs.template b/Dockerfile.nodejs.template new file mode 100644 index 0000000..de3ec20 --- /dev/null +++ b/Dockerfile.nodejs.template @@ -0,0 +1,24 @@ +FROM {BASE_IMAGE} + +USER root + +COPY ./scripts /scripts +COPY {PROJECT_DIR} /project/{PROJECT_DIR} +RUN chown -R user:user /project/{PROJECT_DIR} + +USER user +WORKDIR /project/{WORKDIR} + +# Set NODE_PATH to resolve from project node_modules first, then base node_modules +# This allows npm to install only missing/incompatible packages in project directory +# while still accessing base packages from /home/user/node_modules +ENV NODE_PATH={NODE_PATH} + +RUN /scripts/prepareproject-nodejs.sh + +EXPOSE {PORT} + +# Run node-foxx from current directory (wrapper or project root) +# Use base node-foxx binary (guaranteed to exist), NODE_PATH handles module resolution +CMD ["/home/user/node_modules/.bin/node-foxx"] + diff --git a/baseimages/Dockerfile.node22base b/baseimages/Dockerfile.node22base new file mode 100644 index 0000000..e023852 --- /dev/null +++ b/baseimages/Dockerfile.node22base @@ -0,0 +1,46 @@ +FROM debian:trixie-slim + +COPY ./scripts /scripts +RUN /scripts/debinstall.sh + +# Install Node.js 22 +RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \ + apt-get install -y nodejs && \ + apt-get clean + +WORKDIR /home/user +USER user + +# Create base node_modules with standard packages (immutable, pre-scanned) +# This location is used by all projects and should never be modified +# Standard packages are selected to benefit most projects while keeping image size reasonable +RUN npm init -y && \ + npm install \ + # ArangoDB/Foxx core + @arangodb/node-foxx@^0.0.1-alpha.0 \ + @arangodb/node-foxx-launcher@^0.0.1-alpha.0 \ + @arangodb/arangodb@^0.0.1-alpha.0 \ + # Dependency checking utility + semver@^7.6.3 \ + # Essential utilities + lodash@^4.17.21 \ + dayjs@^1.11.10 \ + uuid@^9.0.1 \ + dotenv@^16.4.5 \ + # HTTP clients + axios@^1.7.2 \ + # Validation + joi@^17.13.3 \ + # Logging + winston@^3.15.0 \ + # Async utilities + async@^3.2.5 \ + # Security + jsonwebtoken@^9.0.2 \ + bcrypt@^5.1.1 + +# Create checksums for base node_modules (for tracking, base remains immutable) +RUN find node_modules -type f -print0 | \ + xargs -0 sha256sum > sums_sha256 + +CMD [ "/scripts/entrypoint.sh" ] diff --git a/baseimages/imagelist.txt b/baseimages/imagelist.txt index 5620215..a608fae 100644 --- a/baseimages/imagelist.txt +++ b/baseimages/imagelist.txt @@ -1,3 +1,4 @@ py13base py13cugraph py13torch +node22base diff --git a/baseimages/scripts/entrypoint.sh b/baseimages/scripts/entrypoint.sh index 9878098..0c38dd2 100755 --- a/baseimages/scripts/entrypoint.sh +++ b/baseimages/scripts/entrypoint.sh @@ -14,16 +14,34 @@ if test -e project.tar.gz ; then tar xzvf project.tar.gz > /dev/null fi -# Run the entrypoint if configured: +# Detect service type and run accordingly if test -e entrypoint ; then ENTRYPOINT=$(cat entrypoint) echo Running project ... - . /home/user/.local/bin/env - . /home/user/the_venv/bin/activate - for p in /project/the_venv/lib/python*/site-packages ; do - export PYTHONPATH=$p - done - exec python $ENTRYPOINT + + # Check if it's a Node.js/Foxx service (requires both package.json and services.json) + if [ -f "package.json" ] && [ -f "services.json" ]; then + # Node.js/Foxx service + echo "Detected Node.js/Foxx service" + if [ -f "node_modules/.bin/node-foxx" ]; then + exec node_modules/.bin/node-foxx + elif [ -f "/home/user/node_modules/.bin/node-foxx" ]; then + # Fallback to base node-foxx binary + exec /home/user/node_modules/.bin/node-foxx + else + echo "Error: node-foxx not found. Make sure node_modules are installed." + exit 1 + fi + else + # Python service (existing logic) + echo "Detected Python service" + . /home/user/.local/bin/env + . /home/user/the_venv/bin/activate + for p in /project/the_venv/lib/python*/site-packages ; do + export PYTHONPATH=$p + done + exec python $ENTRYPOINT + fi fi echo No entrypoint found, running bash instead... diff --git a/charts/templates/deployment.yaml b/charts/templates/deployment.yaml index bca055d..1c67c73 100644 --- a/charts/templates/deployment.yaml +++ b/charts/templates/deployment.yaml @@ -31,6 +31,7 @@ spec: release: {{ .Release.Name }} type: deployment profiles.arangodb.com/deployment: deployment + integration.profiles.arangodb.com/authn: v1 spec: containers: - name: {SERVICE_NAME} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..bbb38ae --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,206 @@ +# Architecture + +This document describes the technical architecture and design decisions for ServiceMaker. + +## Node.js/Foxx Services + +### Base Image Structure + +The base image (`arangodb/node22base:latest`) provides an immutable foundation for all Node.js/Foxx services: + +**File System Layout:** +``` +/home/user/ +├── node_modules/ # Immutable base packages (pre-scanned for security) +│ ├── @arangodb/ +│ │ ├── node-foxx@^0.0.1-alpha.0 +│ │ ├── node-foxx-launcher@^0.0.1-alpha.0 +│ │ └── arangodb@^0.0.1-alpha.0 +│ ├── lodash@^4.17.21 +│ ├── dayjs@^1.11.10 +│ ├── axios@^1.7.2 +│ ├── joi@^17.13.3 +│ ├── winston@^3.15.0 +│ ├── semver@^7.6.3 # Required for dependency checking +│ └── ... (other standard packages) +└── sums_sha256 # SHA256 checksums of all base node_modules files +``` + +**Key Properties:** +- **Immutability**: Base `node_modules` is never modified after image creation +- **Pre-scanning**: Base packages are security-scanned before deployment +- **Version Pinning**: All base packages use caret ranges (e.g., `^4.17.21`) for reproducible builds +- **Checksum Tracking**: `sums_sha256` file enables change detection and verification + +### Service Structure + +Each service is deployed with the following structure: + +``` +/project/{service-name}/ +├── services.json # Auto-generated Foxx service configuration +│ # Format: [{"mount": "/", "basePath": "."}] +├── package.json # Project dependencies and metadata +├── node_modules/ # Project-specific packages ONLY +│ # Contains only packages that are: +│ # - Missing from base node_modules +│ # - Have incompatible versions with base +└── ... # Service code files (manifest.json, routes, etc.) +``` + +**Build-Time Generation:** +- `services.json` is auto-generated for `foxx-service` project types +- Mount path is hardcoded to `"/"` (routing handled by Helm chart at deployment) +- `basePath` is set to `"."` (relative to WORKDIR where `node-foxx` runs) + +### Dependency Resolution Algorithm + +The dependency resolution process ensures no duplication while maintaining compatibility: + +**Phase 1: Pre-Install Analysis (`check-base-dependencies.js`)** + +1. **Read Project Dependencies**: Parse `package.json` to extract all `dependencies` +2. **Check Base Availability**: For each dependency: + - Check if package exists at `/home/user/node_modules/{package-name}/` + - Read version from base package's `package.json` +3. **Version Compatibility Check**: Use `semver.satisfies()` to verify: + - Base version satisfies project's version range (e.g., `^4.17.21` satisfies `^4.17.0`) + - If satisfied → skip installation (use base version) + - If not satisfied → add to install list +4. **Output**: JSON array of packages to install (missing or incompatible) + +**Phase 2: Selective Installation (`prepareproject-nodejs.sh`)** + +1. **Parse Install List**: Extract package specifications from JSON output +2. **Install Missing/Incompatible**: Run `npm install --production --no-save` for each: + ```bash + npm install --production --no-save package-name@version-range + ``` +3. **Result**: Project `node_modules` contains only packages not available/compatible in base + +**Example Scenario:** +``` +Project requires: lodash@^4.17.0, axios@^1.7.0, custom-pkg@^1.0.0 +Base has: lodash@4.17.21, axios@1.7.2 + +Result: +- lodash: ✓ Base version 4.17.21 satisfies ^4.17.0 → Use base +- axios: ✓ Base version 1.7.2 satisfies ^1.7.0 → Use base +- custom-pkg: ✗ Not in base → Install to project/node_modules +``` + +### Module Resolution at Runtime + +Node.js module resolution uses `NODE_PATH` environment variable: + +**Configuration:** +```dockerfile +ENV NODE_PATH=/project/{service-name}/node_modules:/home/user/node_modules +``` + +**Resolution Order:** +1. **Project `node_modules`** (checked first) + - Contains project-specific packages + - Takes precedence for version conflicts +2. **Base `node_modules`** (checked second) + - Contains standard packages + - Used when package not found in project + +**Runtime Behavior:** +- `require('lodash')` → Resolves from base (if compatible version exists) +- `require('custom-pkg')` → Resolves from project (not in base) +- `require('axios')` → Resolves from base (if compatible) or project (if incompatible version installed) + +### Build Process Flow + +**Dockerfile Build Steps:** + +1. **Base Image**: `FROM arangodb/node22base:latest` +2. **Copy Scripts**: Embed `prepareproject-nodejs.sh` and `check-base-dependencies.js` +3. **Copy Project**: Copy service directory to `/project/{service-name}/` + - Local `node_modules` are excluded (not copied) +4. **Set Working Directory**: `WORKDIR /project/{service-name}` +5. **Configure NODE_PATH**: Set environment variable for module resolution +6. **Run Preparation Script**: Execute `prepareproject-nodejs.sh` + - Analyzes dependencies + - Installs only missing/incompatible packages +7. **Set Entrypoint**: `CMD ["/home/user/node_modules/.bin/node-foxx"]` + +**Script Execution Flow:** + +``` +prepareproject-nodejs.sh +├── Verify base node_modules exists +├── Run check-base-dependencies.js +│ ├── Parse package.json +│ ├── Check each dependency against base +│ ├── Verify version compatibility (semver) +│ └── Output JSON: packages to install +├── Parse JSON output +├── Install missing/incompatible packages +└── Verify node-foxx binary accessibility +``` + +### Runtime Execution + +**Container Startup:** + +1. **Entrypoint**: `/home/user/node_modules/.bin/node-foxx` (from base image) +2. **Working Directory**: `/project/{service-name}/` (where `services.json` is located) +3. **Service Discovery**: `node-foxx` reads `services.json` to determine: + - Mount path: `"/"` + - Base path: `"."` (current directory) +4. **Module Resolution**: Uses `NODE_PATH` to resolve dependencies from both locations +5. **Service Launch**: Foxx service starts with access to both base and project packages + +### Security Considerations + +**Base Image Scanning:** +- Base `node_modules` is pre-scanned for vulnerabilities before deployment +- Checksums (`sums_sha256`) enable change detection +- Immutability ensures base packages cannot be modified + +**Project Package Scanning:** +- Only project-specific packages need scanning (smaller surface area) +- Project `node_modules` is mutable and can be scanned separately +- Version conflicts resolved by installing project version (explicit choice) + +**Benefits:** +- Reduced scan time (only scan project packages) +- Base image can be pre-approved and reused +- Clear separation between base (trusted) and project (variable) packages + +### Performance Implications + +**Build Time:** +- Faster builds: Only install missing/incompatible packages +- Reduced network: Fewer packages to download +- Smaller layers: Project `node_modules` is minimal + +**Runtime:** +- Faster startup: Fewer packages to load +- Smaller images: Reduced image size +- Efficient resolution: NODE_PATH lookup is fast (filesystem-based) + +**Storage:** +- Smaller `project.tar.gz`: Only project-specific packages archived +- Base image reuse: Single base image shared across all services +- Layer caching: Base image layers cached, only project layer changes + +### Technical Constraints + +**Version Compatibility:** +- Uses semantic versioning (semver) for compatibility checks +- Caret ranges (^) in base allow patch/minor updates +- Project can override with specific versions if needed + +**NODE_PATH Limitations:** +- Only affects `require()` resolution, not `npm install` behavior +- Requires explicit pre-install check to avoid duplication +- Binary resolution: `.bin` executables must be in accessible `node_modules` + +**File System:** +- Base `node_modules` must be read-only (immutability requirement) +- Project `node_modules` must be writable (for installation) +- Both locations must be accessible via NODE_PATH + diff --git a/scripts/check-base-dependencies.js b/scripts/check-base-dependencies.js new file mode 100644 index 0000000..0e47250 --- /dev/null +++ b/scripts/check-base-dependencies.js @@ -0,0 +1,94 @@ +#!/usr/bin/env node +/** + * Checks which dependencies from package.json are already satisfied by base node_modules. + * Returns a list of packages that need to be installed (missing or incompatible versions). + * + * This script ensures we don't duplicate packages that already exist in the base image. + */ + +const fs = require('fs'); +const path = require('path'); +const semver = require('semver'); + +const BASE_NODE_MODULES = '/home/user/node_modules'; +const PROJECT_PACKAGE_JSON = './package.json'; + +// Read project package.json +let projectPackageJson; +try { + projectPackageJson = JSON.parse(fs.readFileSync(PROJECT_PACKAGE_JSON, 'utf8')); +} catch (error) { + console.error(`Error reading ${PROJECT_PACKAGE_JSON}:`, error.message); + process.exit(1); +} + +// Get all dependencies (dependencies + devDependencies, but we only care about production) +const allDependencies = { + ...(projectPackageJson.dependencies || {}), + // Note: We're in production mode, but checking all for completeness +}; + +if (Object.keys(allDependencies).length === 0) { + console.log('No dependencies found in package.json'); + process.exit(0); +} + +// Check which packages need to be installed +const packagesToInstall = []; +const packagesFromBase = []; + +for (const [packageName, versionRange] of Object.entries(allDependencies)) { + const basePackagePath = path.join(BASE_NODE_MODULES, packageName); + const basePackageJsonPath = path.join(basePackagePath, 'package.json'); + + // Check if package exists in base node_modules + if (fs.existsSync(basePackageJsonPath)) { + try { + const basePackageJson = JSON.parse(fs.readFileSync(basePackageJsonPath, 'utf8')); + const baseVersion = basePackageJson.version; + + // Check if base version satisfies project requirement + if (semver.satisfies(baseVersion, versionRange)) { + packagesFromBase.push(`${packageName}@${baseVersion} (satisfies ${versionRange})`); + // Package is available in base and version is compatible - skip installation + continue; + } else { + // Package exists but version is incompatible - need to install project version + packagesToInstall.push(`${packageName}@${versionRange}`); + } + } catch (error) { + // Error reading base package.json - install to be safe + console.warn(`Warning: Could not read base package.json for ${packageName}, will install`); + packagesToInstall.push(`${packageName}@${versionRange}`); + } + } else { + // Package doesn't exist in base - need to install + packagesToInstall.push(`${packageName}@${versionRange}`); + } +} + +// Output results to stderr (for user visibility, won't interfere with JSON parsing) +const output = { + packagesToInstall: packagesToInstall, + packagesFromBase: packagesFromBase.length, + totalDependencies: Object.keys(allDependencies).length +}; + +process.stderr.write(`\n=== Dependency Analysis ===\n`); +process.stderr.write(`Total dependencies: ${Object.keys(allDependencies).length}\n`); +process.stderr.write(`From base node_modules: ${packagesFromBase.length}\n`); +process.stderr.write(`To install: ${packagesToInstall.length}\n`); + +if (packagesFromBase.length > 0) { + process.stderr.write(`\nPackages using base version:\n`); + packagesFromBase.forEach(pkg => process.stderr.write(` ✓ ${pkg}\n`)); +} + +if (packagesToInstall.length > 0) { + process.stderr.write(`\nPackages to install:\n`); + packagesToInstall.forEach(pkg => process.stderr.write(` → ${pkg}\n`)); +} + +// Write JSON to stdout (shell script will capture this separately) +process.stdout.write(JSON.stringify(output)); + diff --git a/scripts/prepareproject-nodejs.sh b/scripts/prepareproject-nodejs.sh new file mode 100644 index 0000000..8912690 --- /dev/null +++ b/scripts/prepareproject-nodejs.sh @@ -0,0 +1,97 @@ +#!/bin/bash +# This script installs only missing or incompatible project dependencies to the project's node_modules. +# Base node_modules at /home/user/node_modules is immutable and never copied. +# Uses check-base-dependencies.js to avoid duplicating packages that exist in base. + +set -e + +# We're in /project/{service-name} (WORKDIR) +# services.json is in the current directory +# package.json is in the current directory +# node_modules will be created in the current directory +PROJECT_DIR=$(pwd) + +# Verify base node_modules exists (immutable, pre-scanned) +if [ ! -d "/home/user/node_modules" ]; then + echo "ERROR: Base node_modules not found at /home/user/node_modules" + exit 1 +fi + +echo "Base node_modules found at /home/user/node_modules (immutable)" + +# Verify base node-foxx binary exists +if [ -f "/home/user/node_modules/.bin/node-foxx" ]; then + echo "✓ Base node-foxx binary available" +else + echo "WARNING: node-foxx binary not found in base node_modules" +fi + +# Install project dependencies if package.json exists +if [ -f "package.json" ]; then + echo "Analyzing dependencies against base node_modules..." + + # Check which packages need to be installed + CHECK_SCRIPT="/scripts/check-base-dependencies.js" + if [ ! -f "$CHECK_SCRIPT" ]; then + echo "ERROR: check-base-dependencies.js not found at $CHECK_SCRIPT" + exit 1 + fi + + # Run dependency check script + # Capture stdout (JSON) only, stderr (user messages) automatically goes to console + INSTALL_DATA=$(node "$CHECK_SCRIPT") + CHECK_RESULT=$? + + if [ $CHECK_RESULT -ne 0 ]; then + echo "ERROR: Failed to check base dependencies (exit code: $CHECK_RESULT)" + exit 1 + fi + + if [ -z "$INSTALL_DATA" ] || ! echo "$INSTALL_DATA" | grep -q '^{'; then + echo "ERROR: Could not parse dependency check output" + echo "Received: $INSTALL_DATA" + exit 1 + fi + PACKAGES_TO_INSTALL=$(echo "$INSTALL_DATA" | node -e "const data=JSON.parse(require('fs').readFileSync(0,'utf8')); console.log(data.packagesToInstall.map(p => p.split('@')[0]).join(' '))") + + # Count packages + TOTAL_DEPS=$(echo "$INSTALL_DATA" | node -e "const data=JSON.parse(require('fs').readFileSync(0,'utf8')); console.log(data.totalDependencies)") + FROM_BASE=$(echo "$INSTALL_DATA" | node -e "const data=JSON.parse(require('fs').readFileSync(0,'utf8')); console.log(data.packagesFromBase)") + TO_INSTALL_COUNT=$(echo "$INSTALL_DATA" | node -e "const data=JSON.parse(require('fs').readFileSync(0,'utf8')); console.log(data.packagesToInstall.length)") + + echo "" + echo "Dependency summary:" + echo " Total dependencies: $TOTAL_DEPS" + echo " Available in base: $FROM_BASE" + echo " To install: $TO_INSTALL_COUNT" + echo "" + + # Install only packages that are missing or incompatible + if [ -n "$PACKAGES_TO_INSTALL" ] && [ "$PACKAGES_TO_INSTALL" != "" ]; then + echo "Installing missing/incompatible packages: $PACKAGES_TO_INSTALL" + # Install packages individually to avoid installing everything + for package_spec in $(echo "$INSTALL_DATA" | node -e "const data=JSON.parse(require('fs').readFileSync(0,'utf8')); console.log(data.packagesToInstall.join(' '))"); do + npm install --production --no-save "$package_spec" + done + echo "✓ Project-specific dependencies installed" + else + echo "✓ All dependencies satisfied by base node_modules (no installation needed)" + # Create empty node_modules directory if it doesn't exist (for consistency) + mkdir -p node_modules + fi + + # Verify node-foxx is accessible (either from base or project node_modules) + if [ -f "node_modules/.bin/node-foxx" ]; then + echo "✓ node-foxx binary found in project node_modules" + elif [ -f "/home/user/node_modules/.bin/node-foxx" ]; then + echo "✓ node-foxx binary available from base node_modules (will be resolved via NODE_PATH)" + else + echo "ERROR: node-foxx binary not found in base or project node_modules!" + exit 1 + fi +else + echo "No package.json found, skipping dependency installation" +fi + +echo "Node.js project prepared successfully" + diff --git a/src/main.rs b/src/main.rs index 0ece9be..f2ff9f1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,10 @@ use std::path::{Path, PathBuf}; use std::process::Command; use toml::Value; +// Default base images +const DEFAULT_PYTHON_BASE_IMAGE: &str = "arangodb/py13base:latest"; +const DEFAULT_NODEJS_BASE_IMAGE: &str = "arangodb/node22base:latest"; + // Embedded chart files struct ChartFile { path: &'static str, @@ -50,13 +54,21 @@ const SCRIPT_FILES: &[ScriptFile] = &[ path: "prepareproject.sh", content: include_str!("../scripts/prepareproject.sh"), }, + ScriptFile { + path: "prepareproject-nodejs.sh", + content: include_str!("../scripts/prepareproject-nodejs.sh"), + }, + ScriptFile { + path: "check-base-dependencies.js", + content: include_str!("../scripts/check-base-dependencies.js"), + }, ScriptFile { path: "zipper.sh", content: include_str!("../scripts/zipper.sh"), }, ]; -/// A tool to wrap Python projects as Docker services +/// A tool to wrap Python and Node.js projects as Docker services #[derive(Parser, Debug)] #[command(author, version, about, long_about = None)] struct Args { @@ -64,13 +76,13 @@ struct Args { #[arg(long)] name: Option, - /// Path to the folder containing the Python project + /// Path to the folder containing the project #[arg(long)] project_home: Option, /// Base Docker image - #[arg(long, default_value = "arangodb/py13base:latest")] - base_image: String, + #[arg(long)] + base_image: Option, /// Exposed port number #[arg(long)] @@ -96,6 +108,9 @@ struct Args { fn main() -> Result<(), Box> { let mut args = Args::parse(); + // Track if base_image was explicitly set by user + let base_image_explicitly_set = args.base_image.is_some(); + // Get project home first (prompt if needed) if args.project_home.is_none() { let path_str = prompt("Project home path")?; @@ -108,11 +123,72 @@ fn main() -> Result<(), Box> { return Err(format!("Project home does not exist: {}", project_home.display()).into()); } - // Try to get name from pyproject.toml if not provided on command line - if args.name.is_none() - && let Ok(name) = read_name_from_pyproject(project_home) - { - args.name = Some(name); + // Detect project type + let project_type = detect_project_type(project_home)?; + println!("Detected project type: {}", project_type); + + // Handle project type-specific configuration + match project_type.as_str() { + "python" => { + // Try to get name from pyproject.toml if not provided on command line + if args.name.is_none() + && let Ok(name) = read_name_from_pyproject(project_home) + { + args.name = Some(name); + } + + // Try to auto-detect entrypoint if exactly one .py file exists + if args.entrypoint.is_none() + && let Ok(Some(py_file)) = find_single_py_file(project_home) + { + args.entrypoint = Some(py_file); + } + + // Prompt for entrypoint if still not set + if args.entrypoint.is_none() { + args.entrypoint = Some(prompt("Python entrypoint script (e.g., main.py)")?); + } + + // Set default base image for Python if not explicitly set + if !base_image_explicitly_set { + args.base_image = Some(DEFAULT_PYTHON_BASE_IMAGE.to_string()); + } + } + "foxx" => { + // Multi-service structure (has services.json) + // Try to get name from package.json if not provided + if args.name.is_none() + && let Ok(name) = read_name_from_package_json(project_home) + { + args.name = Some(name); + } + + // Set default base image for Node.js if not explicitly set + if !base_image_explicitly_set { + args.base_image = Some(DEFAULT_NODEJS_BASE_IMAGE.to_string()); + } + } + "foxx-service" => { + // Single service directory - will generate services.json + let service_name = project_home.file_name().unwrap().to_str().unwrap(); + + // Try to get name from package.json if not provided + if args.name.is_none() { + if let Ok(name) = read_name_from_package_json(project_home) { + args.name = Some(name); + } else { + args.name = Some(service_name.to_string()); + } + } + + // Set default base image for Node.js if not explicitly set + if !base_image_explicitly_set { + args.base_image = Some(DEFAULT_NODEJS_BASE_IMAGE.to_string()); + } + } + _ => { + return Err(format!("Unsupported project type: {}", project_type).into()); + } } // Prompt for name if still not set @@ -120,7 +196,7 @@ fn main() -> Result<(), Box> { args.name = Some(prompt("Project name")?); } - let project_dir = project_home.file_name().unwrap().to_str().unwrap(); + let initial_project_dir = project_home.file_name().unwrap().to_str().unwrap(); if args.port.is_none() { let port_str = prompt("Exposed port number")?; @@ -131,32 +207,23 @@ fn main() -> Result<(), Box> { args.image_name = Some(prompt("Docker image name")?); } - // Try to auto-detect entrypoint if exactly one .py file exists - if args.entrypoint.is_none() - && let Ok(Some(py_file)) = find_single_py_file(project_home) - { - args.entrypoint = Some(py_file); - } - - // Prompt for entrypoint if still not set - if args.entrypoint.is_none() { - args.entrypoint = Some(prompt("Python entrypoint script (e.g., main.py)")?); - } - let name = args.name.as_ref().unwrap(); let project_home = args.project_home.as_ref().unwrap(); let image_name = args.image_name.as_ref().unwrap(); - let entrypoint = args.entrypoint.as_ref().unwrap(); let port = args.port.unwrap(); + let base_image = args.base_image.as_ref().unwrap(); println!("\n=== Configuration ==="); println!("Project name: {}", name); + println!("Project type: {}", project_type); println!("Project home: {}", project_home.display()); - println!("Project directory name: {}", project_dir); - println!("Base image: {}", args.base_image); + println!("Project directory name: {}", initial_project_dir); + println!("Base image: {}", base_image); println!("Port: {}", port); println!("Image name: {}", image_name); - println!("Entrypoint: {}", entrypoint); + if let Some(ref entrypoint) = args.entrypoint { + println!("Entrypoint: {}", entrypoint); + } println!("Push: {}", args.push); println!("Make tar.gz: {}", args.make_tar_gz); println!("=====================\n"); @@ -174,28 +241,66 @@ fn main() -> Result<(), Box> { // Copy scripts to temp directory with executable permissions copy_scripts_to_temp(&temp_dir)?; - // Copy project directory to temp directory - let project_dest = temp_dir.join(project_dir); - println!( - "Copying project from {} to {}", - project_home.display(), - project_dest.display() - ); - copy_dir_recursive(project_home, &project_dest)?; - - // Extract Python version from base image name - let python_version = extract_python_version(&args.base_image); - - // Read and modify Dockerfile template - let dockerfile_template = include_str!("../Dockerfile.template"); - let modified_dockerfile = modify_dockerfile( - dockerfile_template, - &args.base_image, - project_dir, - entrypoint, - port, - &python_version, - ); + // Handle Node.js single service directory - copy directly and generate services.json + let (service_name_opt, project_dir) = if project_type == "foxx-service" { + let service_name = initial_project_dir; + + // Copy service directory directly (no wrapper folder) + let project_dest = temp_dir.join(service_name); + println!( + "Copying service from {} to {}", + project_home.display(), + project_dest.display() + ); + copy_dir_recursive(project_home, &project_dest)?; + + // Generate services.json with mount path "/" directly in service directory + let services_json_content = generate_services_json(service_name); + let services_json_path = project_dest.join("services.json"); + fs::write(&services_json_path, services_json_content)?; + println!("Generated services.json: {}", services_json_path.display()); + + // Store service_name for later use in Dockerfile generation + (Some(service_name.to_string()), service_name.to_string()) + } else { + // Normal case - copy project directory as-is + let project_dest = temp_dir.join(initial_project_dir); + println!( + "Copying project from {} to {}", + project_home.display(), + project_dest.display() + ); + copy_dir_recursive(project_home, &project_dest)?; + (None, initial_project_dir.to_string()) + }; + + // Choose Dockerfile template and modify based on project type + let modified_dockerfile = match project_type.as_str() { + "python" => { + let python_version = extract_python_version(base_image); + let entrypoint = args.entrypoint.as_ref().unwrap(); + let dockerfile_template = include_str!("../Dockerfile.template"); + modify_dockerfile_python( + dockerfile_template, + base_image, + &project_dir, + entrypoint, + port, + &python_version, + ) + } + "foxx" | "foxx-service" => { + let dockerfile_template = include_str!("../Dockerfile.nodejs.template"); + modify_dockerfile_nodejs( + dockerfile_template, + base_image, + &project_dir, + port, + service_name_opt.as_deref(), + ) + } + _ => return Err("Unsupported project type".into()), + }; // Write modified Dockerfile to temp directory let dockerfile_path = temp_dir.join("Dockerfile"); @@ -321,9 +426,21 @@ fn main() -> Result<(), Box> { // Generate Helm chart println!("\n=== Generating Helm Chart ==="); - let (service_name, version) = read_service_info_from_pyproject(project_home)?; - println!("Service name from pyproject.toml: {}", service_name); - println!("Version from pyproject.toml: {}", version); + let (service_name, version) = match project_type.as_str() { + "python" => { + let (name, ver) = read_service_info_from_pyproject(project_home)?; + println!("Service name from pyproject.toml: {}", name); + println!("Version from pyproject.toml: {}", ver); + (name, ver) + } + "foxx" | "foxx-service" => { + let (name, ver) = read_service_info_from_package_json(project_home)?; + println!("Service name from package.json: {}", name); + println!("Version from package.json: {}", ver); + (name, ver) + } + _ => return Err("Unsupported project type for Helm chart generation".into()), + }; let chart_dir = temp_dir.join(&service_name); @@ -400,7 +517,7 @@ fn extract_python_version(base_image: &str) -> String { "3.13".to_string() } -fn modify_dockerfile( +fn modify_dockerfile_python( template: &str, base_image: &str, project_dir: &str, @@ -416,6 +533,79 @@ fn modify_dockerfile( .replace("{PYTHON_VERSION}", python_version) } +fn modify_dockerfile_nodejs( + template: &str, + base_image: &str, + project_dir: &str, + port: u16, + _service_name: Option<&str>, +) -> String { + // Simplified structure: + // - COPY copies the service directory directly (services.json is inside) + // - WORKDIR is /project/{service-name} (where services.json is located) + // - node_modules is in /project/{service-name}/node_modules + let node_path = format!("/project/{}/node_modules:/home/user/node_modules", project_dir); + + template + .replace("{BASE_IMAGE}", base_image) + .replace("{PROJECT_DIR}", project_dir) + .replace("{WORKDIR}", project_dir) + .replace("{PORT}", &port.to_string()) + .replace("{NODE_PATH}", &node_path) +} + +fn detect_project_type(project_home: &Path) -> Result> { + let pyproject = project_home.join("pyproject.toml"); + let package_json = project_home.join("package.json"); + let services_json = project_home.join("services.json"); + + if pyproject.exists() { + Ok("python".to_string()) + } else if package_json.exists() && services_json.exists() { + Ok("foxx".to_string()) + } else if package_json.exists() { + // Single service directory - needs wrapper structure + Ok("foxx-service".to_string()) + } else { + Err(format!( + "Could not detect project type. Expected pyproject.toml (Python) or package.json (Node.js) in: {}", + project_home.display() + ) + .into()) + } +} + +fn generate_services_json(_service_name: &str) -> String { + // basePath is "." because services.json is in the same directory as the service + // and node-foxx runs from that directory (WORKDIR) + r#"[ + { + "mount": "/", + "basePath": "." + } +]"#.to_string() +} + +fn read_name_from_package_json(project_home: &Path) -> Result> { + let package_json_path = project_home.join("package.json"); + + if !package_json_path.exists() { + return Err(format!("package.json not found in: {}", project_home.display()).into()); + } + + let content = fs::read_to_string(&package_json_path)?; + let value: serde_json::Value = serde_json::from_str(&content)?; + + // Extract project name + let name = value + .get("name") + .and_then(|n| n.as_str()) + .ok_or("Missing 'name' in package.json")? + .to_string(); + + Ok(name) +} + fn copy_dir_recursive(src: &Path, dst: &Path) -> io::Result<()> { if !dst.exists() { fs::create_dir_all(dst)?; @@ -426,8 +616,8 @@ fn copy_dir_recursive(src: &Path, dst: &Path) -> io::Result<()> { let path = entry.path(); let file_name = entry.file_name(); - // Skip .venv directories - if file_name == ".venv" { + // Skip .venv directories (Python) and node_modules (Node.js) + if file_name == ".venv" || file_name == "node_modules" { continue; } @@ -521,6 +711,35 @@ fn read_service_info_from_pyproject( Ok((name, version)) } +fn read_service_info_from_package_json( + project_home: &Path, +) -> Result<(String, String), Box> { + let package_json_path = project_home.join("package.json"); + + if !package_json_path.exists() { + return Err(format!("package.json not found in: {}", project_home.display()).into()); + } + + let content = fs::read_to_string(&package_json_path)?; + let value: serde_json::Value = serde_json::from_str(&content)?; + + // Extract project name + let name = value + .get("name") + .and_then(|n| n.as_str()) + .ok_or("Missing 'name' in package.json")? + .to_string(); + + // Extract version (default to "1.0.0" if not present) + let version = value + .get("version") + .and_then(|v| v.as_str()) + .unwrap_or("1.0.0") + .to_string(); + + Ok((name, version)) +} + fn copy_scripts_to_temp(temp_dir: &Path) -> Result<(), Box> { let scripts_dir = temp_dir.join("scripts"); fs::create_dir_all(&scripts_dir)?; diff --git a/testprojects/itzpapalotl-node/README.md b/testprojects/itzpapalotl-node/README.md new file mode 100644 index 0000000..4544e0f --- /dev/null +++ b/testprojects/itzpapalotl-node/README.md @@ -0,0 +1,11 @@ +itzpapalotl +=========== + +This ia a simple example application written for ArangoDB-Foxx. +It returns a random Aztec deity name. + +The application provides a simple REST API with the following methods: +* `GET /itzpapalotl/index`: shows an HTML overview page +* `GET /itzpapalotl/random`: returns a random deity name as JSON +* `GET /itzpapalotl/{deity}/summon`: summon a deity + diff --git a/testprojects/itzpapalotl-node/itzpapalotl.js b/testprojects/itzpapalotl-node/itzpapalotl.js new file mode 100644 index 0000000..7fc9847 --- /dev/null +++ b/testprojects/itzpapalotl-node/itzpapalotl.js @@ -0,0 +1,118 @@ +/*jslint indent: 2, nomen: true, maxlen: 100, white: true, plusplus: true, unparam: true */ +/*global require */ + +//////////////////////////////////////////////////////////////////////////////// +/// @brief An example Foxx-Application for ArangoDB +/// +/// @file +/// +/// DISCLAIMER +/// +/// Copyright 2010-2013 triagens GmbH, Cologne, Germany +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// +/// Copyright holder is triAGENS GmbH, Cologne, Germany +/// +/// @author Jan Steemann +/// @author Copyright 2011-2013, triAGENS GmbH, Cologne, Germany +//////////////////////////////////////////////////////////////////////////////// + + +"use strict"; + +const createRouter = require('@arangodb/node-foxx/router'); +const { context } = require('@arangodb/node-foxx/locals'); +const joi = require("joi"); +const router = createRouter(); + +context.use(router); + +// our app is about the following Aztec deities: +var deities = [ + "CentzonTotochtin", + "Chalchihuitlicue", + "Chantico", + "Chicomecoatl", + "Cihuacoatl", + "Cinteotl", + "Coatlicue", + "Coyolxauhqui", + "Huehuecoyotl", + "Huitzilopochtli", + "Ilamatecuhtli", + "Itzcoliuhqui", + "Itzpaplotl", + "Mayauel", + "Meztli", + "Mictlantecuhtli", + "Mixcoatl", + "Quetzalcoatl", + "Tezcatlipoca", + "Tialoc", + "Tlauixcalpantecuhtli", + "Tlazolteotl", + "Tonatiuh", + "Tozi", + "XipeTotec", + "Xochiquetzal", + "Xolotl", + "Yacatecuhtli" +]; + +// install index route (this is the default route mentioned in manifest.json) +// this route will create an HTML overview page +router.get('/index', function (req, res) { + res.set("content-type", "text/html"); + + var body = "

" + context.service.manifest.name + " (" + context.service.manifest.version + ")

"; + body += "

an example application demoing a few Foxx features

"; + + deities.forEach(function (deity) { + body += "summon " + deity + "
"; + }); + + body += "
pick a random Aztec deity"; + + res.body = body; +}) + .summary("prints an overview page"); + +// install route to return a random deity name in JSON +router.get('/random', function (req, res) { + var idx = Math.round(Math.random() * (deities.length - 1)); + res.json({ name: deities[idx] }); +}) + .summary("returns a random deity name"); + +// install deity-specific route for summoning +// deity name is passed as part of the URL +router.get('/:deity/summon', function (req, res) { + var deity = req.pathParams.deity; + + console.log("request to summon %s", deity); + + if (deities.indexOf(deity) === -1) { + // unknown deity + res.throw(404, "The requested deity could not be found"); + } + + console.log("summoning %s", deity); + + res.json({ name: deity, summoned: true }); +}) + .summary("summons the requested deity") + .pathParam("deity", + joi.string().required() + ); + diff --git a/testprojects/itzpapalotl-node/manifest.json b/testprojects/itzpapalotl-node/manifest.json new file mode 100644 index 0000000..2ec5de3 --- /dev/null +++ b/testprojects/itzpapalotl-node/manifest.json @@ -0,0 +1,12 @@ +{ + "name": "itzpapalotl", + "version": "1.2.0", + "author": "jsteemann", + "description": "random Aztec deity service", + "license": "Apache License, Version 2.0", + "main": "itzpapalotl.js", + "engines": { + "arangodb": "^3.0" + }, + "defaultDocument": "index" +} \ No newline at end of file diff --git a/testprojects/itzpapalotl-node/package.json b/testprojects/itzpapalotl-node/package.json new file mode 100644 index 0000000..e16dce3 --- /dev/null +++ b/testprojects/itzpapalotl-node/package.json @@ -0,0 +1,11 @@ +{ + "name": "demo-itzpapalotl", + "version": "1.0.0", + "description": "itzpapalotl ===========", + "main": "itzpapalotl.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "", + "license": "ISC" +}