Skip to content
Merged
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: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@
],
"dependencies": {
"@actions/core": "^3.0.0",
"@faabletools/buildpacks": "^1.7.0",
"axios": "^1.13.2",
"fs-extra": "^11.3.2",
"handlebars": "^4.7.8",
Expand Down
9 changes: 0 additions & 9 deletions src/commands/deploy/check_environment.ts

This file was deleted.

168 changes: 8 additions & 160 deletions src/commands/deploy/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,30 +3,15 @@ import { requireApi } from '../../api/context'
import { Configuration } from '../../lib/Configuration'
import { log } from '../../log'
import { link } from '../link'
import {
BuildPlan,
buildpack_names,
configure_buildpacks,
detect_buildpack,
get_buildpack,
plan_summary
} from '@faabletools/buildpacks'
import { cmd } from '../../lib/cmd'
import { check_environment } from './check_environment'
import { git_context } from './git_context'
import { deploy_remote } from './remote'
import { resolve_app_id } from './resolve_app_id'
import { secrets } from './secrets'
import { is_superseded } from './superseded'
import { mark_build_failure, start_log_sync, upload_logs } from './upload_logs'
import { upload_tag } from './upload_tag'

export interface DeployCommandArgs {
app_id: string
workdir?: string
buildpack?: string
remote?: boolean
local?: boolean
}

export const deploy: CommandModule<unknown, DeployCommandArgs> = {
Expand All @@ -47,35 +32,12 @@ export const deploy: CommandModule<unknown, DeployCommandArgs> = {
type: 'string',
description: 'Working directory'
})
.option('buildpack', {
alias: 'b',
type: 'string',
choices: buildpack_names(),
description:
'Force a specific buildpack (overrides auto-detection and faable.json)'
})
.option('remote', {
type: 'boolean',
description:
'Build server-side (remote build), regardless of the app build_mode'
})
.option('local', {
type: 'boolean',
description:
'Build locally with Docker, even if the app is set to remote builds'
})
.conflicts('remote', 'local')
.showHelpOnFail(false) as any
},

