Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ PACKAGER ?= deb
MAKE_VARS = $(if $(PREFIX),PREFIX=$(PREFIX),) \
$(if $(DESTDIR),DESTDIR=$(DESTDIR),)

.PHONY: help deps build install deb rpm apk clean
.PHONY: help deps build install deb rpm apk clean dev

help:
@echo "opensource-server — delegates to each component's Makefile."
Expand All @@ -20,6 +20,7 @@ help:
@echo " rpm build .rpm packages, collected into ./dist"
@echo " apk build .apk packages, collected into ./dist"
@echo " clean remove build artifacts, staging, packages and ./dist"
@echo " dev run the Manager locally (SQLite, no Proxmox)"
@echo " help show this message"
@echo ""
@echo "Variables: PREFIX (default /opt/opensource-server), DESTDIR (default /)."
Expand Down Expand Up @@ -51,3 +52,8 @@ deb rpm apk:
@echo ""
@echo "Packages collected in dist/:"
@ls -1 dist/

# Run the Manager locally (SQLite, no Proxmox). Delegates to create-a-container;
# forwards LOG_LEVEL when provided (e.g. `make dev LOG_LEVEL=trace`).
dev:
$(MAKE) -C create-a-container dev $(if $(LOG_LEVEL),LOG_LEVEL=$(LOG_LEVEL),)
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Full documentation lives in [`mie-opensource-landing/docs/`](mie-opensource-land
| If you want to... | Read |
|---|---|
| Install and operate a production deployment | [Installation Guide](mie-opensource-landing/docs/admins/installation.md) |
| Run the full stack locally to develop or contribute | [Development Workflow](mie-opensource-landing/docs/developers/development-workflow.md) |
| Run the Manager locally to develop or contribute (`make dev`, or the full Docker stack) | [Development Workflow](mie-opensource-landing/docs/developers/development-workflow.md) |
| Use a deployed cluster as an end user (create containers, etc.) | [User Getting Started](mie-opensource-landing/docs/users/getting-started.md) |
| Contribute changes | [Contributing](mie-opensource-landing/docs/developers/contributing.md) |
| Understand the system design | [System Architecture](mie-opensource-landing/docs/developers/system-architecture.md) |
Expand Down
44 changes: 39 additions & 5 deletions create-a-container/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,10 @@ help:
@echo "create-a-container — builds the opensource-server package."
@echo ""
@echo "Targets:"
@echo " deps install dependencies (npm ci, server + client)"
@echo " deps install production dependencies (npm ci, server + client)"
@echo " build build the web client bundle"
@echo " install stage the app into DESTDIR (default /)"
@echo " dev run server + client watch (npm run dev)"
@echo " dev run the Manager locally (SQLite, no Proxmox)"
@echo " deb build the .deb package"
@echo " rpm build the .rpm package"
@echo " apk build the .apk package"
Expand All @@ -42,9 +42,43 @@ deps:
build: deps
npm --prefix client run build

# Full local development: server (nodemon) + client (vite watch) together.
dev: deps
npm run dev
# Run the Manager locally against SQLite, with a dummy (mock) hypervisor so
# containers can be "created" without Proxmox. No .env is needed — the server's
# defaults (SQLite, auto-generated session secret, non-production mode) are
# sufficient for development.
#
# Self-contained (dev needs a full install incl. devDependencies, unlike the
# production `deps` which omits them, so it isn't split into shared phony
# prerequisites): install server + client deps with `npm install
# --no-package-lock`. This is a fast, incremental install that keeps
# devDependencies and runs the server's patch-package postinstall, but never
# rewrites package-lock.json (so `make dev` leaves the lockfile untouched even
# when the local npm would otherwise renormalize it). Then run migrations, seed
# a localhost site + dummy node, build the client once, and run three processes
# together via concurrently:
# - server (nodemon, restarts on change)
# - job-runner (so container creation exercises the real POST /containers ->
# Job -> job-runner -> bin/create-container.js -> DummyApi path)
# - client build in watch mode (rebuilds client/dist, which the server serves)
#
# Override behavior on the command line, e.g.:
# make dev LOG_LEVEL=trace # log every SQL query
#
# Only forward LOG_LEVEL into the subprocess when it's actually provided on the
# command line. Setting it unconditionally (even to empty) would shadow any
# LOG_LEVEL in a local .env, since dotenv does not override existing env vars.
dev: LOG_LEVEL_PREFIX = $(if $(LOG_LEVEL),LOG_LEVEL=$(LOG_LEVEL) ,)
dev:
npm install --no-package-lock
npm --prefix client install --no-package-lock
npm run db:migrate
node bin/dev-bootstrap.js
npm --prefix client run build
@echo "Starting Manager (server + job-runner + client watch) at http://localhost:3000 ..."
$(LOG_LEVEL_PREFIX)npx concurrently -n server,jobs,client -c blue,magenta,green \
"npx nodemon server.js" \
"node job-runner.js" \
"npm --prefix client run build:watch"

install: build
$(INSTALL) -d $(DESTBIN)
Expand Down
96 changes: 96 additions & 0 deletions create-a-container/bin/dev-bootstrap.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
#!/usr/bin/env node
/**
* dev-bootstrap.js
*
* Provisions the minimum data needed to use the Manager locally WITHOUT a real
* Proxmox cluster. Intended to be run by `make dev` (and safe to run by hand).
*
* It creates, idempotently:
* - a `localhost` Site,
* - a `dummy` Node (nodeType: 'dummy') that acts as a mock hypervisor, and
* - a `localhost` ExternalDomain (so image templates that expose an HTTP port,
* which auto-add an HTTP service, have a domain to bind to — otherwise the
* new-container form is unsubmittable).
*
* IMPORTANT: This is deliberately NOT wired into the `/auth/dev` login route.
* Doing so previously clobbered the Docker Compose bootstrap, which calls
* `POST /auth/dev` before creating its own "Development" site — stealing site
* id 1 and importing real nodes/containers into the wrong site.
*
* To stay clear of that path entirely, this script is a NO-OP if ANY Site
* already exists. The Docker stack always creates its own site, so running this
* there does nothing. It only ever populates a fresh, empty local database.
*
* Refuses to run when NODE_ENV === 'production'.
*
* Exit code 0 = success (including the intentional no-op).
*/

const path = require('path');
const db = require(path.join(__dirname, '..', 'models'));
const { sequelize, Site, Node, ExternalDomain } = db;

const DUMMY_NODE_NAME = 'local-dummy';

async function main() {
if (process.env.NODE_ENV === 'production') {
console.error('dev-bootstrap: refusing to run in production');
process.exit(1);
}

await sequelize.authenticate();

// No-op if any site exists — never interfere with an existing environment
// (e.g. the Docker Compose "Development" site).
const existingSiteCount = await Site.count();
if (existingSiteCount > 0) {
console.log(
`dev-bootstrap: ${existingSiteCount} site(s) already present — nothing to do.`,
);
return;
}

console.log('dev-bootstrap: empty database detected, provisioning local dev environment...');

const site = await Site.create({
name: 'localhost',
internalDomain: 'localhost',
dhcpRange: '10.0.0.100,10.0.0.250',
subnetMask: '255.255.255.0',
gateway: '10.0.0.1',
dnsForwarders: '1.1.1.1,8.8.8.8',
externalIp: '127.0.0.1',
});
console.log(` • Site "localhost" created (id=${site.id})`);

const node = await Node.create({
name: DUMMY_NODE_NAME,
nodeType: 'dummy',
siteId: site.id,
ipv4Address: '127.0.0.1',
// Placeholder credentials: a dummy node never talks to a real hypervisor,
// but these must be non-null so code that gates on "has API credentials"
// (e.g. the live container-status resolver) routes through node.api() and
// gets the DummyApi instead of treating the node as unreachable.
apiUrl: 'local',
tokenId: 'local',
secret: 'local',
nvidiaAvailable: false,
});
console.log(` • Dummy Node "${node.name}" created (id=${node.id}, nodeType=dummy)`);

const domain = await ExternalDomain.create({
name: 'localhost',
siteId: site.id,
});
console.log(` • ExternalDomain "localhost" created (id=${domain.id})`);

console.log('dev-bootstrap: done. The Manager can now create containers locally (simulated).');
}

main()
.then(() => process.exit(0))
.catch((err) => {
console.error('dev-bootstrap failed:', err.message);
process.exit(1);
});
29 changes: 24 additions & 5 deletions create-a-container/config/config.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
require('dotenv').config();

const config = { dialect: process.env.DATABASE_DIALECT || 'sqlite' };
// SQL query logging is noisy (e.g. the job-runner polls the Jobs table on every
// tick), so it is gated behind the most verbose log level. Only LOG_LEVEL=trace
// enables Sequelize query logging — in both development and production. Any
// other level (or unset) keeps it off.
const sqlLogging = (process.env.LOG_LEVEL || '').toLowerCase() === 'trace' ? console.log : false;

const config = { dialect: process.env.DATABASE_DIALECT || 'sqlite', logging: sqlLogging };
if (config.dialect === 'mysql') {
config.host = process.env.MYSQL_HOST;
config.port = process.env.MYSQL_PORT;
Expand All @@ -15,15 +21,28 @@ if (config.dialect === 'mysql') {
config.database = process.env.POSTGRES_DATABASE;
} else if (config.dialect === 'sqlite') {
config.storage = process.env.SQLITE_STORAGE || 'data/database.sqlite';
// SQLite is a development-only convenience (production uses Postgres). Use the
// actively-maintained @vscode/sqlite3 fork instead of the unmaintained sqlite3
// package; it builds from source (no prebuilt-binary glibc mismatches) and is
// a dev dependency, so it is required lazily here — only when the sqlite
// dialect is actually selected — and never loaded in production.
try {
config.dialectModule = require('@vscode/sqlite3');
} catch (err) {
throw new Error(
"DATABASE_DIALECT=sqlite requires the '@vscode/sqlite3' dev dependency. " +
"Install dev dependencies (e.g. `npm install` / `make dev`), or set " +
'DATABASE_DIALECT=postgres for production.',
);
}
} else {
throw new Error(`Unsupported Database Dialect: ${config.dialect}`);
}

module.exports = {
development: config,
test: config,
production: {
...config,
logging: false
},
// Query logging is controlled solely by LOG_LEVEL (trace), so production uses
// the same config — it stays silent unless LOG_LEVEL=trace is set.
production: config,
};
5 changes: 5 additions & 0 deletions create-a-container/example.env
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@
# you also need to configure the relevant variables for the selected dialect
DATABASE_DIALECT=

# log verbosity. "trace" additionally logs every SQL query to the console
# (noisy — e.g. the job-runner polls the Jobs table on every tick), in both
# development and production. Any other value (or unset) keeps SQL logging off.
LOG_LEVEL=

# sqlite3 file path
SQLITE_STORAGE=

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
'use strict';

/**
* Adds a `nodeType` discriminator to Nodes so the app can select the right
* API client implementation (NodeApi surface):
* - 'proxmox' (default): real Proxmox host, uses ProxmoxApi.
* - 'dummy': dev-only mock hypervisor, uses DummyApi.
*
* Existing rows are backfilled to 'proxmox' via the column default, so every
* production node keeps its current behaviour. Portable across Postgres and
* SQLite (plain string column + default, no ENUM).
*
* @type {import('sequelize-cli').Migration}
*/
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn('Nodes', 'nodeType', {
type: Sequelize.STRING(50),
allowNull: false,
defaultValue: 'proxmox'
});
},

async down(queryInterface) {
await queryInterface.removeColumn('Nodes', 'nodeType');
}
};
30 changes: 23 additions & 7 deletions create-a-container/models/node.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const {
} = require('sequelize');
const https = require('https');
const ProxmoxApi = require('../utils/proxmox-api');
const DummyApi = require('../utils/dummy-api');

