Compare commits
2 Commits
wip/app-de
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 88206ff215 | |||
| 54196a2d4f |
@@ -158,7 +158,6 @@ const APP_TEMPLATES = {
|
|||||||
healthCheck: "/api/v3/system/status",
|
healthCheck: "/api/v3/system/status",
|
||||||
subpathSupport: 'native',
|
subpathSupport: 'native',
|
||||||
urlBaseEnv: 'URL_BASE',
|
urlBaseEnv: 'URL_BASE',
|
||||||
dependsOn: ["prowlarr", "qbittorrent"],
|
|
||||||
setupInstructions: [
|
setupInstructions: [
|
||||||
"Configure download clients (qBittorrent, etc.)",
|
"Configure download clients (qBittorrent, etc.)",
|
||||||
"Add indexers for content discovery",
|
"Add indexers for content discovery",
|
||||||
@@ -191,8 +190,7 @@ const APP_TEMPLATES = {
|
|||||||
defaultPort: 7878,
|
defaultPort: 7878,
|
||||||
healthCheck: "/api/v3/system/status",
|
healthCheck: "/api/v3/system/status",
|
||||||
subpathSupport: 'native',
|
subpathSupport: 'native',
|
||||||
urlBaseEnv: 'URL_BASE',
|
urlBaseEnv: 'URL_BASE'
|
||||||
dependsOn: ["prowlarr", "qbittorrent"]
|
|
||||||
},
|
},
|
||||||
|
|
||||||
"prowlarr": {
|
"prowlarr": {
|
||||||
@@ -1174,7 +1172,6 @@ const APP_TEMPLATES = {
|
|||||||
healthCheck: "/api/v1/system/status",
|
healthCheck: "/api/v1/system/status",
|
||||||
subpathSupport: 'native',
|
subpathSupport: 'native',
|
||||||
urlBaseEnv: 'URL_BASE',
|
urlBaseEnv: 'URL_BASE',
|
||||||
dependsOn: ["prowlarr", "qbittorrent"],
|
|
||||||
setupInstructions: [
|
setupInstructions: [
|
||||||
"Configure download clients",
|
"Configure download clients",
|
||||||
"Add indexers",
|
"Add indexers",
|
||||||
@@ -1208,7 +1205,6 @@ const APP_TEMPLATES = {
|
|||||||
healthCheck: "/api/v1/system/status",
|
healthCheck: "/api/v1/system/status",
|
||||||
subpathSupport: 'native',
|
subpathSupport: 'native',
|
||||||
urlBaseEnv: 'URL_BASE',
|
urlBaseEnv: 'URL_BASE',
|
||||||
dependsOn: ["prowlarr", "qbittorrent"],
|
|
||||||
setupInstructions: [
|
setupInstructions: [
|
||||||
"Configure download clients",
|
"Configure download clients",
|
||||||
"Add indexers for books",
|
"Add indexers for books",
|
||||||
@@ -1242,7 +1238,6 @@ const APP_TEMPLATES = {
|
|||||||
healthCheck: "/",
|
healthCheck: "/",
|
||||||
subpathSupport: 'native',
|
subpathSupport: 'native',
|
||||||
urlBaseEnv: 'BASE_URL',
|
urlBaseEnv: 'BASE_URL',
|
||||||
dependsOn: ["sonarr", "radarr"],
|
|
||||||
setupInstructions: [
|
setupInstructions: [
|
||||||
"Connect to Sonarr and Radarr",
|
"Connect to Sonarr and Radarr",
|
||||||
"Configure subtitle providers",
|
"Configure subtitle providers",
|
||||||
@@ -1271,7 +1266,6 @@ const APP_TEMPLATES = {
|
|||||||
healthCheck: "/api/v1/status",
|
healthCheck: "/api/v1/status",
|
||||||
subpathSupport: 'native',
|
subpathSupport: 'native',
|
||||||
urlBaseEnv: 'BASE_PATH',
|
urlBaseEnv: 'BASE_PATH',
|
||||||
dependsOn: ["sonarr", "radarr"],
|
|
||||||
setupInstructions: [
|
setupInstructions: [
|
||||||
"Connect to Plex, Jellyfin, or Emby server",
|
"Connect to Plex, Jellyfin, or Emby server",
|
||||||
"Link Sonarr and Radarr",
|
"Link Sonarr and Radarr",
|
||||||
@@ -1301,7 +1295,6 @@ const APP_TEMPLATES = {
|
|||||||
healthCheck: "/",
|
healthCheck: "/",
|
||||||
subpathSupport: 'native',
|
subpathSupport: 'native',
|
||||||
urlBaseEnv: 'TAUTULLI_HTTP_ROOT',
|
urlBaseEnv: 'TAUTULLI_HTTP_ROOT',
|
||||||
dependsOn: ["plex"],
|
|
||||||
setupInstructions: [
|
setupInstructions: [
|
||||||
"Connect to Plex server",
|
"Connect to Plex server",
|
||||||
"Configure notifications",
|
"Configure notifications",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dashcaddy-api",
|
"name": "dashcaddy-api",
|
||||||
"version": "1.3.1",
|
"version": "1.4.0",
|
||||||
"description": "DashCaddy API server - Dashboard backend for Docker, Caddy & DNS management",
|
"description": "DashCaddy API server - Dashboard backend for Docker, Caddy & DNS management",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -226,198 +226,6 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check which template-declared dependencies are missing from running services.
|
|
||||||
* Returns null if the template has no dependsOn field.
|
|
||||||
* @param {Object} template - App template with optional `dependsOn: string[]`
|
|
||||||
* @param {Array} runningServices - Array of services from services.json
|
|
||||||
* @returns {{ dependsOn: string[], missing: string[], satisfied: string[] } | null}
|
|
||||||
*/
|
|
||||||
function checkDependencies(template, runningServices) {
|
|
||||||
if (!template || !Array.isArray(template.dependsOn) || template.dependsOn.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const deployedTemplateIds = new Set(
|
|
||||||
(runningServices || [])
|
|
||||||
.map(s => s.appTemplate || s.deploymentManifest?.templateId)
|
|
||||||
.filter(Boolean)
|
|
||||||
);
|
|
||||||
const satisfied = [];
|
|
||||||
const missing = [];
|
|
||||||
for (const depId of template.dependsOn) {
|
|
||||||
if (deployedTemplateIds.has(depId)) satisfied.push(depId);
|
|
||||||
else missing.push(depId);
|
|
||||||
}
|
|
||||||
return { dependsOn: [...template.dependsOn], missing, satisfied };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Topologically sort a list of template IDs by their dependsOn relationships.
|
|
||||||
* Dependencies come before dependents. Skips IDs not present in templates.
|
|
||||||
* Detects cycles and breaks them by emitting in DFS finish order.
|
|
||||||
* @param {string[]} ids
|
|
||||||
* @returns {string[]}
|
|
||||||
*/
|
|
||||||
function topologicalSortTemplates(ids) {
|
|
||||||
const idSet = new Set(ids);
|
|
||||||
const visited = new Set();
|
|
||||||
const visiting = new Set();
|
|
||||||
const result = [];
|
|
||||||
|
|
||||||
function visit(id) {
|
|
||||||
if (visited.has(id)) return;
|
|
||||||
if (visiting.has(id)) return; // cycle — break here
|
|
||||||
visiting.add(id);
|
|
||||||
const tpl = ctx.APP_TEMPLATES[id];
|
|
||||||
if (tpl && Array.isArray(tpl.dependsOn)) {
|
|
||||||
for (const dep of tpl.dependsOn) {
|
|
||||||
if (idSet.has(dep)) visit(dep);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
visiting.delete(id);
|
|
||||||
visited.add(id);
|
|
||||||
result.push(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const id of ids) visit(id);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a default deploy config for a dependency template.
|
|
||||||
* Uses the template's defaults: subdomain, port, IP from siteConfig.
|
|
||||||
*/
|
|
||||||
function buildDefaultDepConfig(template) {
|
|
||||||
return {
|
|
||||||
subdomain: template.subdomain,
|
|
||||||
port: template.defaultPort,
|
|
||||||
ip: ctx.siteConfig.dnsServerIp || 'localhost',
|
|
||||||
createDns: true,
|
|
||||||
tailscaleOnly: false,
|
|
||||||
allowedIPs: []
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deploy a list of dependency apps in topological order, waiting for each
|
|
||||||
* to become healthy before continuing. Skips deps already running.
|
|
||||||
* Throws on first failure.
|
|
||||||
*/
|
|
||||||
async function deployDependencyChain(depIds) {
|
|
||||||
const ordered = topologicalSortTemplates(depIds);
|
|
||||||
const services = await servicesStateManager.read();
|
|
||||||
const deployedTemplateIds = new Set(
|
|
||||||
services.map(s => s.appTemplate || s.deploymentManifest?.templateId).filter(Boolean)
|
|
||||||
);
|
|
||||||
|
|
||||||
const deployed = [];
|
|
||||||
for (const depId of ordered) {
|
|
||||||
if (deployedTemplateIds.has(depId)) {
|
|
||||||
log.info('deploy', 'Dependency already running, skipping', { depId });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const depTemplate = ctx.APP_TEMPLATES[depId];
|
|
||||||
if (!depTemplate) {
|
|
||||||
throw new Error(`[DC-205] Dependency "${depId}" has no template`);
|
|
||||||
}
|
|
||||||
const depConfig = buildDefaultDepConfig(depTemplate);
|
|
||||||
log.info('deploy', 'Deploying dependency', { depId, subdomain: depConfig.subdomain });
|
|
||||||
|
|
||||||
const containerId = await deployContainer(depId, depConfig, depTemplate);
|
|
||||||
await helpers.waitForHealthCheck(containerId, depTemplate.healthCheck, depConfig.port);
|
|
||||||
log.info('deploy', 'Dependency healthy', { depId, containerId });
|
|
||||||
|
|
||||||
// Generate Caddy config + DNS for the dep so it's actually reachable
|
|
||||||
const caddyOptions = {
|
|
||||||
tailscaleOnly: false,
|
|
||||||
allowedIPs: [],
|
|
||||||
subpathSupport: depTemplate.subpathSupport || 'strip'
|
|
||||||
};
|
|
||||||
const isSubdirectoryMode = ctx.siteConfig.routingMode === 'subdirectory' && ctx.siteConfig.domain;
|
|
||||||
const depCaddyConfig = caddy.generateConfig(depConfig.subdomain, depConfig.ip, depConfig.port, caddyOptions);
|
|
||||||
if (isSubdirectoryMode) {
|
|
||||||
await helpers.ensureMainDomainBlock();
|
|
||||||
await helpers.addSubpathConfig(depConfig.subdomain, depCaddyConfig);
|
|
||||||
} else {
|
|
||||||
await helpers.addCaddyConfig(depConfig.subdomain, depCaddyConfig);
|
|
||||||
}
|
|
||||||
if (!isSubdirectoryMode) {
|
|
||||||
try {
|
|
||||||
await ctx.dns.createRecord(depConfig.subdomain, depConfig.ip);
|
|
||||||
} catch (dnsErr) {
|
|
||||||
log.warn('deploy', 'DNS creation failed for dependency', { depId, error: dnsErr.message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build a minimal manifest for the dep so it's restorable later
|
|
||||||
const processedDep = helpers.processTemplateVariables(depTemplate, depConfig);
|
|
||||||
const depManifest = {
|
|
||||||
templateId: depId,
|
|
||||||
config: {
|
|
||||||
subdomain: depConfig.subdomain,
|
|
||||||
port: depConfig.port,
|
|
||||||
ip: depConfig.ip,
|
|
||||||
createDns: depConfig.createDns,
|
|
||||||
tailscaleOnly: false,
|
|
||||||
allowedIPs: [],
|
|
||||||
useExisting: false
|
|
||||||
},
|
|
||||||
container: {
|
|
||||||
image: processedDep.docker.image,
|
|
||||||
ports: processedDep.docker.ports,
|
|
||||||
volumes: processedDep.docker.volumes || [],
|
|
||||||
environment: { ...processedDep.docker.environment },
|
|
||||||
capabilities: processedDep.docker.capabilities || undefined
|
|
||||||
},
|
|
||||||
caddy: {
|
|
||||||
tailscaleOnly: false,
|
|
||||||
allowedIPs: [],
|
|
||||||
subpathSupport: depTemplate.subpathSupport || 'strip',
|
|
||||||
routingMode: ctx.siteConfig.routingMode
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
await ctx.addServiceToConfig({
|
|
||||||
id: depConfig.subdomain,
|
|
||||||
name: depTemplate.name,
|
|
||||||
logo: depTemplate.logo || `/assets/${depId}.png`,
|
|
||||||
url: ctx.buildServiceUrl(depConfig.subdomain),
|
|
||||||
containerId,
|
|
||||||
appTemplate: depId,
|
|
||||||
tailscaleOnly: false,
|
|
||||||
routingMode: ctx.siteConfig.routingMode,
|
|
||||||
deployedAt: new Date().toISOString(),
|
|
||||||
deploymentManifest: depManifest
|
|
||||||
});
|
|
||||||
|
|
||||||
deployed.push({ depId, containerId, subdomain: depConfig.subdomain });
|
|
||||||
}
|
|
||||||
return deployed;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check dependencies for an app (frontend calls this before opening deploy modal)
|
|
||||||
router.post('/apps/check-dependencies', asyncHandler(async (req, res) => {
|
|
||||||
const { appId } = req.body;
|
|
||||||
if (!appId || typeof appId !== 'string') {
|
|
||||||
throw new ValidationError('appId is required');
|
|
||||||
}
|
|
||||||
const template = ctx.APP_TEMPLATES[appId];
|
|
||||||
if (!template) throw new ValidationError('Invalid app template');
|
|
||||||
|
|
||||||
const services = await servicesStateManager.read();
|
|
||||||
const result = checkDependencies(template, services);
|
|
||||||
if (!result) {
|
|
||||||
return res.json({ appId, dependsOn: [], missing: [], satisfied: [] });
|
|
||||||
}
|
|
||||||
// Enrich missing deps with friendly names for the UI
|
|
||||||
const missingDetails = result.missing.map(id => ({
|
|
||||||
id,
|
|
||||||
name: ctx.APP_TEMPLATES[id]?.name || id,
|
|
||||||
icon: ctx.APP_TEMPLATES[id]?.icon || '📦'
|
|
||||||
}));
|
|
||||||
res.json({ appId, ...result, missingDetails });
|
|
||||||
}, 'apps-check-dependencies'));
|
|
||||||
|
|
||||||
// Check for existing container before deployment
|
// Check for existing container before deployment
|
||||||
router.post('/apps/check-existing', asyncHandler(async (req, res) => {
|
router.post('/apps/check-existing', asyncHandler(async (req, res) => {
|
||||||
const { appId } = req.body;
|
const { appId } = req.body;
|
||||||
@@ -433,7 +241,7 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag
|
|||||||
|
|
||||||
// Deploy new app
|
// Deploy new app
|
||||||
router.post('/apps/deploy', asyncHandler(async (req, res) => {
|
router.post('/apps/deploy', asyncHandler(async (req, res) => {
|
||||||
const { appId, config, deployDependencies } = req.body;
|
const { appId, config } = req.body;
|
||||||
if (!appId || typeof appId !== 'string') {
|
if (!appId || typeof appId !== 'string') {
|
||||||
throw new ValidationError('appId is required');
|
throw new ValidationError('appId is required');
|
||||||
}
|
}
|
||||||
@@ -443,7 +251,6 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag
|
|||||||
if (!config.subdomain || typeof config.subdomain !== 'string') {
|
if (!config.subdomain || typeof config.subdomain !== 'string') {
|
||||||
throw new ValidationError('config.subdomain is required');
|
throw new ValidationError('config.subdomain is required');
|
||||||
}
|
}
|
||||||
let deployedDeps = [];
|
|
||||||
try {
|
try {
|
||||||
log.info('deploy', 'Deploying app', { appId, subdomain: config.subdomain });
|
log.info('deploy', 'Deploying app', { appId, subdomain: config.subdomain });
|
||||||
const template = ctx.APP_TEMPLATES[appId];
|
const template = ctx.APP_TEMPLATES[appId];
|
||||||
@@ -452,13 +259,6 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag
|
|||||||
throw new ValidationError('Invalid app template');
|
throw new ValidationError('Invalid app template');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deploy declared dependencies first if requested
|
|
||||||
if (Array.isArray(deployDependencies) && deployDependencies.length > 0) {
|
|
||||||
log.info('deploy', 'Deploying dependencies first', { appId, deps: deployDependencies });
|
|
||||||
deployedDeps = await deployDependencyChain(deployDependencies);
|
|
||||||
log.info('deploy', 'Dependencies deployed', { count: deployedDeps.length });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.subdomain) {
|
if (config.subdomain) {
|
||||||
if (!REGEX.SUBDOMAIN.test(config.subdomain)) {
|
if (!REGEX.SUBDOMAIN.test(config.subdomain)) {
|
||||||
throw new ValidationError('[DC-301] Invalid subdomain format');
|
throw new ValidationError('[DC-301] Invalid subdomain format');
|
||||||
@@ -609,8 +409,7 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag
|
|||||||
success: true, containerId, usedExisting,
|
success: true, containerId, usedExisting,
|
||||||
url: serviceUrl,
|
url: serviceUrl,
|
||||||
message: usedExisting ? `${template.name} configured using existing container!` : `${template.name} deployed successfully!`,
|
message: usedExisting ? `${template.name} configured using existing container!` : `${template.name} deployed successfully!`,
|
||||||
setupInstructions: template.setupInstructions || [],
|
setupInstructions: template.setupInstructions || []
|
||||||
deployedDependencies: deployedDeps
|
|
||||||
};
|
};
|
||||||
if (dnsWarning) response.warning = dnsWarning;
|
if (dnsWarning) response.warning = dnsWarning;
|
||||||
|
|
||||||
|
|||||||
@@ -70,9 +70,6 @@ module.exports = function({ docker, credentialManager: _credentialManager, servi
|
|||||||
const deployedComponents = [];
|
const deployedComponents = [];
|
||||||
const errors = [];
|
const errors = [];
|
||||||
|
|
||||||
// Lazy-load apps helpers once for health waits between components
|
|
||||||
const appsHelpers = require('../apps/helpers')(ctx);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (const component of componentsToDeploy) {
|
for (const component of componentsToDeploy) {
|
||||||
try {
|
try {
|
||||||
@@ -87,18 +84,6 @@ module.exports = function({ docker, credentialManager: _credentialManager, servi
|
|||||||
log.info('recipe', `Component deployed: ${component.id}`, {
|
log.info('recipe', `Component deployed: ${component.id}`, {
|
||||||
containerId: result.containerId?.substring(0, 12)
|
containerId: result.containerId?.substring(0, 12)
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wait for the component to become healthy before deploying the next one
|
|
||||||
// (so dependent components — Sonarr after Prowlarr — see a working dep)
|
|
||||||
if (result.containerId && result.healthPort) {
|
|
||||||
try {
|
|
||||||
await appsHelpers.waitForHealthCheck(result.containerId, result.healthPath, result.healthPort);
|
|
||||||
log.info('recipe', `Component healthy: ${component.id}`);
|
|
||||||
} catch (healthErr) {
|
|
||||||
log.warn('recipe', `Component health wait failed: ${component.id}`, { error: healthErr.message });
|
|
||||||
// Don't abort the recipe — continue with the next component
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (componentError) {
|
} catch (componentError) {
|
||||||
log.error('recipe', `Component failed: ${component.id}`, {
|
log.error('recipe', `Component failed: ${component.id}`, {
|
||||||
error: componentError.message
|
error: componentError.message
|
||||||
@@ -364,17 +349,6 @@ module.exports = function({ docker, credentialManager: _credentialManager, servi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve health check path + port for the waiter
|
|
||||||
let healthPath = null;
|
|
||||||
let healthPort = null;
|
|
||||||
if (component.templateRef) {
|
|
||||||
const tpl = ctx.APP_TEMPLATES[component.templateRef];
|
|
||||||
healthPath = tpl?.healthCheck || null;
|
|
||||||
healthPort = port || tpl?.defaultPort || null;
|
|
||||||
} else if (dockerConfig.ports?.length > 0) {
|
|
||||||
healthPort = port || dockerConfig.ports[0].split(/[:/]/)[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: component.id,
|
id: component.id,
|
||||||
role: component.role,
|
role: component.role,
|
||||||
@@ -384,9 +358,7 @@ module.exports = function({ docker, credentialManager: _credentialManager, servi
|
|||||||
internal: component.internal || false,
|
internal: component.internal || false,
|
||||||
templateRef: component.templateRef,
|
templateRef: component.templateRef,
|
||||||
logo,
|
logo,
|
||||||
url,
|
url
|
||||||
healthPath,
|
|
||||||
healthPort
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
133
scripts/release.sh
Normal file
133
scripts/release.sh
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Cut a DashCaddy release: bump dashcaddy-api/package.json, commit, push,
|
||||||
|
# build tarball + version.json on DNS2 (the get.dashcaddy.net publishing
|
||||||
|
# host), refresh install.sh, then mirror everything to the dc-contabo-de
|
||||||
|
# get2 backup.
|
||||||
|
#
|
||||||
|
# Usage: scripts/release.sh <version>
|
||||||
|
# Example: scripts/release.sh 1.4.0
|
||||||
|
#
|
||||||
|
# Pre-flight: must be on `main`, working tree clean, gitea remote reachable.
|
||||||
|
#
|
||||||
|
# Hosts/URLs are overridable via env:
|
||||||
|
# DASHCADDY_RELEASE_HOST default root@100.104.4.5 (DNS2, hosts get.dashcaddy.net)
|
||||||
|
# DASHCADDY_MIRROR_HOST default root@dc-contabo-de (hosts get2.dashcaddy.net)
|
||||||
|
# DASHCADDY_GITEA_URL default http://100.98.123.59:3000/sami7777/dashcaddy.git
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
VERSION="${1:-}"
|
||||||
|
[[ -z "$VERSION" ]] && { echo "Usage: $0 <version>" >&2; exit 1; }
|
||||||
|
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "Invalid version (need X.Y.Z): $VERSION" >&2; exit 1; }
|
||||||
|
|
||||||
|
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
||||||
|
RELEASE_HOST="${DASHCADDY_RELEASE_HOST:-root@100.104.4.5}"
|
||||||
|
MIRROR_HOST="${DASHCADDY_MIRROR_HOST:-root@dc-contabo-de}"
|
||||||
|
GITEA_URL="${DASHCADDY_GITEA_URL:-http://100.98.123.59:3000/sami7777/dashcaddy.git}"
|
||||||
|
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
|
# ── Pre-flight ─────────────────────────────────────────────────────────────
|
||||||
|
[[ -f dashcaddy-api/package.json ]] || { echo "Run from dashcaddy repo root (no dashcaddy-api/package.json)" >&2; exit 1; }
|
||||||
|
[[ -n "$(git status --porcelain)" ]] && { echo "Working tree must be clean" >&2; exit 1; }
|
||||||
|
BRANCH="$(git rev-parse --abbrev-ref HEAD)"
|
||||||
|
[[ "$BRANCH" == "main" ]] || { echo "Must be on main (current: $BRANCH)" >&2; exit 1; }
|
||||||
|
|
||||||
|
CURRENT="$(node -p "require('./dashcaddy-api/package.json').version")"
|
||||||
|
[[ "$CURRENT" == "$VERSION" ]] && { echo "package.json already at $VERSION — nothing to do" >&2; exit 1; }
|
||||||
|
|
||||||
|
echo "─── Cutting release ───"
|
||||||
|
echo " current: $CURRENT"
|
||||||
|
echo " target: $VERSION"
|
||||||
|
echo " release: $RELEASE_HOST"
|
||||||
|
echo " mirror: $MIRROR_HOST"
|
||||||
|
echo
|
||||||
|
|
||||||
|
# ── 1. Bump dashcaddy-api/package.json ────────────────────────────────────
|
||||||
|
echo "[1/6] Bumping dashcaddy-api/package.json"
|
||||||
|
node -e "
|
||||||
|
const fs = require('fs');
|
||||||
|
const pkg = require('./dashcaddy-api/package.json');
|
||||||
|
pkg.version = '$VERSION';
|
||||||
|
fs.writeFileSync('./dashcaddy-api/package.json', JSON.stringify(pkg, null, 2) + '\n');
|
||||||
|
"
|
||||||
|
|
||||||
|
# ── 2. Rebuild status frontend so dist/*.js matches source ────────────────
|
||||||
|
if [[ -f status/build.js ]]; then
|
||||||
|
echo "[2/6] Rebuilding status frontend"
|
||||||
|
(cd status && node build.js >/dev/null)
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 3. Commit + push ──────────────────────────────────────────────────────
|
||||||
|
echo "[3/6] Committing + pushing"
|
||||||
|
git add dashcaddy-api/package.json
|
||||||
|
# status/dist/ is .gitignored but its tracked files still need re-staging on rebuild.
|
||||||
|
# -f bypasses the ignore for these specific paths.
|
||||||
|
[[ -d status/dist ]] && git add -f status/dist/ 2>/dev/null || true
|
||||||
|
git commit -m "chore(release): bump to $VERSION" >/dev/null
|
||||||
|
git push gitea main >/dev/null
|
||||||
|
COMMIT="$(git rev-parse --short HEAD)"
|
||||||
|
echo " → committed: $COMMIT"
|
||||||
|
|
||||||
|
# ── 4. Build tarball on the publishing host ───────────────────────────────
|
||||||
|
echo "[4/6] Building tarball on $RELEASE_HOST"
|
||||||
|
ssh "$RELEASE_HOST" "set -e
|
||||||
|
rm -rf /tmp/dashcaddy-build
|
||||||
|
mkdir -p /tmp/dashcaddy-build
|
||||||
|
cd /tmp/dashcaddy-build
|
||||||
|
git clone --depth 1 '$GITEA_URL' dashcaddy >/dev/null 2>&1
|
||||||
|
cd dashcaddy
|
||||||
|
ACTUAL_COMMIT=\$(git rev-parse --short HEAD)
|
||||||
|
if [ \"\$ACTUAL_COMMIT\" != \"$COMMIT\" ]; then
|
||||||
|
echo \" ! cloned commit \$ACTUAL_COMMIT does not match expected $COMMIT — aborting\" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
rm -rf .git
|
||||||
|
find . -type d -name node_modules -exec rm -rf {} + 2>/dev/null || true
|
||||||
|
cd /tmp/dashcaddy-build
|
||||||
|
tar zcf dashcaddy-$VERSION.tar.gz dashcaddy/
|
||||||
|
"
|
||||||
|
echo " → built /tmp/dashcaddy-build/dashcaddy-$VERSION.tar.gz"
|
||||||
|
|
||||||
|
# ── 5. Publish on the release host ────────────────────────────────────────
|
||||||
|
echo "[5/6] Publishing v$VERSION + refreshed install.sh on $RELEASE_HOST"
|
||||||
|
ssh "$RELEASE_HOST" "set -e
|
||||||
|
cd /var/www/get.dashcaddy.net
|
||||||
|
cp -a release release.backup-\$(date -u +%Y%m%d-%H%M%S)
|
||||||
|
cp /tmp/dashcaddy-build/dashcaddy-$VERSION.tar.gz release/
|
||||||
|
cp /tmp/dashcaddy-build/dashcaddy-$VERSION.tar.gz release/latest.tar.gz
|
||||||
|
( cd release && sha256sum latest.tar.gz > latest.tar.gz.sha256 )
|
||||||
|
cp /tmp/dashcaddy-build/dashcaddy/dashcaddy-installer/install.sh release/install.sh
|
||||||
|
chmod +x release/install.sh
|
||||||
|
SHA256=\$(sha256sum release/dashcaddy-$VERSION.tar.gz | cut -d' ' -f1)
|
||||||
|
cat > release/version.json <<EOF
|
||||||
|
{
|
||||||
|
\"version\": \"$VERSION\",
|
||||||
|
\"commit\": \"$COMMIT\",
|
||||||
|
\"date\": \"\$(date -u +%Y-%m-%dT%H:%M:%SZ)\",
|
||||||
|
\"sha256\": \"\$SHA256\",
|
||||||
|
\"changelog\": \"$COMMIT chore(release): bump to $VERSION\",
|
||||||
|
\"breaking\": false,
|
||||||
|
\"tarball\": \"dashcaddy-$VERSION.tar.gz\"
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
"
|
||||||
|
|
||||||
|
# ── 6. Mirror to backup host ──────────────────────────────────────────────
|
||||||
|
echo "[6/6] Mirroring to $MIRROR_HOST"
|
||||||
|
ssh "$RELEASE_HOST" "rsync -aq --delete /var/www/get.dashcaddy.net/release/ $MIRROR_HOST:/var/www/get2.dashcaddy.net/release/"
|
||||||
|
|
||||||
|
# ── Verify ───────────────────────────────────────────────────────────────
|
||||||
|
echo
|
||||||
|
echo "─── Verifying live ───"
|
||||||
|
SERVED_VER="$(curl -fsSL --max-time 5 https://get.dashcaddy.net/release/version.json | node -p "JSON.parse(require('fs').readFileSync('/dev/stdin')).version")"
|
||||||
|
[[ "$SERVED_VER" == "$VERSION" ]] || { echo "MISMATCH: get.dashcaddy.net serves $SERVED_VER, expected $VERSION" >&2; exit 1; }
|
||||||
|
echo " get.dashcaddy.net → $SERVED_VER ✓"
|
||||||
|
|
||||||
|
SHA_LOCAL="$(ssh "$RELEASE_HOST" "sha256sum /var/www/get.dashcaddy.net/release/dashcaddy-$VERSION.tar.gz | cut -d' ' -f1")"
|
||||||
|
SHA_HTTP="$(curl -fsSL --max-time 30 "https://get.dashcaddy.net/release/dashcaddy-$VERSION.tar.gz" | sha256sum | cut -d' ' -f1)"
|
||||||
|
[[ "$SHA_LOCAL" == "$SHA_HTTP" ]] || { echo "SHA mismatch on served tarball" >&2; exit 1; }
|
||||||
|
echo " tarball sha256 → $SHA_HTTP ✓"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Done. v$VERSION published from commit $COMMIT."
|
||||||
@@ -10,22 +10,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>`);
|
</div>`);
|
||||||
|
|
||||||
injectModal('dep-warning-modal', `<div id="dep-warning-modal" class="weather-modal">
|
|
||||||
<div class="weather-modal-content" style="min-width: 460px; max-width: 600px;">
|
|
||||||
<h3 id="dep-warning-title">Missing Dependencies</h3>
|
|
||||||
<p id="dep-warning-subtitle" class="modal-subtitle"></p>
|
|
||||||
<div id="dep-warning-list" style="display: flex; flex-direction: column; gap: 8px; margin: 16px 0;"></div>
|
|
||||||
<p style="font-size: 0.82rem; color: var(--muted); margin-top: 8px;">
|
|
||||||
Recommended: deploy these dependencies first so the app works correctly on first launch.
|
|
||||||
</p>
|
|
||||||
<div class="weather-modal-buttons" style="margin-top: 20px;">
|
|
||||||
<button id="dep-warning-cancel">Cancel</button>
|
|
||||||
<button id="dep-warning-skip">Deploy anyway</button>
|
|
||||||
<button id="dep-warning-confirm" class="btn-accent">Deploy with dependencies</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>`);
|
|
||||||
|
|
||||||
injectModal('app-deploy-modal', `<div id="app-deploy-modal" class="weather-modal">
|
injectModal('app-deploy-modal', `<div id="app-deploy-modal" class="weather-modal">
|
||||||
<div class="weather-modal-content" style="min-width: 600px; max-width: 700px;">
|
<div class="weather-modal-content" style="min-width: 600px; max-width: 700px;">
|
||||||
<h3 id="app-deploy-title">Deploy Application</h3>
|
<h3 id="app-deploy-title">Deploy Application</h3>
|
||||||
@@ -354,7 +338,7 @@
|
|||||||
if (isWidget) {
|
if (isWidget) {
|
||||||
option.onclick = () => toggleDashboardWidget(app, option);
|
option.onclick = () => toggleDashboardWidget(app, option);
|
||||||
} else {
|
} else {
|
||||||
option.onclick = () => checkAndShowDeployConfig(app);
|
option.onclick = () => showDeployConfig(app);
|
||||||
}
|
}
|
||||||
grid.appendChild(option);
|
grid.appendChild(option);
|
||||||
});
|
});
|
||||||
@@ -391,93 +375,6 @@
|
|||||||
showNotification(`${app.name} widget ${newState ? 'enabled' : 'disabled'}`, 'success', 2000);
|
showNotification(`${app.name} widget ${newState ? 'enabled' : 'disabled'}`, 'success', 2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check declared dependencies for an app before opening the deploy modal.
|
|
||||||
// If any are missing, show the dep-warning-modal so the user can pick which to auto-deploy.
|
|
||||||
async function checkAndShowDeployConfig(appTemplate) {
|
|
||||||
// Reset any leftover dep selection from a previous flow
|
|
||||||
delete appTemplate._deployDeps;
|
|
||||||
|
|
||||||
let depResult = null;
|
|
||||||
try {
|
|
||||||
const resp = await secureFetch('/api/v1/apps/check-dependencies', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ appId: appTemplate.id })
|
|
||||||
});
|
|
||||||
depResult = await resp.json();
|
|
||||||
} catch (e) {
|
|
||||||
// Network/server error — fall through to normal deploy flow
|
|
||||||
console.warn('Dep check failed, continuing without it:', e);
|
|
||||||
return showDeployConfig(appTemplate);
|
|
||||||
}
|
|
||||||
|
|
||||||
// No deps declared, or all already deployed
|
|
||||||
if (!depResult || !Array.isArray(depResult.missing) || depResult.missing.length === 0) {
|
|
||||||
return showDeployConfig(appTemplate);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show warning modal
|
|
||||||
const depModal = document.getElementById('dep-warning-modal');
|
|
||||||
const titleEl = document.getElementById('dep-warning-title');
|
|
||||||
const subtitleEl = document.getElementById('dep-warning-subtitle');
|
|
||||||
const listEl = document.getElementById('dep-warning-list');
|
|
||||||
const cancelBtn = document.getElementById('dep-warning-cancel');
|
|
||||||
const skipBtn = document.getElementById('dep-warning-skip');
|
|
||||||
const confirmBtn = document.getElementById('dep-warning-confirm');
|
|
||||||
|
|
||||||
titleEl.textContent = `${appTemplate.name} has missing dependencies`;
|
|
||||||
subtitleEl.textContent = `${appTemplate.name} works best when these apps are deployed first:`;
|
|
||||||
|
|
||||||
// Render checkboxes for each missing dep
|
|
||||||
listEl.innerHTML = depResult.missingDetails.map(d => `
|
|
||||||
<label class="radio-option" style="display: flex; align-items: center; gap: 10px; cursor: pointer;">
|
|
||||||
<input type="checkbox" class="dep-warning-checkbox" value="${escapeHtml(d.id)}" checked style="margin: 0;" />
|
|
||||||
<span style="font-size: 1.2rem;">${escapeHtml(d.icon || '📦')}</span>
|
|
||||||
<div>
|
|
||||||
<div class="fw-500">${escapeHtml(d.name)}</div>
|
|
||||||
<div class="text-hint">Not deployed</div>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
`).join('');
|
|
||||||
|
|
||||||
// Close the app selector behind it
|
|
||||||
modal.classList.remove('show');
|
|
||||||
depModal.classList.add('show');
|
|
||||||
|
|
||||||
// Helper to clean up event listeners after one of the buttons is pressed
|
|
||||||
const cleanup = () => {
|
|
||||||
cancelBtn.onclick = null;
|
|
||||||
skipBtn.onclick = null;
|
|
||||||
confirmBtn.onclick = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
cancelBtn.onclick = () => {
|
|
||||||
cleanup();
|
|
||||||
depModal.classList.remove('show');
|
|
||||||
// Re-open app selector so user can pick something else
|
|
||||||
modal.classList.add('show');
|
|
||||||
};
|
|
||||||
|
|
||||||
skipBtn.onclick = () => {
|
|
||||||
cleanup();
|
|
||||||
depModal.classList.remove('show');
|
|
||||||
// Continue to normal deploy flow without any deps
|
|
||||||
showDeployConfig(appTemplate);
|
|
||||||
};
|
|
||||||
|
|
||||||
confirmBtn.onclick = () => {
|
|
||||||
// Collect checked deps
|
|
||||||
const chosen = Array.from(listEl.querySelectorAll('.dep-warning-checkbox'))
|
|
||||||
.filter(cb => cb.checked)
|
|
||||||
.map(cb => cb.value);
|
|
||||||
cleanup();
|
|
||||||
depModal.classList.remove('show');
|
|
||||||
// Stash chosen deps on the template; addAppToGrid will pick them up
|
|
||||||
if (chosen.length > 0) appTemplate._deployDeps = chosen;
|
|
||||||
showDeployConfig(appTemplate);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show deployment configuration modal
|
// Show deployment configuration modal
|
||||||
async function showDeployConfig(appTemplate) {
|
async function showDeployConfig(appTemplate) {
|
||||||
const deployModal = document.getElementById('app-deploy-modal');
|
const deployModal = document.getElementById('app-deploy-modal');
|
||||||
@@ -868,11 +765,6 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Include user-selected dependencies (from dep warning modal)
|
|
||||||
if (Array.isArray(appTemplate._deployDeps) && appTemplate._deployDeps.length > 0) {
|
|
||||||
apiDeployConfig.deployDependencies = appTemplate._deployDeps;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add existing container info if using existing
|
// Add existing container info if using existing
|
||||||
if (usingExisting) {
|
if (usingExisting) {
|
||||||
apiDeployConfig.config.useExisting = true;
|
apiDeployConfig.config.useExisting = true;
|
||||||
|
|||||||
Reference in New Issue
Block a user