2 Commits
1.2.0 ... BUG-3

Author SHA1 Message Date
fccview
2a437f3db8 Fix script path bug and add support for pinned versions 2025-08-26 13:44:08 +01:00
fccview
47e19246ce update readme and docker compose file. whopsie. 2025-08-26 10:08:10 +01:00
9 changed files with 137 additions and 103 deletions

View File

@@ -2,8 +2,8 @@ name: Docker
on:
push:
branches: ["main", "legacy", "feature/*", "bugfix/*"]
tags: ["v*.*.*"]
branches: ["main", "legacy"]
tags: ["*"]
pull_request:
branches: ["main"]

View File

@@ -49,8 +49,8 @@ If you find my projects helpful and want to fuel my late-night coding sessions w
```bash
services:
cronjob-manager:
image: ghcr.io/fccview/cronmaster:main
container_name: cronmaster-test
image: ghcr.io/fccview/cronmaster:1.2.1
container_name: cronmaster
user: "root"
ports:
# Feel free to change port, 3000 is very common so I like to map it to something else
@@ -59,7 +59,7 @@ services:
- NODE_ENV=production
- DOCKER=true
- NEXT_PUBLIC_CLOCK_UPDATE_INTERVAL=30000
- NEXT_PUBLIC_HOST_PROJECT_DIR=/path/to/cronmaster/directory
- HOST_PROJECT_DIR=/path/to/cronmaster/directory
# If docker struggles to find your crontab user, update this variable with it.
# Obviously replace fccview with your user - find it with: ls -asl /var/spool/cron/crontabs/
# - HOST_CRONTAB_USER=fccview
@@ -69,7 +69,7 @@ services:
# These are needed if you want to keep your data on the host machine and not wihin the docker volume.
# DO NOT change the location of ./scripts as all cronjobs that use custom scripts created via the app
# will target this foler (thanks to the NEXT_PUBLIC_HOST_PROJECT_DIR variable set above)
# will target this foler (thanks to the HOST_PROJECT_DIR variable set above)
- ./scripts:/app/scripts
- ./data:/app/data
- ./snippets:/app/snippets
@@ -82,7 +82,7 @@ services:
init: true
# Default platform is set to amd64, uncomment to use arm64.
#platform: linux/arm64
#platform: linux/arm64
```
### ARM64 Support
@@ -132,7 +132,7 @@ The following environment variables can be configured:
| Variable | Default | Description |
| ----------------------------------- | ------- | ------------------------------------------------------------------------------------------- |
| `NEXT_PUBLIC_CLOCK_UPDATE_INTERVAL` | `30000` | Clock update interval in milliseconds (30 seconds) |
| `NEXT_PUBLIC_HOST_PROJECT_DIR` | `N/A` | Mandatory variable to make sure cron runs on the right path. |
| `HOST_PROJECT_DIR` | `N/A` | Mandatory variable to make sure cron runs on the right path. |
| `DOCKER` | `false` | ONLY set this to true if you are runnign the app via docker, in the docker-compose.yml file |
**Example**: To change the clock update interval to 60 seconds:
@@ -144,14 +144,14 @@ NEXT_PUBLIC_CLOCK_UPDATE_INTERVAL=60000 docker-compose up
**Example**: Your `docker-compose.yml` file or repository are in `~/homelab/cronmaster/`
```bash
NEXT_PUBLIC_HOST_PROJECT_DIR=/home/<your_user_here>/homelab/cronmaster
HOST_PROJECT_DIR=/home/<your_user_here>/homelab/cronmaster
```
### Important Notes for Docker
- Root user is required for cron operations and direct file access. There is no way around this, if you don't feel comfortable in running it as root feel free to run the app locally with `yarn install`, `yarn build` and `yarn start`
- Crontab files are accessed directly via file system mounts at `/host/cron/crontabs` and `/host/crontab` for real-time reading and writing
- `NEXT_PUBLIC_HOST_PROJECT_DIR` is required in order for the scripts created within the app to run properly
- `HOST_PROJECT_DIR` is required in order for the scripts created within the app to run properly
- The `DOCKER=true` environment variable enables direct file access mode for crontab operations. This is REQUIRED when running the application in docker mode.
## Usage
@@ -214,7 +214,7 @@ The application uses standard cron format: `* * * * *`
## Community shouts
I would like to thank the following members for raising issues and help test/debug them!
I would like to thank the following members for raising issues and help test/debug them!
<table>
<tbody>

View File