module.exports = (sequelize, DataTypes) => {
class Node extends Model {
Expand All @@ -24,15 +25,25 @@ module.exports = (sequelize, DataTypes) => {
}

/**
* Create an authenticated ProxmoxApi client for this node.
* Detects whether stored credentials are username/password or API token
* based on presence of '!' in tokenId (Proxmox convention).
* @returns {Promise<ProxmoxApi>} Authenticated API client
* @throws {Error} If credentials are missing or authentication fails
* Create an API client for this node, selected by `nodeType`.
*
* - `proxmox` (default): returns an authenticated {@link ProxmoxApi}.
* Detects whether stored credentials are username/password or API token
* based on presence of '!' in tokenId (Proxmox convention).
* - `dummy`: returns a {@link DummyApi} that simulates provisioning so the
* full create-container code path can run locally without a hypervisor.
*
* @returns {Promise<ProxmoxApi|DummyApi>} API client implementing the NodeApi surface
* @throws {Error} If Proxmox configuration is missing or authentication fails (proxmox only)
*/
async api() {
if (!this.tokenId || !this.secret) {
throw new Error(`Node ${this.name}: Missing credentials (tokenId and secret required)`);
// Dummy nodes have no real hypervisor; return the simulated client.
if (this.nodeType === 'dummy') {
return new DummyApi(this);
}

if (!this.apiUrl || !this.tokenId || !this.secret) {
throw new Error(`Node ${this.name}: Missing Proxmox configuration (apiUrl, tokenId, and secret are required)`);
}

const httpsAgent = new https.Agent({
Expand Down Expand Up @@ -61,6 +72,11 @@ module.exports = (sequelize, DataTypes) => {
type: DataTypes.STRING(255),
allowNull: false
},
nodeType: {
type: DataTypes.STRING(50),
allowNull: false,
defaultValue: 'proxmox'
},
ipv4Address: {
type: DataTypes.STRING(15),
allowNull: true
Expand Down
Loading
Loading