Skip to content

Commit e2382ec

Browse files
committed
feat: use a setting for no service projets
1 parent d028cc8 commit e2382ec

8 files changed

Lines changed: 74 additions & 24 deletions

File tree

cli/src/commands/app/rollback.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
*/
66

77
import type { Command } from 'commander';
8-
import { getPerformer, getComposePath } from '../../utils/config';
8+
import { getPerformer } from '../../utils/config';
99
import { createSpinner } from '../../utils/output';
1010
import { validateEnv } from '../../utils/validation';
1111
import { createStackBackend } from '../../services/orchestrator/factory';
@@ -25,9 +25,9 @@ export function registerRollbackCommand(program: Command): void {
2525
.action(withErrorHandler(async (env: string, service: string | undefined, options: { server?: string }) => {
2626
const { config, stackName, connection } = validateEnv(env, options.server);
2727

28-
if (!getComposePath()) {
28+
if (config.no_services) {
2929
throw new DeployError(
30-
'Rollback is not supported for upload-only projects (no docker-compose.yml)',
30+
'Rollback is not supported for upload-only projects',
3131
ErrorCode.ROLLBACK_FAILED,
3232
'To restore a previous version, re-deploy from the corresponding git commit.',
3333
);

cli/src/commands/deploy-phases.ts

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -231,9 +231,14 @@ export async function uploadFiles(ctx: DeployContext): Promise<UploadRollbackPla
231231
const fileContent = readFileSync(srcAbs);
232232

233233
// Ensure destination and backup dirs exist on all hosts in one call each
234-
await Promise.all(plan.hosts.map(({ conn }) =>
235-
sshExec(conn, `mkdir -p '${dirname(destPath)}' '${dirname(backupPath)}'`),
236-
));
234+
await Promise.all(plan.hosts.map(async ({ name, conn }) => {
235+
const r = await sshExec(conn, `mkdir -p '${dirname(destPath)}' '${dirname(backupPath)}'`);
236+
if (r.exitCode !== 0) throw new DeployError(
237+
`upload: cannot create ${dirname(destPath)} on ${name}: ${r.stderr.trim() || `exit ${r.exitCode}`}`,
238+
ErrorCode.DEPLOY_FAILED,
239+
`The deploy user must own the destination directory. Run once on the server as root:\n mkdir -p '${dirname(destPath)}' && chown ${conn.user}: '${dirname(destPath)}'`,
240+
);
241+
}));
237242

238243
const tasks = plan.hosts.map(hostState => async () => {
239244
const { name, conn } = hostState;
@@ -253,22 +258,28 @@ export async function uploadFiles(ctx: DeployContext): Promise<UploadRollbackPla
253258
if (result.exitCode !== 0) {
254259
const detail = result.stderr.trim() || result.stdout.trim() || `exit code ${result.exitCode}`;
255260
throw new DeployError(
256-
`upload: failed to transfer ${upload.src} to ${name}: ${detail}`,
261+
`upload: failed to transfer ${upload.src} ${destPath} on ${name}: ${detail}`,
257262
ErrorCode.DEPLOY_FAILED,
258263
`Ensure ${conn.user} has write access to ${dirname(destPath)} on ${name}. Run once as root:\n mkdir -p '${dirname(destPath)}' && chown ${conn.user}: '${dirname(destPath)}'`,
259264
);
260265
}
261266

262267
if (upload.permissions) {
263268
const r = await sshExec(conn, `chmod ${upload.permissions} '${destPath}'`);
264-
if (r.exitCode !== 0) throw new DeployError(`upload: chmod failed on ${destPath} (${name})`, ErrorCode.DEPLOY_FAILED);
269+
if (r.exitCode !== 0) throw new DeployError(
270+
`upload: chmod ${upload.permissions} failed on ${destPath} (${name}): ${r.stderr.trim() || `exit ${r.exitCode}`}`,
271+
ErrorCode.DEPLOY_FAILED,
272+
);
265273
}
266274
if (upload.owner) {
267275
const r = await sshExec(conn, `chown ${upload.owner} '${destPath}'`);
268276
if (r.exitCode !== 0) throw new DeployError(
269-
`upload: chown failed on ${destPath} (${name})`,
277+
`upload: chown ${upload.owner} failed on ${destPath} (${name}): ${r.stderr.trim() || `exit ${r.exitCode}`}`,
270278
ErrorCode.DEPLOY_FAILED,
271-
`Grant chown rights:\n echo '${conn.user} ALL=(ALL) NOPASSWD: /bin/chown * ${dirname(destPath)}/*' >> /etc/sudoers.d/dockflow`,
279+
`The deploy user needs sudo rights for chown. Either run once on the server as root:\n` +
280+
` chown ${upload.owner} '${destPath}'\n` +
281+
`Or grant the deploy user the right permanently:\n` +
282+
` echo '${conn.user} ALL=(ALL) NOPASSWD: /bin/chown * ${dirname(destPath)}/*' >> /etc/sudoers.d/dockflow`,
272283
);
273284
}
274285
});

cli/src/commands/deploy.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -220,11 +220,12 @@ async function resolveSetup(rawEnv: string | undefined, rawVersion: string | und
220220
const { rendered, composeContent, composeDirPath } = Compose.renderAndResolveCompose(
221221
{ env, version: deployVersion, branch: branchName, project_name: config.project_name, config },
222222
templateContext,
223+
{ uploadOnly: config.no_services },
223224
);
224225
config = loadConfig({ content: rendered.get('.dockflow/config.yml'), silent: true }) ?? config;
225226

226-
// Validate --only service names before acquiring the lock
227-
if (options.only) {
227+
// Validate --only service names before acquiring the lock (skip for no_services — no Docker services)
228+
if (options.only && !config.no_services) {
228229
const compose = Compose.loadFromString(composeContent);
229230
const available = Object.keys(compose.services);
230231
const filterSet = options.only.split(',').map((s) => s.trim());

cli/src/schemas/config.schema.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,4 +395,10 @@ export const DockflowConfigSchema = z.object({
395395
'Files or directories to transfer to the remote server before deploying. ' +
396396
'Useful for config files referenced as bind mounts in docker-compose volumes.'
397397
),
398+
399+
no_services: z.boolean().optional().describe(
400+
'Set to true for projects with no Docker services (upload-only deployments). ' +
401+
'Skips Docker build, compose deploy, and all service commands. ' +
402+
'Without this flag, a missing docker-compose.yml is treated as a configuration error.'
403+
),
398404
});

cli/src/services/compose.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,7 @@ export function renderTemplates(
262262
export function renderAndResolveCompose(
263263
ctx: ComposeRenderContext,
264264
templateContext?: TemplateContext | null,
265+
options: { uploadOnly?: boolean } = {},
265266
): RenderedComposeResult {
266267
const projectRoot = getProjectRoot();
267268

@@ -275,13 +276,18 @@ export function renderAndResolveCompose(
275276

276277
const originalComposePath = getComposePath();
277278
if (!originalComposePath) {
278-
printWarning('No docker-compose.yml found — skipping Docker build and deploy. Expected at .dockflow/docker/docker-compose.yml');
279-
return {
280-
rendered,
281-
composeContent: 'services: {}\n',
282-
composeDirPath: join(projectRoot, '.dockflow', 'docker'),
283-
projectRoot,
284-
};
279+
if (options.uploadOnly) {
280+
return {
281+
rendered,
282+
composeContent: 'services: {}\n',
283+
composeDirPath: join(projectRoot, '.dockflow', 'docker'),
284+
projectRoot,
285+
};
286+
}
287+
throw new ConfigError(
288+
'No docker-compose.yml found',
289+
'Expected at docker-compose.yml or .dockflow/docker/docker-compose.yml',
290+
);
285291
}
286292

287293
const composeRelPath = relative(projectRoot, originalComposePath).replace(/\\/g, '/');

cli/src/utils/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@ export interface DockflowConfig {
161161
proxy?: ProxyConfig;
162162
notifications?: NotificationsConfig;
163163
upload?: UploadItem[];
164+
no_services?: boolean;
164165
}
165166

166167
/**

cli/src/utils/errors.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
import { printBlank, colors } from './output';
1010
import { closeAllConnections } from './ssh';
11-
import { getComposePath } from './config';
11+
import { loadConfig } from './config';
1212

1313
/**
1414
* CLI Error codes for different failure scenarios
@@ -208,16 +208,16 @@ export function withErrorHandler<T extends unknown[]>(
208208
};
209209
}
210210

211-
/** Wraps withErrorHandler and blocks execution when no docker-compose.yml is found. */
211+
/** Wraps withErrorHandler and blocks execution when the project is configured as no_services. */
212212
export function withServicesRequired<T extends unknown[]>(
213213
action: CommandAction<T>
214214
): CommandAction<T> {
215215
return withErrorHandler(async (...args: T): Promise<void> => {
216-
if (!getComposePath()) {
216+
if (loadConfig()?.no_services) {
217217
throw new DeployError(
218-
'This command requires Docker services (no docker-compose.yml found)',
218+
'This command requires Docker services',
219219
ErrorCode.VALIDATION_FAILED,
220-
'This project has no Docker services configured.',
220+
'This project is configured as no_services and has no Docker services.',
221221
);
222222
}
223223
await action(...args);

docs/app/configuration/upload/page.mdx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ services:
3232
| `service` | `string \| string[]` | Service(s) this upload belongs to. When deploying with `--only`, only uploads matching a targeted service are transferred. Uploads without this field are always transferred. | No |
3333
| `permissions` | `string` | File permissions applied after upload, in octal notation (e.g. `"640"`, `"755"`). | No |
3434
| `owner` | `string` | File owner applied after upload, in `"user"` or `"user:group"` format (e.g. `"mosquitto"`, `"www-data:www-data"`). Requires the deploy user to have `sudo` or sufficient privileges. | No |
35+
| `exclude` | `string[]` | Glob patterns or paths to skip during directory upload (e.g. `".git"`, `"*.md"`, `"node_modules"`). Ignored for single-file uploads. | No |
36+
| `compress` | `boolean` | Compress the archive during transfer (default: `true`). Set to `false` for directories full of already-compressed files (JARs, ZIPs, images) to reduce CPU overhead without affecting transfer size. | No |
3537

3638
`dest` must start with `/`. If `src` is a directory, `dest` is used as the destination directory and the relative structure is preserved.
3739

@@ -150,6 +152,29 @@ services:
150152
- /opt/dockflow/monitoring/prometheus.yml:/etc/prometheus/prometheus.yml
151153
```
152154

155+
## Upload-only projects
156+
157+
Some projects have no Docker services — they only transfer files to a server. Use `no_services: true` to declare this explicitly:
158+
159+
```yaml
160+
# .dockflow/config.yml
161+
project_name: my-project
162+
no_services: true
163+
164+
upload:
165+
- src: ./dist/
166+
dest: /var/www/my-project/
167+
```
168+
169+
With `no_services: true`:
170+
- Docker build and compose deploy are skipped entirely
171+
- Commands that require a running stack (`dockflow app logs`, `dockflow app status`, `rollback`, etc.) return a clear error instead of failing silently
172+
- Pre/post-deploy hooks still run
173+
174+
<Callout type="warning">
175+
Without `no_services: true`, a missing `docker-compose.yml` is treated as a configuration error and the deployment fails. Only set this flag when the project genuinely has no Docker services — not as a workaround for a misconfigured compose path.
176+
</Callout>
177+
153178
## See also
154179

155180
- [Templates](/configuration/templates) — use `{{ current.env.xxx }}` inside transferred files via Nunjucks rendering

0 commit comments

Comments
 (0)