Skip to content

Commit 7dd0cd5

Browse files
committed
feat: implement defaultStorage per #198
1 parent 35e3d37 commit 7dd0cd5

File tree

7 files changed

+113
-14
lines changed

7 files changed

+113
-14
lines changed

create-a-container/bin/create-container.js

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,30 @@ function generateImageFilename(parsed, digest) {
4848
return sanitized;
4949
}
5050

51+
/**
52+
* Resolve which Proxmox storage to use for a given content type.
53+
* Returns the preferred storage if it supports the content type,
54+
* otherwise falls back to the largest enabled storage that does.
55+
* @param {object} client - ProxmoxApi instance
56+
* @param {string} nodeName - Proxmox node name
57+
* @param {string} preferred - Preferred storage name
58+
* @param {string} contentType - Proxmox content type ('vztmpl' or 'rootdir')
59+
* @returns {Promise<string>} Resolved storage name
60+
* @throws {Error} If no enabled storage supports the content type
61+
*/
62+
async function resolveStorage(client, nodeName, preferred, contentType) {
63+
const storages = await client.datastores(nodeName, contentType, true);
64+
if (storages.length === 0) {
65+
throw new Error(`No enabled storage on node ${nodeName} supports content type '${contentType}'`);
66+
}
67+
if (storages.some(s => s.storage === preferred)) {
68+
return preferred;
69+
}
70+
const largest = storages.reduce((max, s) => (s.total > max.total ? s : max), storages[0]);
71+
console.warn(`Storage '${preferred}' does not support '${contentType}' on node ${nodeName}, falling back to '${largest.storage}'`);
72+
return largest.storage;
73+
}
74+
5175
/**
5276
* Setup ACL for container owner
5377
* Grants PVEVMUser role to username@ldap on /vms/{vmid}
@@ -180,8 +204,9 @@ async function main() {
180204
const parsed = parseDockerRef(container.template);
181205
console.log(`Docker image: ${parsed.registry}/${parsed.namespace}/${parsed.image}:${parsed.tag}`);
182206

183-
const storage = node.imageStorage || 'local';
184-
console.log(`Using storage: ${storage}`);
207+
const templateStorage = await resolveStorage(client, node.name, node.imageStorage || 'local', 'vztmpl');
208+
const rootfsStorage = await resolveStorage(client, node.name, node.volumeStorage || 'local-lvm', 'rootdir');
209+
console.log(`Using template storage: ${templateStorage}, rootfs storage: ${rootfsStorage}`);
185210

186211
// Get image digest from registry to create unique filename
187212
const repo = parsed.namespace ? `${parsed.namespace}/${parsed.image}` : parsed.image;
@@ -193,8 +218,8 @@ async function main() {
193218
console.log(`Target filename: ${filename}`);
194219

195220
// Check if image already exists in storage
196-
const existingContents = await client.storageContents(node.name, storage, 'vztmpl');
197-
const expectedVolid = `${storage}:vztmpl/${filename}.tar`;
221+
const existingContents = await client.storageContents(node.name, templateStorage, 'vztmpl');
222+
const expectedVolid = `${templateStorage}:vztmpl/${filename}.tar`;
198223
const imageExists = existingContents.some(item => item.volid === expectedVolid);
199224

200225
if (imageExists) {
@@ -203,7 +228,7 @@ async function main() {
203228
// Pull the image from OCI registry
204229
const imageRef = container.template;
205230
console.log(`Pulling image ${imageRef}...`);
206-
const pullUpid = await client.pullOciImage(node.name, storage, {
231+
const pullUpid = await client.pullOciImage(node.name, templateStorage, {
207232
reference: imageRef,
208233
filename
209234
});
@@ -216,7 +241,7 @@ async function main() {
216241

217242
// Create container from the pulled image (Proxmox adds .tar to the filename)
218243
console.log(`Creating container from ${filename}.tar...`);
219-
const ostemplate = `${storage}:vztmpl/${filename}.tar`;
244+
const ostemplate = `${templateStorage}:vztmpl/${filename}.tar`;
220245
const createUpid = await client.createLxc(node.name, {
221246
vmid,
222247
hostname: container.hostname,
@@ -231,7 +256,7 @@ async function main() {
231256
onboot: 1,
232257
tags: container.username,
233258
unprivileged: 1,
234-
storage: 'local-lvm'
259+
storage: rootfsStorage
235260
});
236261
console.log(`Create task started: ${createUpid}`);
237262

@@ -252,12 +277,16 @@ async function main() {
252277
const templateVmid = templateContainer.vmid;
253278
console.log(`Found template VMID: ${templateVmid}`);
254279

280+
const rootfsStorage = await resolveStorage(client, node.name, node.volumeStorage || 'local-lvm', 'rootdir');
281+
console.log(`Using rootfs storage: ${rootfsStorage}`);
282+
255283
// Clone the template
256284
console.log(`Cloning template ${templateVmid} to VMID ${vmid}...`);
257285
const cloneUpid = await client.cloneLxc(node.name, templateVmid, vmid, {
258286
hostname: container.hostname,
259287
description: `Cloned from template ${container.template}`,
260-
full: 1
288+
full: 1,
289+
storage: rootfsStorage
261290
});
262291
console.log(`Clone task started: ${cloneUpid}`);
263292

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
'use strict';
2+
3+
/** @type {import('sequelize-cli').Migration} */
4+
module.exports = {
5+
async up(queryInterface, Sequelize) {
6+
await queryInterface.addColumn('Nodes', 'volumeStorage', {
7+
type: Sequelize.STRING(255),
8+
allowNull: false,
9+
defaultValue: 'local-lvm'
10+
});
11+
},
12+
13+
async down(queryInterface) {
14+
await queryInterface.removeColumn('Nodes', 'volumeStorage');
15+
}
16+
};