handler: async args => {
const workdir = args.workdir || process.cwd()

// Wire the shared buildpacks package to the CLI's sinks: pino (which tees
// into the build-log buffer) and cmd() (which captures subprocess output
// into the same buffer). Must happen before any detect/build call.
configure_buildpacks({ log, exec: cmd })

// Pass the explicit app target to the OIDC exchange so a monorepo (several
// apps, one repo) can be disambiguated in CI.
const ctx = await requireApi(args.app_id)
Expand All @@ -98,128 +60,14 @@ export const deploy: CommandModule<unknown, DeployCommandArgs> = {
// it came from and who pushed it (env in CI, git fallback locally).
const git = await git_context({ workdir })

// Resolve the buildpack plan (detection or forced override). All the build
// thinking happens here; build() below just executes the plan. Detection can
// fail (e.g. an app whose start command can't be inferred) — and it runs
// before the create-first deployment exists, so without this a detection
// failure would leave NO deployment row: no deploy-failed email, nothing in
// the dashboard, just a red X in the CI logs the user may never see. Record
// it as a failed deployment instead.
let plan: BuildPlan
try {
plan = await detect_buildpack(
{ workdir, config },
args.buildpack || config.buildpack
)
} catch (error: any) {
// Log the reason into the build buffer first (so it rides along in the
// attached logs), then record a typeless BUILD_ERROR row — typeless so it
// can't rewrite the app's runtime_strategy. Best-effort: a create that
// itself fails (e.g. the free-plan quota gate) must not mask the original
// detection error.
log.error(error.message)
const failed = await api
.createDeployment({ app_id: app.id, ...git })
.catch(() => null)
if (failed) {
await mark_build_failure(api, { deployment_id: failed.id, app })
}
throw error
}

// Remote build path (v2, arch/deploy/deploy-v2-remote-build.md): the
// server decides via the app's build_mode; --remote/--local override for
// testing. Pre-build failures with a server-decided mode fall back to the
// local build below (deploy_remote returns null); with --remote they
// fail hard.
let deployment: { id: string }
const want_remote =
!args.local && (Boolean(args.remote) || app.build_mode === 'remote')
const remote_deployment = want_remote
? await deploy_remote({
api,
app,
plan,
git,
workdir,
explicit: Boolean(args.remote)
})
: null

if (remote_deployment) {
deployment = remote_deployment
} else {
if (want_remote) {
log.warn('↩️ Falling back to a local build')
}

// Create-first: register the deployment BEFORE building. Gate rejections
// (free-plan quota 429, disabled app 409) surface here, at second 0 —
// before any build minutes are spent. The row is born QUEUED; the CLI
// owns it until the built image lands (or the build fails). `type` rides
// on the create — the buildpack plan is already resolved.
deployment = await api.createDeployment({
app_id: app.id,
type: plan.type,
...git
})

try {
// Check if we can build docker images
await check_environment()

const runtime_label = plan.runtime.version
? `${plan.runtime.name}-${plan.runtime.version}`
: plan.runtime.name
log.info(
`🚀 Deploying "${app.name}" (${app.id}) runtime=${runtime_label}`
)
log.info(`🧩 Build plan ${plan_summary(plan)}`)

// get environment variables
const env_vars = await api.getAppSecrets(app.id)

const buildpack = get_buildpack(plan.buildpack)
if (!buildpack) {
throw new Error(`No buildpack registered for plan=${plan.buildpack}`)
}

// The build starts now: declare it (QUEUED → BUILDING) and stream the
// captured output to the deployment while it runs (best-effort).
await api
.updateDeploymentStatus(deployment.id, { phase: 'BUILDING' })
.catch((error: any) =>
log.warn(`Could not mark deployment BUILDING: ${error.message}`)
)
const stop_log_sync = start_log_sync(api, deployment.id)
try {
await buildpack.build({ workdir, config, app, env_vars, deployment }, plan)

// Upload to Faable registry, tagged by version and pinned to digest
const { image_ref } = await upload_tag({
app,
api,
deployment_id: deployment.id
})

// Complete the deployment with the built image — this is the handoff:
// the controller claims it and materializes the workload.
await api.completeDeployment(deployment.id, image_ref)
} finally {
stop_log_sync()
}
} catch (error: any) {
// The deployment already exists (created pre-build), so a failed build
// marks THAT row BUILD_ERROR with the captured logs — no extra row, no
// second quota hit. Gate rejections can't reach here anymore: the
// create happens before any building.
await mark_build_failure(api, { deployment_id: deployment.id, app })
throw error
}

// Attach the final build output to the deployment (best-effort).
await upload_logs(api, deployment.id)
} // end local build path (remote builds upload their own logs server-side)
// Remote build only (arch/deploy/remote-artifact-default-cutover.md): the
// CLI no longer builds — it uploads the source and the platform builds
// server-side (framework detection, buildpacks, artifact/image output all
// live in the builder). No Docker, no local fallback: a rejected admission
// (build_mode=local opt-out, or the global kill-switch off) or a build
// error throws and exits red.
log.info(`🚀 Deploying "${app.name}" (${app.id})`)
const deployment = await deploy_remote({ api, app, git, workdir })

const dashboard_url = `https://dashboard.faable.com/deploy/${app.team}/app/${app.id}`
log.info(`Preparing to deploy in faable cloud · ${deployment.id}`)
Expand Down
61 changes: 21 additions & 40 deletions src/commands/deploy/remote/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { BuildPlan } from "@faabletools/buildpacks";
import { FaableApi, FaableApp } from "../../../api/FaableApi";
import { log } from "../../../log";
import { git_context } from "../git_context";
Expand All @@ -9,57 +8,39 @@ import { upload_missing_blobs } from "./upload";
export interface DeployRemoteProps {
api: FaableApi;
app: FaableApp;
plan: BuildPlan;
git: Awaited<ReturnType<typeof git_context>>;
workdir: string;
/** --remote was passed: fail hard instead of falling back to local. */
explicit: boolean;
}

/**
* Remote build path (v2, arch/deploy/deploy-v2-remote-build.md): upload the
* source delta to the CAS, create the deployment with `source` (manifest +
* serialized BuildPlan) and follow the server-side build until the image
* handoff. No Docker daemon needed.
* Remote build (arch/deploy/remote-artifact-default-cutover.md): upload the
* source delta to the CAS, create the deployment with `source` (manifest only —
* the builder re-detects the framework server-side) and follow the server-side
* build until the handoff. No Docker daemon, no local buildpacks.
*
* Fallback contract:
* - PRE-build failures (collection, upload, create — including the server
* gate `remote_build_disabled`) return null when the mode came from the
* server, so the caller falls back to a local build. With --remote they
* fail hard (testing wants to see the errors).
* - Failures AFTER the deployment exists never fall back: a BUILD_ERROR
* would fail locally too (it's the user's build), and a timeout may still
* be building — a local fallback would double-deploy.
* There is no local fallback anymore: the CLI no longer builds. Any failure —
* a rejected admission (`remote_build_disabled` when the app opted out with
* build_mode=local, or the global kill-switch is off), an upload error, or a
* BUILD_ERROR — throws so the command exits red.
*/
export const deploy_remote = async (
props: DeployRemoteProps
): Promise<{ id: string } | null> => {
const { api, app, plan, git, workdir, explicit } = props;
): Promise<{ id: string }> => {
const { api, app, git, workdir } = props;

log.info(
`☁️ Remote build (${explicit ? "--remote" : `app build_mode=remote`})`
);
log.info(`☁️ Remote build`);

let deployment: { id: string };
try {
const manifest = await collect_manifest(workdir);
log.info(`🗂️ ${manifest.length} files in the source manifest`);
await upload_missing_blobs(api, app.id, workdir, manifest);
const manifest = await collect_manifest(workdir);
log.info(`🗂️ ${manifest.length} files in the source manifest`);
await upload_missing_blobs(api, app.id, workdir, manifest);

deployment = await api.createDeployment({
app_id: app.id,
type: plan.type,
source: { manifest, plan },
...git,
});
} catch (error: any) {
if (explicit) throw error;
const code = error?.response?.data?.code;
log.warn(
`☁️ Remote build unavailable${code ? ` (${code})` : `: ${error.message}`}`
);
return null;
}
// No `plan`: the builder re-detects on the assembled tree and syncs the app's
// runtime_strategy on completion.
const deployment = await api.createDeployment({
app_id: app.id,
source: { manifest },
...git,
});

log.info(`Preparing to build in faable cloud · ${deployment.id}`);
await follow_remote_build(api, deployment.id);
Expand Down
6 changes: 3 additions & 3 deletions src/commands/deploy/remote/manifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,14 +49,14 @@ test("symlinks are rejected with an actionable error", async (t) => {
const dir = git_fixture();
symlinkSync("src/index.js", join(dir, "link.js"));
await t.throwsAsync(() => collect_manifest(dir), {
message: /Symlinks are not supported.*--local/s,
message: /Symlinks are not supported/s,
});
});

test("a non-git directory is rejected toward --local", async (t) => {
test("a non-git directory is rejected", async (t) => {
const dir = mkdtempSync(join(tmpdir(), "faable-nogit-"));
writeFileSync(join(dir, "package.json"), "{}");
await t.throwsAsync(() => collect_manifest(dir), {
message: /git repository.*--local/s,
message: /git repository/s,
});
});
8 changes: 4 additions & 4 deletions src/commands/deploy/remote/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ const MAX_FILE_BYTES = 100 * 1024 * 1024;
* them like a fresh clone would.
*
* Symlinks are rejected (no representation in the manifest by design — see
* deploy-v2-remote-build.md) and a non-git directory is unsupported in the
* MVP (deploy with --local instead).
* deploy-v2-remote-build.md) and a non-git directory is unsupported (the source
* tree is collected via git).
*/
export const collect_manifest = async (
workdir: string
Expand All @@ -34,7 +34,7 @@ export const collect_manifest = async (
}).catch(() => null);
if (!inside || String(inside.stdout).trim() !== "true") {
throw new Error(
"Remote builds need a git repository (the source tree is collected via git). Deploy with --local instead."
"Deploys need a git repository (the source tree is collected via git). Initialize one with `git init` and commit your files."
);
}

Expand Down Expand Up @@ -72,7 +72,7 @@ export const collect_manifest = async (
if (stat.isDirectory()) continue;
if (stat.isSymbolicLink()) {
throw new Error(
`Symlinks are not supported in remote builds: ${rel}. Deploy with --local instead.`
`Symlinks are not supported: ${rel}. Replace the symlink with the real file.`
);
}
if (stat.size > MAX_FILE_BYTES) {
Expand Down
Loading