@@ -58,10 +58,10 @@ export function CreateTaskModal({
loadScriptContent();
}, [selectedScript]);
const handleScriptSelect = (script: Script) => {
const handleScriptSelect = async (script: Script) => {
onFormChange({
selectedScriptId: script.id,
command: getHostScriptPath(script.filename),
command: await getHostScriptPath(script.filename),
});
};

View File

@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useEffect, useState } from "react";
import { Modal } from "../ui/Modal";
import { Button } from "../ui/Button";
import { Input } from "../ui/Input";
@@ -27,6 +27,18 @@ export function SelectScriptModal({
const [searchQuery, setSearchQuery] = useState("");
const [previewScript, setPreviewScript] = useState<Script | null>(null);
const [previewContent, setPreviewContent] = useState<string>("");
const [hostScriptPath, setHostScriptPath] = useState<string>("");
useEffect(() => {
const fetchHostScriptPath = async () => {
const path = await getHostScriptPath(previewScript?.filename || "");
setHostScriptPath(path);
};
if (previewScript) {
fetchHostScriptPath();
}
}, [previewScript]);
const filteredScripts = scripts.filter(
(script) =>
@@ -156,7 +168,7 @@ export function SelectScriptModal({
</div>
<div className="bg-muted/30 p-3 rounded border border-border/30">
<code className="text-sm font-mono text-foreground break-all">
{getHostScriptPath(previewScript.filename)}
{hostScriptPath}
</code>
</div>
</div>

View File

@@ -19,8 +19,6 @@ export async function fetchCronJobs(): Promise<CronJob[]> {
}
}
export async function createCronJob(
formData: FormData
): Promise<{ success: boolean; message: string }> {
@@ -42,7 +40,7 @@ export async function createCronJob(
const selectedScript = scripts.find((s) => s.id === selectedScriptId);
if (selectedScript) {
finalCommand = getScriptPath(selectedScript.filename);
finalCommand = await getScriptPath(selectedScript.filename);
} else {
return { success: false, message: "Selected script not found" };
}

View File

@@ -37,29 +37,32 @@ async function generateUniqueFilename(baseName: string): Promise<string> {
}
async function ensureScriptsDirectory() {
if (!existsSync(SCRIPTS_DIR)) {
await mkdir(SCRIPTS_DIR, { recursive: true });
const scriptsDir = await SCRIPTS_DIR();
if (!existsSync(scriptsDir)) {
await mkdir(scriptsDir, { recursive: true });
}
}
async function ensureHostScriptsDirectory() {
const isDocker = process.env.DOCKER === "true";
const hostScriptsDir = isDocker
? "/app/scripts"
: join(process.cwd(), "scripts");
const hostProjectDir = process.env.HOST_PROJECT_DIR || process.cwd();
const hostScriptsDir = join(hostProjectDir, "scripts");
if (!existsSync(hostScriptsDir)) {
await mkdir(hostScriptsDir, { recursive: true });
}
}
async function saveScriptFile(filename: string, content: string) {
const isDocker = process.env.DOCKER === "true";
const scriptsDir = isDocker ? "/app/scripts" : await SCRIPTS_DIR();
await ensureScriptsDirectory();
const scriptPath = join(SCRIPTS_DIR, filename);
const scriptPath = join(scriptsDir, filename);
await writeFile(scriptPath, content, "utf8");
}
async function deleteScriptFile(filename: string) {
const scriptPath = join(SCRIPTS_DIR, filename);
const scriptPath = join(await SCRIPTS_DIR(), filename);
if (existsSync(scriptPath)) {
await unlink(scriptPath);
}
@@ -226,7 +229,11 @@ export async function cloneScript(
export async function getScriptContent(filename: string): Promise<string> {
try {
const scriptPath = join(SCRIPTS_DIR, filename);
const isDocker = process.env.DOCKER === "true";
const scriptPath = isDocker
? join("/app/scripts", filename)
: join(process.cwd(), "scripts", filename);
if (existsSync(scriptPath)) {
const content = await readFile(scriptPath, "utf8");
const lines = content.split("\n");

View File

@@ -1,15 +1,22 @@
"use server";
import { join } from "path";
const isDocker = process.env.DOCKER === "true";
const SCRIPTS_DIR = isDocker ? "/app/scripts" : join(process.cwd(), "scripts");
const SCRIPTS_DIR = async () => {
if (isDocker && process.env.HOST_PROJECT_DIR) {
return `${process.env.HOST_PROJECT_DIR}/scripts`;
}
return join(process.cwd(), "scripts");
};
export function getScriptPath(filename: string): string {
return join(SCRIPTS_DIR, filename);
export async function getScriptPath(filename: string): Promise<string> {
return join(await SCRIPTS_DIR(), filename);
}
export function getHostScriptPath(filename: string): string {
const hostProjectDir =
process.env.NEXT_PUBLIC_HOST_PROJECT_DIR || process.cwd();
export async function getHostScriptPath(filename: string): Promise<string> {
const hostProjectDir = process.env.HOST_PROJECT_DIR || process.cwd();
const hostScriptsDir = join(hostProjectDir, "scripts");
return `bash ${join(hostScriptsDir, filename)}`;
}

View File

@@ -4,80 +4,89 @@ import { promisify } from "util";
const execAsync = promisify(exec);
async function execHostCrontab(command: string): Promise<string> {
try {
const { stdout } = await execAsync(
`nsenter -t 1 -m -u -i -n -p sh -c "${command}"`
);
return stdout;
} catch (error: any) {
console.error("Error executing host crontab command:", error);
throw error;
}
try {
const { stdout } = await execAsync(
`nsenter -t 1 -m -u -i -n -p sh -c "${command}"`
);
return stdout;
} catch (error: any) {
console.error("Error executing host crontab command:", error);
throw error;
}
}
async function getTargetUser(): Promise<string> {
try {
if (process.env.HOST_CRONTAB_USER) {
return process.env.HOST_CRONTAB_USER;
}
const { stdout } = await execAsync('stat -c "%U" /var/run/docker.sock');
const dockerSocketOwner = stdout.trim();
if (dockerSocketOwner === 'root') {
try {
const projectDir = process.env.NEXT_PUBLIC_HOST_PROJECT_DIR;
if (projectDir) {
const dirOwner = await execHostCrontab(`stat -c "%U" "${projectDir}"`);
return dirOwner.trim();
}
} catch (error) {
console.warn("Could not detect user from project directory:", error);
}
try {
const users = await execHostCrontab('getent passwd | grep ":/home/" | head -1 | cut -d: -f1');
const firstUser = users.trim();
if (firstUser) {
return firstUser;
}
} catch (error) {
console.warn("Could not detect user from passwd:", error);
}
return 'root';
}
return dockerSocketOwner;
} catch (error) {
console.error("Error detecting target user:", error);
return 'root';
try {
if (process.env.HOST_CRONTAB_USER) {
return process.env.HOST_CRONTAB_USER;
}
const { stdout } = await execAsync('stat -c "%U" /var/run/docker.sock');
const dockerSocketOwner = stdout.trim();
if (dockerSocketOwner === "root") {
try {
const projectDir = process.env.HOST_PROJECT_DIR;
if (projectDir) {
const dirOwner = await execHostCrontab(
`stat -c "%U" "${projectDir}"`
);
return dirOwner.trim();
}
} catch (error) {
console.warn("Could not detect user from project directory:", error);
}
try {
const users = await execHostCrontab(
'getent passwd | grep ":/home/" | head -1 | cut -d: -f1'
);
const firstUser = users.trim();
if (firstUser) {
return firstUser;
}
} catch (error) {
console.warn("Could not detect user from passwd:", error);
}
return "root";
}
return dockerSocketOwner;
} catch (error) {
console.error("Error detecting target user:", error);
return "root";
}
}
export async function readHostCrontab(): Promise<string> {
try {
const user = await getTargetUser();
return await execHostCrontab(`crontab -l -u ${user} 2>/dev/null || echo ""`);
} catch (error) {
console.error("Error reading host crontab:", error);
return "";
}
try {
const user = await getTargetUser();
return await execHostCrontab(
`crontab -l -u ${user} 2>/dev/null || echo ""`
);
} catch (error) {
console.error("Error reading host crontab:", error);
return "";
}
}
export async function writeHostCrontab(content: string): Promise<boolean> {
try {
const user = await getTargetUser();
let finalContent = content;
if (!finalContent.endsWith('\n')) {
finalContent += '\n';
}
const base64Content = Buffer.from(finalContent).toString('base64');
await execHostCrontab(`echo '${base64Content}' | base64 -d | crontab -u ${user} -`);
return true;
} catch (error) {
console.error("Error writing host crontab:", error);
return false;
try {
const user = await getTargetUser();
let finalContent = content;
if (!finalContent.endsWith("\n")) {
finalContent += "\n";
}
const base64Content = Buffer.from(finalContent).toString("base64");
await execHostCrontab(
`echo '${base64Content}' | base64 -d | crontab -u ${user} -`
);
return true;
} catch (error) {
console.error("Error writing host crontab:", error);
return false;
}
}

View File

@@ -1,16 +1,17 @@
services:
cronjob-manager:
image: ghcr.io/fccview/cronmaster:main
container_name: cronmaster-test
container_name: cronmaster
user: "root"
ports:
# Feel free to change port, 3000 is very common so I like to map it to something else
- "40124:3000"
- "40123:3000"
environment:
- NODE_ENV=production
- DOCKER=true
# Legacy used to be NEXT_PUBLIC_HOST_PROJECT_DIR, this was causing issues on runtime.
- HOST_PROJECT_DIR=/path/to/cronmaster/directory
- NEXT_PUBLIC_CLOCK_UPDATE_INTERVAL=30000
- NEXT_PUBLIC_HOST_PROJECT_DIR=/path/to/cronmaster/directory
# If docker struggles to find your crontab user, update this variable with it.
# Obviously replace fccview with your user - find it with: ls -asl /var/spool/cron/crontabs/
# - HOST_CRONTAB_USER=fccview
@@ -20,7 +21,7 @@ services:
# These are needed if you want to keep your data on the host machine and not wihin the docker volume.
# DO NOT change the location of ./scripts as all cronjobs that use custom scripts created via the app
# will target this foler (thanks to the NEXT_PUBLIC_HOST_PROJECT_DIR variable set above)
# will target this folder (thanks to the HOST_PROJECT_DIR variable set above)
- ./scripts:/app/scripts
- ./data:/app/data
- ./snippets:/app/snippets
@@ -33,4 +34,4 @@ services:
init: true
# Default platform is set to amd64, uncomment to use arm64.
#platform: linux/arm64
#platform: linux/arm64