create-a-container/models/node.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,11 @@ module.exports = (sequelize, DataTypes) => {
8686
type: DataTypes.STRING(255),
8787
allowNull: false,
8888
defaultValue: 'local'
89+
},
90+
volumeStorage: {
91+
type: DataTypes.STRING(255),
92+
allowNull: false,
93+
defaultValue: 'local-lvm'
8994
}
9095
}, {
9196
sequelize,

create-a-container/routers/nodes.js

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ router.post('/', async (req, res) => {
117117
return res.redirect('/sites');
118118
}
119119

120-
const { name, ipv4Address, apiUrl, tokenId, secret, tlsVerify, imageStorage } = req.body;
120+
const { name, ipv4Address, apiUrl, tokenId, secret, tlsVerify, imageStorage, volumeStorage } = req.body;
121121

122122
await Node.create({
123123
name,
@@ -127,6 +127,7 @@ router.post('/', async (req, res) => {
127127
secret: secret || null,
128128
tlsVerify: tlsVerify === '' || tlsVerify === null ? null : tlsVerify === 'true',
129129
imageStorage: imageStorage || 'local',
130+
volumeStorage: volumeStorage || 'local-lvm',
130131
siteId
131132
});
132133

@@ -170,6 +171,7 @@ router.post('/import', async (req, res) => {
170171
const nodesWithIp = await Promise.all(nodes.map(async (n) => {
171172
let ipv4Address = null;
172173
let imageStorage = 'local';
174+
let volumeStorage = 'local-lvm';
173175

174176
try {
175177
const networkInterfaces = await client.nodeNetwork(n.node);
@@ -193,6 +195,17 @@ router.post('/import', async (req, res) => {
193195
console.error(`Failed to fetch storages for node ${n.node}:`, err.message);
194196
}
195197

198+
// Find largest storage supporting CT volumes (rootdir)
199+
try {
200+
const storages = await client.datastores(n.node, 'rootdir', true);
201+
if (storages.length > 0) {
202+
const largest = storages.reduce((max, s) => (s.total > max.total ? s : max), storages[0]);
203+
volumeStorage = largest.storage;
204+
}
205+
} catch (err) {
206+
console.error(`Failed to fetch volume storages for node ${n.node}:`, err.message);
207+
}
208+
196209
return {
197210
name: n.node,
198211
ipv4Address,
@@ -201,6 +214,7 @@ router.post('/import', async (req, res) => {
201214
secret,
202215
tlsVerify: tlsVerify === '' || tlsVerify === null ? null : tlsVerify === 'true',
203216
imageStorage,
217+
volumeStorage,
204218
siteId
205219
};
206220
}));
@@ -249,15 +263,16 @@ router.put('/:id', async (req, res) => {
249263
return res.redirect(`/sites/${siteId}/nodes`);
250264
}
251265

252-
const { name, ipv4Address, apiUrl, tokenId, secret, tlsVerify, imageStorage } = req.body;
266+
const { name, ipv4Address, apiUrl, tokenId, secret, tlsVerify, imageStorage, volumeStorage } = req.body;
253267

254268
const updateData = {
255269
name,
256270
ipv4Address: ipv4Address || null,
257271
apiUrl: apiUrl || null,
258272
tokenId: tokenId || null,
259273
tlsVerify: tlsVerify === '' || tlsVerify === null ? null : tlsVerify === 'true',
260-
imageStorage: imageStorage || 'local'
274+
imageStorage: imageStorage || 'local',
275+
volumeStorage: volumeStorage || 'local-lvm'
261276
};
262277

263278
// Only update secret if a new value was provided

create-a-container/views/nodes/form.ejs

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,22 @@
114114
<div class="form-text">Storage for CT Template images used when building containers</div>
115115
</div>
116116

117+
<div class="mb-3">
118+
<label for="volumeStorage" class="form-label">Volume Storage</label>
119+
<input
120+
type="text"
121+
class="form-control"
122+
id="volumeStorage"
123+
name="volumeStorage"
124+
value="<%= node && node.volumeStorage ? node.volumeStorage : 'local-lvm' %>"
125+
placeholder="e.g., local-lvm"
126+
list="volumeStorageList"
127+
autocomplete="off"
128+
>
129+
<datalist id="volumeStorageList"></datalist>
130+
<div class="form-text">Storage for container root filesystems</div>
131+
</div>
132+
117133
<div class="d-flex justify-content-between">
118134
<a href="/sites/<%= site.id %>/nodes" class="btn btn-secondary" aria-label="Cancel and return to nodes list">Cancel</a>
119135
<button type="submit" class="btn btn-primary">
@@ -135,11 +151,13 @@
135151
fetch('/sites/<%= site.id %>/nodes/<%= node.id %>/storages')
136152
.then(res => res.json())
137153
.then(storages => {
138-
const datalist = document.getElementById('imageStorageList');
154+
const imageDatalist = document.getElementById('imageStorageList');
155+
const volumeDatalist = document.getElementById('volumeStorageList');
139156
storages.forEach(s => {
140157
const option = document.createElement('option');
141158
option.value = s.name;
142-
datalist.appendChild(option);
159+
imageDatalist.appendChild(option);
160+
volumeDatalist.appendChild(option.cloneNode(true));
143161
});
144162
})
145163
.catch(() => {});

mie-opensource-landing/docs/admins/core-concepts/nodes.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ Nodes are Proxmox VE servers within a site that host containers.
1313
- **API URL**: e.g., `https://192.168.1.10:8006/api2/json`
1414
- **Authentication**: Username/password or API token
1515
- **TLS Verification**: Enable/disable certificate validation
16+
- **Template Storage**: Proxmox storage for CT template images (`vztmpl` content)
17+
- **Volume Storage**: Proxmox storage for container root filesystems (`rootdir` content)
1618

1719
## Adding Nodes
1820

@@ -57,3 +59,16 @@ Proxmox uses self-signed certificates by default. Either disable TLS verificatio
5759
- **Delete**: Remove a node (must have no active containers first)
5860
- **Multi-node**: Proxmox supports HA features — see [Proxmox HA docs](https://pve.proxmox.com/wiki/High_Availability)
5961

62+
## Storage Configuration
63+
64+
Nodes have two storage settings because most Proxmox storage types only support one content type.
65+
66+
| Setting | Content Type | Default | Purpose |
67+
|---|---|---|---|
68+
| **Template Storage** | `vztmpl` | `local` | Stores pulled Docker/OCI images as CT templates |
69+
| **Volume Storage** | `rootdir` | `local-lvm` | Stores container root filesystems |
70+
71+
If a configured storage does not support the required content type, the system falls back to the largest enabled storage on the node that does. If no storage supports the required content type, container creation fails.
72+
73+
To verify storage content types in Proxmox, navigate to **Datacenter → Storage** and check the **Content** column.
74+

mie-opensource-landing/docs/developers/database-schema.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ erDiagram
4545
string apiTokenSecretOrPassword
4646
boolean disableTlsVerification
4747
string imageStorage "default: local"
48+
string volumeStorage "default: local-lvm"
4849
int siteId FK
4950
}
5051
@@ -170,7 +171,7 @@ erDiagram
170171
Top-level organizational unit. Has many Nodes and ExternalDomains.
171172

172173
### Node
173-
Proxmox VE server within a site. `name` must match Proxmox hostname (unique). `imageStorage` defaults to `'local'`. Belongs to Site, has many Containers.
174+
Proxmox VE server within a site. `name` must match Proxmox hostname (unique). `imageStorage` defaults to `'local'` (CT templates). `volumeStorage` defaults to `'local-lvm'` (container rootfs). Belongs to Site, has many Containers.
174175

175176
### Container
176177
LXC container on a Proxmox node. Unique composite index on `(nodeId, containerId)`. `hostname`, `macAddress`, `ipv4Address` globally unique. Belongs to Node and optionally to a Job.

0 commit comments

Comments
 (0)