Skip to content
Open
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
1 change: 1 addition & 0 deletions create-a-container/client/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export interface Site {
export interface Node {
id: number;
name: string;
nodeType: 'proxmox' | 'dummy' | 'docker';
siteId: number;
ipv4Address: string | null;
apiUrl: string | null;
Expand Down
74 changes: 48 additions & 26 deletions create-a-container/client/src/pages/nodes/NodeFormPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@ import type { Node } from '@/lib/types';

const schema = z.object({
name: z.string().min(1, 'Required'),
nodeType: z.enum(['proxmox', 'dummy', 'docker']).default('proxmox'),
ipv4Address: z.string().optional(),
apiUrl: z.string().url('Must be a valid URL').or(z.literal('')).optional(),
apiUrl: z.string().optional(),
tokenId: z.string().optional(),
secret: z.string().optional(),
tlsVerify: z.boolean().optional(),
Expand All @@ -49,6 +50,10 @@ export function NodeFormPage() {
const { register, handleSubmit, reset, watch, setValue, formState } = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
name: '',
nodeType: 'proxmox',
ipv4Address: '',
apiUrl: '',
tlsVerify: true,
nvidiaAvailable: false,
imageStorage: 'local',
Expand All @@ -58,11 +63,13 @@ export function NodeFormPage() {
});
const tlsVerify = watch('tlsVerify');
const nvidiaAvailable = watch('nvidiaAvailable');
const nodeType = watch('nodeType');

useEffect(() => {
if (node) {
reset({
name: node.name,
nodeType: node.nodeType || 'proxmox',
ipv4Address: node.ipv4Address || '',
apiUrl: node.apiUrl || '',
tokenId: node.tokenId || '',
Expand Down Expand Up @@ -107,8 +114,8 @@ export function NodeFormPage() {
title={isEdit ? 'Edit node' : 'New node'}
subtitle={
isEdit
? 'Update Proxmox connection details and storage settings.'
: 'Register a Proxmox node with API credentials and default storage.'
? 'Update node connection details and storage settings.'
: 'Register a node with connection details and default storage.'
}
backTo={{ label: 'Back to nodes', to: `/sites/${siteId}/nodes` }}
maxWidth="3xl"
Expand All @@ -135,6 +142,14 @@ export function NodeFormPage() {
hasError={!!formState.errors.name}
{...register('name')}
/>
<div className="grid gap-2">
<label className="text-sm font-medium">Node type</label>
<select className="border rounded-md px-3 py-2" {...register('nodeType')}>
<option value="proxmox">Proxmox</option>
<option value="docker">Docker</option>
<option value="dummy">Dummy</option>
</select>
</div>
<Input
label="IPv4 address"
placeholder="10.0.0.1"
Expand All @@ -143,33 +158,40 @@ export function NodeFormPage() {
{...register('ipv4Address')}
/>
<Input
label="Proxmox API URL"
type="url"
placeholder="https://pve.example.com:8006"
label={nodeType === 'docker' ? 'Docker host' : 'Proxmox API URL'}
placeholder={
nodeType === 'docker'
? 'unix:///var/run/docker.sock'
: 'https://pve.example.com:8006'
}
error={formState.errors.apiUrl?.message}
hasError={!!formState.errors.apiUrl}
{...register('apiUrl')}
/>
<Input
label="API token ID"
placeholder="root@pam!my-token"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
{...register('tokenId')}
/>
<Input
label="API token secret"
type="password"
autoComplete="new-password"
helperText={isEdit && node?.hasSecret ? 'Leave blank to keep existing secret' : undefined}
{...register('secret')}
/>
<Switch
label="Verify TLS certificate"
checked={tlsVerify ?? true}
onCheckedChange={(c) => setValue('tlsVerify', c)}
/>
{nodeType === 'proxmox' && (
<>
<Input
label="API token ID"
placeholder="root@pam!my-token"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
{...register('tokenId')}
/>
<Input
label="API token secret"
type="password"
autoComplete="new-password"
helperText={isEdit && node?.hasSecret ? 'Leave blank to keep existing secret' : undefined}
{...register('secret')}
/>
<Switch
label="Verify TLS certificate"
checked={tlsVerify ?? true}
onCheckedChange={(c) => setValue('tlsVerify', c)}
/>
</>
)}
<div className="grid gap-4 sm:grid-cols-3">
<Input label="Image storage" required {...register('imageStorage')} />
<Input label="Volume storage" required {...register('volumeStorage')} />
Expand Down
8 changes: 8 additions & 0 deletions create-a-container/client/src/pages/nodes/NodesListPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,14 @@ function NvidiaBadge({ n }: { n: Node }) {
}

function CredentialsBadge({ n }: { n: Node }) {
if (n.nodeType === 'dummy') {
return <Badge variant="secondary">Not required</Badge>;
}

if (n.nodeType === 'docker') {
return n.apiUrl ? <Badge variant="success">Host set</Badge> : <Badge variant="warning">Missing</Badge>;
}

return n.hasSecret ? <Badge variant="success">Set</Badge> : <Badge variant="warning">Missing</Badge>;
}

Expand Down
35 changes: 34 additions & 1 deletion create-a-container/models/node.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
'use strict';
const {
Model
Model,
Op
} = require('sequelize');
const https = require('https');
const ProxmoxApi = require('../utils/proxmox-api');
const DummyApi = require('../utils/dummy-api');
const DockerApi = require('../utils/docker-api');

module.exports = (sequelize, DataTypes) => {
class Node extends Model {
Expand All @@ -24,6 +26,30 @@ module.exports = (sequelize, DataTypes) => {
});
}

hasApiAccess() {
if (this.nodeType === 'dummy') return true;
if (this.nodeType === 'docker') return !!this.apiUrl;
return !!(this.apiUrl && this.tokenId && this.secret);
}

static provisionableWhere() {
return {
[Op.or]: [
{ nodeType: 'dummy' },
{
nodeType: 'docker',
apiUrl: { [Op.ne]: null },
},
{
nodeType: 'proxmox',
apiUrl: { [Op.ne]: null },
tokenId: { [Op.ne]: null },
secret: { [Op.ne]: null },
},
],
};
}

/**
* Create an API client for this node, selected by `nodeType`.
*
Expand All @@ -42,6 +68,13 @@ module.exports = (sequelize, DataTypes) => {
return new DummyApi(this);
}

if (this.nodeType === 'docker') {
if (!this.apiUrl) {
throw new Error(`Node ${this.name}: Missing Docker configuration (apiUrl / DOCKER_HOST is required)`);
}
return new DockerApi(this);
}

if (!this.apiUrl || !this.tokenId || !this.secret) {
throw new Error(`Node ${this.name}: Missing Proxmox configuration (apiUrl, tokenId, and secret are required)`);
}
Expand Down
9 changes: 1 addition & 8 deletions create-a-container/routers/api/v1/containers.js
Original file line number Diff line number Diff line change
Expand Up @@ -438,14 +438,7 @@ router.post(
// provisioned.
const nodeWhere = {
siteId: site.id,
[Sequelize.Op.or]: [
{ nodeType: 'dummy' },
{
apiUrl: { [Sequelize.Op.ne]: null },
tokenId: { [Sequelize.Op.ne]: null },
secret: { [Sequelize.Op.ne]: null },
},
],
...Node.provisionableWhere(),
};
if (wantsNvidia) nodeWhere.nvidiaAvailable = true;
const node = await Node.findOne({
Expand Down
37 changes: 34 additions & 3 deletions create-a-container/routers/api/v1/nodes.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
const express = require('express');
const https = require('https');
const { Node, Site, Container } = require('../../../models');
const { isValidDockerHost } = require('../../../utils/docker-api');
const { apiAuth, apiAdmin, asyncHandler, ok, created, noContent, ApiError } =
require('../../../middlewares/api');

Expand All @@ -16,6 +17,7 @@ function serialize(n) {
return {
id: n.id,
name: n.name,
nodeType: n.nodeType,
siteId: n.siteId,
ipv4Address: n.ipv4Address,
apiUrl: n.apiUrl,
Expand All @@ -35,6 +37,28 @@ async function loadSite(req) {
return site;
}

function normalizeNodeType(nodeType) {
return nodeType || 'proxmox';
}

function validateNodeInput({ nodeType, apiUrl }) {
const type = normalizeNodeType(nodeType);

if (!['proxmox', 'docker', 'dummy'].includes(type)) {
throw new ApiError(400, 'invalid_node_type', 'Node type must be proxmox, docker, or dummy');
}

if (type === 'docker' && (!apiUrl || !isValidDockerHost(apiUrl))) {
throw new ApiError(
400,
'invalid_docker_host',
'Docker host must use unix://, tcp://, http://, or https:// without extra path components',
);
}

return type;
}

router.get(
'/',
asyncHandler(async (req, res) => {
Expand Down Expand Up @@ -63,7 +87,7 @@ router.get(
const node = await Node.findOne({
where: { id: parseInt(req.params.id, 10), siteId: site.id },
});
if (!node || !node.apiUrl || !node.tokenId || !node.secret) return ok(res, []);
if (!node || !node.hasApiAccess()) return ok(res, []);
try {
const client = await node.api();
const storages = await client.datastores(node.name, 'vztmpl', true);
Expand All @@ -80,10 +104,12 @@ router.post(
apiAdmin,
asyncHandler(async (req, res) => {
const site = await loadSite(req);
const { name, ipv4Address, apiUrl, tokenId, secret, tlsVerify, imageStorage, volumeStorage, networkBridge, nvidiaAvailable } =
const { name, nodeType, ipv4Address, apiUrl, tokenId, secret, tlsVerify, imageStorage, volumeStorage, networkBridge, nvidiaAvailable } =
req.body || {};
const type = validateNodeInput({ nodeType, apiUrl });
const node = await Node.create({
name,
nodeType: type,
ipv4Address: ipv4Address || null,
apiUrl: apiUrl || null,
tokenId: tokenId || null,
Expand All @@ -109,10 +135,15 @@ router.put(
where: { id: parseInt(req.params.id, 10), siteId: site.id },
});
if (!node) throw new ApiError(404, 'not_found', 'Node not found');
const { name, ipv4Address, apiUrl, tokenId, secret, tlsVerify, imageStorage, volumeStorage, networkBridge, nvidiaAvailable } =
const { name, nodeType, ipv4Address, apiUrl, tokenId, secret, tlsVerify, imageStorage, volumeStorage, networkBridge, nvidiaAvailable } =
req.body || {};
const type = validateNodeInput({
nodeType: nodeType || node.nodeType,
apiUrl,
});
const update = {
name,
nodeType: type,
ipv4Address: ipv4Address || null,
apiUrl: apiUrl || null,
tokenId: tokenId || null,
Expand Down
8 changes: 4 additions & 4 deletions create-a-container/utils/container-status.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ const STATUS_VALUES = Object.freeze(Object.values(STATUS));

const ACTIVE_JOB_STATUSES = ['pending', 'running'];

function nodeHasCreds(node) {
return !!(node && node.apiUrl && node.tokenId && node.secret);
function nodeHasApiAccess(node) {
return !!(node && typeof node.hasApiAccess === 'function' && node.hasApiAccess());
}

/**
Expand Down Expand Up @@ -118,7 +118,7 @@ function decideStatus(facts) {
*/
async function computeContainerStatus({ container, Job, api, snapshot }) {
const node = container.node;
const hasCreds = nodeHasCreds(node);
const hasCreds = nodeHasApiAccess(node);
const hasVmid = container.containerId != null;

// --- Determine Proxmox presence / run state ---
Expand Down Expand Up @@ -202,7 +202,7 @@ async function computeContainerStatuses(containers, Job) {
const node = group[0].node;
let snapshot = { ok: false, data: null };

if (nodeHasCreds(node)) {
if (nodeHasApiAccess(node)) {
try {
const api = await node.api();
const data = await api.clusterResources('lxc');
Expand Down
Loading
Loading