Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh

Commit

A g1t.page build can be a container. A project with a `Dockerfile` (or `Containerfile`, or the path set as its Dockerfile) and no framework that builds a static site or a Worker, a framework set up to need a server (Next.js without a static export, Astro with `@astrojs/node`, SvelteKit with `adapter-node`, a server-rendered React Router or Remix app), or a Node.js, Python (Django, Flask, FastAPI), Go, Ruby (Rails, Rack) or PHP (Laravel, Composer) server is built as one; with no Dockerfile g1t writes one for the server it recognises, shows it whole in the build's log to be committed and changed, and builds it with a `.dockerignore` of its own, never touching the checkout. Build as (`BUILD_AS`: auto, static, workers or docker) wins over all of it, and Auto keeps a Wrangler config a Workers project and a static framework static, which cost nothing while no one visits; a project with nothing to serve now says to add a Dockerfile. The image is built with the sandbox's own Docker Engine (or the job's, from a workflow) for linux/amd64, held to the instance's disk, and pushed to Cloudflare's registry with the push-only login the deployments service gives the build in a new `registry` step, kept in a folder of its own and redacted; the finish report carries the image by digest, its port (the Dockerfile's `EXPOSE`, else 8080) and size. While g1t.page runs no containers (`not_enabled`), a project that is a container only for its Dockerfile is built as the site it is without one, as before. `g1t-runner detect` prints `kind=container`, `framework` (the framework, the server, or `dockerfile`), `dockerfile` and `port`.

Claude-Session:
https://claude.ai/code/session_01PnwTRSNaC7vEHTAuBzjhrn
Claudeagent and Claudeagentcommitted Parentfffac75Browse files
3 files+1101−390/3 viewed
+506−39
2222 //! bundled as a Workers project's is, without a Wrangler config of its
2323 //! own. A framework set up to need a server fails before anything is
2424 //! installed, saying what to change.
25+//! - A container (deploy/container.rs): a project with a `Dockerfile` (or
26+//! `Containerfile`, or the path it chose) and no framework that builds a
27+//! static site or a Worker, a framework set up to need a server, or a
28+//! Node.js, Python, Go, Ruby or PHP server, for which g1t writes a
29+//! Dockerfile and shows it in the log. The image is built with Docker
30+//! and pushed to Cloudflare's registry with a push-only login the
31+//! deployments service gives this build alone, for minutes (the
32+//! `registry` step); the service runs it.
2533 //! - Anything else: a static site. Its `build` script runs, and the first
2634 //! of `dist`, `build`, `out`, `public`, `_site` or `.output/public` that
2735 //! exists is served, or the repository itself if it has an `index.html`.
2836 //!
29−//! The project's own **Build command** and **Output directory** win over
30−//! what is detected.
37+//! The project's own **Build as** (`BUILD_AS`: auto, static, workers or
38+//! docker), **Dockerfile** (`DOCKERFILE`), **Build command** and **Output
39+//! directory** win over what is detected.
3140 //!
3241 //! Configuration comes from the environment:
3342 //!
6372 use crate::checks::{redact, run_command};
6473 use crate::{WORKDIR, auth_option, env, git};
6574
75+mod container;
6676 mod frameworks;
6777
78+use container::BuildAs;
6879 use frameworks::{Adapter, Framework};
6980
7081 /// Where `wrangler deploy --dry-run` writes the bundle in a sandbox.
525536 assets_dir: Option<PathBuf>,
526537 warnings: Vec<String>,
527538 /// What kind of project it was found to be, for its settings page:
528− /// `workers`, `static` (a build's output) or `html` (the files as they are).
539+ /// `workers`, `static` (a build's output), `html` (the files as they
540+ /// are) or `container`.
529541 detected: &'static str,
530− /// The framework it was found to be built with, if any (`astro`…).
542+ /// The framework it was found to be built with, if any (`astro`…), or
543+ /// for a container what it was built from (`dockerfile`, `flask`…).
531544 framework: Option<&'static str>,
545+ /// For a container: the image pushed, its digest, port and size.
546+ container: Option<Value>,
532547 }
533548
534549 /// What a project without a Workers config is: a site it built, or its own
597612 /// unless one was chosen or the framework says where it writes.
598613 #[derive(Debug, PartialEq, Eq)]
599614 pub(crate) struct Detected {
600− /// `workers`, `static`, `html`, or `none` when there is nothing to serve.
615+ /// `workers`, `static`, `html`, `container`, or `none` when there is
616+ /// nothing to serve.
601617 pub kind: &'static str,
602618 /// The framework it is built with, if g1t knows it.
603619 pub framework: Option<Framework>,
610626 /// What runs before wrangler bundles a Workers project; for anything
611627 /// else, `build`.
612628 pre_build: Option<String>,
629+ /// For a container: its Dockerfile, or the one g1t writes.
630+ pub container: Option<ContainerBuild>,
631+ /// What it would be served as without its Dockerfile, when it is a
632+ /// container only for having one: built so while g1t.page runs no
633+ /// containers (`not_enabled` from the registry step).
634+ fallback: Option<Box<Detected>>,
613635 }
614636
637+/// How a container is built.
638+#[derive(Debug, Clone, PartialEq, Eq)]
639+pub(crate) struct ContainerBuild {
640+ /// The project's own Dockerfile, relative to the project; none when
641+ /// g1t writes one.
642+ pub dockerfile: Option<String>,
643+ /// The Dockerfile g1t writes, for a server it recognises.
644+ pub generated: Option<container::Server>,
645+ /// What it listens on: its Dockerfile's `EXPOSE`, else 8080. `PORT`
646+ /// is set to it.
647+ pub port: u16,
648+}
649+
650+impl Detected {
651+ fn nothing(framework: Option<Framework>, install: Option<String>, problem: String) -> Detected {
652+ Detected {
653+ kind: "none",
654+ framework,
655+ install,
656+ build: None,
657+ output_dir: None,
658+ problem: Some(problem),
659+ pre_build: None,
660+ container: None,
661+ fallback: None,
662+ }
663+ }
664+
665+ /// The `framework` `g1t-runner detect` prints: the framework, else for
666+ /// a container the server g1t wrote a Dockerfile for (`flask`…) or
667+ /// `dockerfile`.
668+ pub(crate) fn framework_id(&self) -> Option<&'static str> {
669+ self.framework.as_ref().map(|f| f.id).or_else(|| {
670+ self.container.as_ref().map(|c| c.generated.as_ref().map_or("dockerfile", |server| server.id))
671+ })
672+ }
673+}
674+
675+/// Why nothing could be found to serve, and what to add.
676+const NOTHING_TO_SERVE: &str = "found nothing to serve: no Workers config, no Dockerfile, no index.html, and none of dist, build, out, \
677+ public, _site or .output/public. Add a `Dockerfile` to deploy it as a container, or set **Build command** and \
678+ **Output directory** for a static site.";
679+
680+/// The project as a container: its own Dockerfile, else one g1t writes
681+/// for `framework` or for a server it recognises. None when it has neither.
682+fn as_container(dir: &Path, framework: Option<Framework>, own: Option<String>) -> Option<Detected> {
683+ let build = match own {
684+ Some(path) => {
685+ let text = std::fs::read_to_string(dir.join(&path)).unwrap_or_default();
686+ ContainerBuild { port: container::exposed_port(&text).unwrap_or(container::DEFAULT_PORT), dockerfile: Some(path), generated: None }
687+ }
688+ None => {
689+ let server = match &framework {
690+ // One set up for Workers (OpenNext's adapter) is not run as a server.
691+ Some(framework) if framework.workers => None,
692+ Some(framework) => container::for_framework(dir, framework.id),
693+ None => container::server(dir),
694+ }?;
695+ ContainerBuild { dockerfile: None, generated: Some(server), port: container::DEFAULT_PORT }
696+ }
697+ };
698+ let file = build.dockerfile.clone().unwrap_or_else(|| "Dockerfile (written by g1t)".to_owned());
699+ Some(Detected {
700+ kind: "container",
701+ framework,
702+ // The image installs what it needs: nothing runs before its build.
703+ install: None,
704+ build: Some(format!("docker build -f {file} .")),
705+ output_dir: None,
706+ problem: None,
707+ pre_build: None,
708+ container: Some(build),
709+ fallback: None,
710+ })
711+}
712+
713+#[cfg(test)]
615714 pub(crate) fn detect(dir: &Path, custom_build: Option<&str>, chosen_output: Option<&str>) -> Result<Detected> {
715+ detect_as(dir, custom_build, chosen_output, BuildAs::Auto, None)
716+}
717+
718+/// What the project is, by its **Build as** setting (`build_as`) and its
719+/// Dockerfile's path (`dockerfile`, when it chose one):
720+///
721+/// - **Docker**: a container, from its Dockerfile or one g1t writes.
722+/// - **Static site**: built and served as files, whatever else it has.
723+/// - **Workers**: its Wrangler config or a framework adapter's Worker.
724+/// - **Auto**: a Wrangler config is a Workers project; a framework that
725+/// builds a static site or a Worker is that, which costs nothing while
726+/// no one visits; a framework set up to need a server is a container
727+/// when it has a Dockerfile, or when g1t can write one; a project with
728+/// no framework g1t knows is a container when it has a Dockerfile or is
729+/// a server g1t recognises (a Node.js, Python, Go, Ruby or PHP app),
730+/// else a static site, else nothing, saying to add a Dockerfile.
731+pub(crate) fn detect_as(
732+ dir: &Path,
733+ custom_build: Option<&str>,
734+ chosen_output: Option<&str>,
735+ build_as: BuildAs,
736+ dockerfile: Option<&str>,
737+) -> Result<Detected> {
738+ let own = container::own_dockerfile(dir, dockerfile)?;
739+ match build_as {
740+ BuildAs::Docker => {
741+ let framework = frameworks::detect(dir, read_wrangler(dir)?.is_some());
742+ Ok(as_container(dir, framework.clone(), own).unwrap_or_else(|| {
743+ Detected::nothing(
744+ framework,
745+ None,
746+ "**Build as** is Docker, but this project has no Dockerfile and g1t doesn't recognise a server it can write \
747+ one for. Add a `Dockerfile` (or set its path under Settings, Deployments)."
748+ .to_owned(),
749+ )
750+ }))
751+ }
752+ BuildAs::Static => detect_site(dir, custom_build, chosen_output, true),
753+ BuildAs::Workers => {
754+ let site = detect_site(dir, custom_build, chosen_output, false)?;
755+ if site.kind == "workers" {
756+ return Ok(site);
757+ }
758+ Ok(Detected::nothing(
759+ site.framework,
760+ site.install,
761+ "**Build as** is Workers, but this project has no Wrangler config (`wrangler.jsonc`, `wrangler.json` or \
762+ `wrangler.toml`) and no framework adapter that writes a Worker."
763+ .to_owned(),
764+ ))
765+ }
766+ BuildAs::Auto => {
767+ let site = detect_site(dir, custom_build, chosen_output, false)?;
768+ if site.kind == "workers" || (site.framework.is_some() && site.problem.is_none()) {
769+ return Ok(site);
770+ }
771+ if let Some(problem) = site.problem.clone() {
772+ // A framework set up to need a server.
773+ let found = as_container(dir, site.framework.clone(), own);
774+ return Ok(found.unwrap_or_else(|| {
775+ Detected::nothing(site.framework, site.install, format!("{problem} Or add a `Dockerfile` to run it as a container."))
776+ }));
777+ }
778+ let has_own = own.is_some();
779+ if let Some(mut found) = as_container(dir, None, own) {
780+ // A Dockerfile beside a site that could be served as it is.
781+ if has_own && (site.kind == "static" || site.kind == "html") {
782+ found.fallback = Some(Box::new(site));
783+ }
784+ return Ok(found);
785+ }
786+ if site.kind == "none" {
787+ return Ok(Detected::nothing(site.framework, site.install, NOTHING_TO_SERVE.to_owned()));
788+ }
789+ Ok(site)
790+ }
791+ }
792+}
793+
794+/// What the project is as a static site or Workers project: `as_static`
795+/// ignores its Wrangler config and any framework adapter, and builds it
796+/// even when its framework is set up to need a server.
797+fn detect_site(dir: &Path, custom_build: Option<&str>, chosen_output: Option<&str>, as_static: bool) -> Result<Detected> {
616798 let custom_build = custom_build.map(str::trim).filter(|c| !c.is_empty());
617799 let chosen_output = chosen_output.map(|d| d.trim().trim_matches('/')).filter(|d| !d.is_empty());
618− let config = read_wrangler(dir)?;
800+ let config = if as_static { None } else { read_wrangler(dir)? };
619801 let framework = frameworks::detect(dir, config.is_some());
620802 let installs: Vec<&str> = [install_command(dir), framework.as_ref().and_then(|f| f.install)].into_iter().flatten().collect();
621803 let install = (!installs.is_empty()).then(|| installs.join(" && "));
638820 output_dir: config.assets.and_then(|assets| assets.directory),
639821 problem: None,
640822 pre_build,
823+ container: None,
824+ fallback: None,
641825 });
642826 }
643827 let build = custom_build
645829 .or(framework_build)
646830 .or_else(|| has_build_script(dir).then(|| "npm run build".to_owned()));
647831 // An adapter's Worker, unless the project chose a folder to serve.
648− if chosen_output.is_none() && framework.as_ref().is_some_and(|f| f.adapter.is_some()) {
832+ if !as_static && chosen_output.is_none() && framework.as_ref().is_some_and(|f| f.adapter.is_some()) {
649833 // Where its files are is known once it has built: the adapter's
650834 // own folder, or where the config it writes says.
651835 return Ok(Detected {
656840 pre_build: build.clone(),
657841 build,
658842 problem: None,
843+ container: None,
844+ fallback: None,
659845 });
660846 }
661− // A command or a folder of the project's own says it knows better.
847+ // A command or a folder of the project's own says it knows better, as
848+ // does **Build as** set to Static site.
662849 let problem = framework
663850 .as_ref()
664851 .and_then(|f| f.problem.clone())
665− .filter(|_| custom_build.is_none() && chosen_output.is_none());
666− if problem.is_some() {
667− return Ok(Detected { kind: "none", framework, install, build: None, output_dir: None, problem, pre_build: None });
852+ .filter(|_| !as_static && custom_build.is_none() && chosen_output.is_none());
853+ if let Some(problem) = problem {
854+ return Ok(Detected::nothing(framework, install, problem));
668855 }
669856 let output_dir = chosen_output
670857 .map(str::to_owned)
676863 (None, None) => "none",
677864 _ => "static",
678865 };
679− Ok(Detected { kind, framework, install, pre_build: build.clone(), build, output_dir, problem: None })
866+ Ok(Detected { kind, framework, install, pre_build: build.clone(), build, output_dir, problem: None, container: None, fallback: None })
680867 }
681868
682869 /// The compatibility date a Worker gets when its config names none.
723910 bundle.with_file_name(format!("{}.wrangler.json", bundle.file_name().unwrap_or_default().to_string_lossy()))
724911 }
725912
726−fn build(log: &mut Log, secrets: &[String]) -> Result<Built> {
913+/// What a container build says when g1t.page runs no containers.
914+const CONTAINERS_OFF: &str = "This project deploys as a container, and Docker deployments are not switched on for g1t.page yet. \
915+ Set **Build as** to Static site or Workers under Settings, Deployments if it can be built as one.";
916+
917+/// Where a container's registry login is kept for the build: its own
918+/// folder, never the job's `~/.docker`.
919+fn docker_config_dir() -> PathBuf {
920+ bundle_dir().with_file_name(format!("{}.docker", bundle_dir().file_name().unwrap_or_default().to_string_lossy()))
921+}
922+
923+/// Where the Dockerfile g1t writes for a project without one goes: beside
924+/// the bundle, never in the checkout.
925+fn generated_dockerfile_path() -> PathBuf {
926+ bundle_dir().with_file_name(format!("{}.Dockerfile", bundle_dir().file_name().unwrap_or_default().to_string_lossy()))
927+}
928+
929+/// What the deployments service answers the `registry` step with: where
930+/// to push the app's image, with a login good for pushing only and only
931+/// for minutes, and the largest image the app's instance can run.
932+#[derive(Debug, Deserialize)]
933+struct RegistryGrant {
934+ registry: String,
935+ repository: String,
936+ tag: String,
937+ username: String,
938+ password: String,
939+ #[serde(alias = "maxImageBytes")]
940+ max_image_bytes: u64,
941+ #[serde(default, alias = "instanceType")]
942+ instance_type: Option<String>,
943+}
944+
945+/// `name@sha256:…` for `repository` among an image's repository digests,
946+/// as `docker image inspect` lists them, one a line.
947+fn pushed_digest(repo_digests: &str, registry: &str, repository: &str) -> Option<String> {
948+ let prefix = format!("{registry}/{repository}@sha256:");
949+ repo_digests
950+ .lines()
951+ .map(str::trim)
952+ .find(|line| line.starts_with(&prefix) && line.len() == prefix.len() + 64)
953+ .map(str::to_owned)
954+}
955+
956+/// Bytes, as a person reads them: `1.4 GB`.
957+fn size_text(bytes: u64) -> String {
958+ format!("{:.1} GB", bytes as f64 / 1_000_000_000.0)
959+}
960+
961+/// Runs `docker` with `args`, signed in through `config`; its output, or
962+/// why it failed, redacted.
963+fn docker(args: &[&str], config: &Path, input: Option<&str>, secrets: &[String]) -> Result<String> {
964+ use std::io::Write;
965+ use std::process::{Command, Stdio};
966+ let mut command = Command::new("docker");
967+ command.args(args).env("DOCKER_CONFIG", config).stdout(Stdio::piped()).stderr(Stdio::piped());
968+ command.stdin(if input.is_some() { Stdio::piped() } else { Stdio::null() });
969+ let mut child = command.spawn().context("could not run docker")?;
970+ if let (Some(input), Some(mut stdin)) = (input, child.stdin.take()) {
971+ stdin.write_all(input.as_bytes())?;
972+ }
973+ let output = child.wait_with_output()?;
974+ let text = redact(&format!("{}{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr)), secrets);
975+ if !output.status.success() {
976+ bail!("`docker {}` failed: {}", args.first().copied().unwrap_or_default(), text.trim());
977+ }
978+ Ok(text)
979+}
980+
981+/// Builds the project's container image with Docker and pushes it to
982+/// Cloudflare's registry, at the place and with the short-lived push-only
983+/// login the deployments service gives for this build (`registry`). The
984+/// sandbox never holds g1t's own Cloudflare token. None when g1t.page runs
985+/// no containers (the service answers `not_enabled`).
986+fn container_build(reporter: &Reporter, log: &mut Log, secrets: &[String], dir: &Path, plan: &Detected) -> Result<Option<Value>> {
987+ let build = plan.container.as_ref().context("not a container")?;
988+ // Asked first: a build that could not be pushed is not run.
989+ let answer = reporter.send("registry", json!({}))?;
990+ if answer["ok"] == false {
991+ if answer["error"]["code"] == "not_enabled" {
992+ return Ok(None);
993+ }
994+ bail!("{}", answer["error"]["message"].as_str().unwrap_or("g1t refused the image's upload"));
995+ }
996+ let grant: RegistryGrant =
997+ serde_json::from_value(answer["value"].clone()).context("g1t's answer to the image's upload was not understood")?;
998+ let mut secrets = secrets.to_vec();
999+ secrets.push(grant.password.clone());
1000+ let dockerfile = match (&build.dockerfile, &build.generated) {
1001+ (Some(own), _) => {
1002+ log.line(&format!("Building a container from {own}, listening on port {}.", build.port));
1003+ dir.join(own)
1004+ }
1005+ (None, Some(server)) => {
1006+ // Shown whole, so it can be committed and changed.
1007+ log.line(&format!("This {} project has no Dockerfile, so g1t wrote this one (commit it as `Dockerfile` to change it):", server.name));
1008+ for line in server.dockerfile.lines() {
1009+ log.line(&format!(" {line}"));
1010+ }
1011+ let path = generated_dockerfile_path();
1012+ std::fs::write(&path, &server.dockerfile)?;
1013+ // BuildKit reads `<Dockerfile>.dockerignore` beside it.
1014+ std::fs::write(path.with_file_name(format!("{}.dockerignore", path.file_name().unwrap_or_default().to_string_lossy())), container::DOCKERIGNORE)?;
1015+ path
1016+ }
1017+ (None, None) => bail!("nothing to build the container from"),
1018+ };
1019+ if std::env::var("BUILD_COMMAND").is_ok_and(|c| !c.trim().is_empty()) {
1020+ log.line("Build command is not used for a container: its Dockerfile builds it.");
1021+ }
1022+ // A sandbox's own Docker Engine, started for the build; in a workflow
1023+ // step, the job's.
1024+ if clones() {
1025+ crate::docker::engine::enable(crate::docker::engine::Options::default())
1026+ .map_err(|problem| anyhow::anyhow!("Docker is not available in this sandbox: {problem}"))?;
1027+ let started = crate::docker::engine::ensure_started();
1028+ for note in crate::docker::take_notes() {
1029+ log.line(&note);
1030+ }
1031+ started.map_err(|problem| anyhow::anyhow!("Docker could not start: {problem}"))?;
1032+ }
1033+ let config = docker_config_dir();
1034+ std::fs::create_dir_all(&config)?;
1035+ let image = format!("{}/{}:{}", grant.registry, grant.repository, grant.tag);
1036+ let quote = |path: &Path| format!("'{}'", path.display().to_string().replace('\'', r"'\''"));
1037+ let built = step(
1038+ log,
1039+ &format!("docker build --platform linux/amd64 --pull -f {} -t {image} .", quote(&dockerfile)),
1040+ &secrets,
1041+ );
1042+ let pushed = built.and_then(|()| {
1043+ let size: u64 = docker(&["image", "inspect", "--format", "{{.Size}}", &image], &config, None, &secrets)?.trim().parse().unwrap_or(0);
1044+ log.line(&format!("The image is {}.", size_text(size)));
1045+ if size > grant.max_image_bytes {
1046+ bail!(
1047+ "The image is {}, more than the {} an app of its size ({}) can hold. Make it smaller (a slim base image, a multi-stage build), or choose a larger instance under Settings, Deployments.",
1048+ size_text(size),
1049+ size_text(grant.max_image_bytes),
1050+ grant.instance_type.as_deref().unwrap_or("its instance type"),
1051+ );
1052+ }
1053+ docker(&["login", &grant.registry, "--username", &grant.username, "--password-stdin"], &config, Some(&grant.password), &secrets)?;
1054+ log.line(&format!("$ docker push {image}"));
1055+ let output = docker(&["push", &image], &config, None, &secrets)?;
1056+ log.line(output.trim_end());
1057+ let digests = docker(&["image", "inspect", "--format", "{{join .RepoDigests \"\\n\"}}", &image], &config, None, &secrets)?;
1058+ let reference = pushed_digest(&digests, &grant.registry, &grant.repository).context("the pushed image's digest was not found")?;
1059+ Ok(json!({
1060+ "image": reference,
1061+ "port": build.port,
1062+ "sizeBytes": size,
1063+ "dockerfile": build.dockerfile,
1064+ "generated": build.generated.as_ref().map(|server| server.id),
1065+ }))
1066+ });
1067+ let _ = std::fs::remove_dir_all(&config);
1068+ let _ = std::fs::remove_file(generated_dockerfile_path());
1069+ pushed.map(Some)
1070+}
1071+
1072+fn build(reporter: &Reporter, log: &mut Log, secrets: &[String]) -> Result<Built> {
7271073 let project = project_dir();
7281074 let dir = project.as_path();
7291075 let custom_build = std::env::var("BUILD_COMMAND").ok();
7301076 let chosen = std::env::var("OUTPUT_DIR").ok().filter(|d| !d.trim().is_empty());
731− let plan = detect(dir, custom_build.as_deref(), chosen.as_deref())?;
732− let framework_id = plan.framework.as_ref().map(|f| f.id);
1077+ let build_as = BuildAs::parse(&std::env::var("BUILD_AS").unwrap_or_default());
1078+ let dockerfile = std::env::var("DOCKERFILE").ok();
1079+ let mut plan = detect_as(dir, custom_build.as_deref(), chosen.as_deref(), build_as, dockerfile.as_deref())?;
7331080 if let Some(framework) = &plan.framework {
7341081 log.line(&format!("Detected {}: framework={}.", framework.name, framework.id));
7351082 }
7371084 if let Some(problem) = &plan.problem {
7381085 bail!("{problem}");
7391086 }
1087+ if plan.container.is_some() {
1088+ match container_build(reporter, log, secrets, dir, &plan)? {
1089+ Some(image) => {
1090+ return Ok(Built {
1091+ worker: json!({}),
1092+ assets_dir: None,
1093+ warnings: Vec::new(),
1094+ detected: "container",
1095+ framework: plan.framework_id(),
1096+ container: Some(image),
1097+ });
1098+ }
1099+ // g1t.page runs no containers yet: the site it is without its
1100+ // Dockerfile, as it was built before.
1101+ None => match plan.fallback.take() {
1102+ Some(site) => {
1103+ log.line(&format!("Building it as a {} site instead, as it is without its Dockerfile.", if site.kind == "html" { "plain HTML" } else { "static" }));
1104+ plan = *site;
1105+ }
1106+ None => bail!("{CONTAINERS_OFF}"),
1107+ },
1108+ }
1109+ }
1110+ let framework_id = plan.framework.as_ref().map(|f| f.id);
7401111 if let Some(install) = &plan.install {
7411112 step(log, install, secrets)?;
7421113 }
7431114 if let Some(command) = &plan.pre_build {
7441115 step(log, command, secrets)?;
7451116 }
746− let own_config = read_wrangler(dir)?;
1117+ // A static site (by **Build as**, or found to be one) never reads a
1118+ // Workers config.
1119+ let workers = plan.kind == "workers";
1120+ let own_config = if workers { read_wrangler(dir)? } else { None };
7471121 // A framework built for Workers may write the config wrangler reads
7481122 // (`@cloudflare/vite-plugin` does, and Astro's adapter with it), with
7491123 // or without one of the project's own.
750− let written = if own_config.is_some() || plan.framework.as_ref().is_some_and(|f| f.workers) {
1124+ let written = if own_config.is_some() || (workers && plan.framework.as_ref().is_some_and(|f| f.workers)) {
7511125 redirected_config(dir)?
7521126 } else {
7531127 None
7551129 if let Some((config, base)) = written {
7561130 log.line(&format!("Using the Workers config the build wrote, in {}.", base.display()));
7571131 let (worker, assets_dir, warnings) = build_worker(log, secrets, config, &base, None)?;
758− return Ok(Built { worker, assets_dir, warnings, detected: "workers", framework: framework_id });
1132+ return Ok(Built { worker, assets_dir, warnings, detected: "workers", framework: framework_id, container: None });
7591133 }
7601134 if let Some(config) = own_config {
7611135 let (worker, assets_dir, warnings) = build_worker(log, secrets, config, dir, None)?;
762− return Ok(Built { worker, assets_dir, warnings, detected: "workers", framework: framework_id });
1136+ return Ok(Built { worker, assets_dir, warnings, detected: "workers", framework: framework_id, container: None });
7631137 }
764− let adapter = plan.framework.as_ref().and_then(|f| f.adapter.as_ref()).filter(|_| chosen.is_none());
1138+ let adapter = plan.framework.as_ref().and_then(|f| f.adapter.as_ref()).filter(|_| workers && chosen.is_none());
7651139 if let Some(adapter) = adapter
7661140 && let Some(entry) = adapter_entry(dir, adapter)
7671141 {
7921166 let built = build_worker(log, secrets, config, dir, Some(&file));
7931167 let _ = std::fs::remove_file(&file);
7941168 let (worker, assets_dir, warnings) = built?;
795− return Ok(Built { worker, assets_dir, warnings, detected: "workers", framework: framework_id });
1169+ return Ok(Built { worker, assets_dir, warnings, detected: "workers", framework: framework_id, container: None });
7961170 }
7971171 if let (Some(adapter), Some(framework)) = (adapter, &plan.framework)
7981172 && framework.output.is_none()
8431217 assets_dir: Some(assets_dir),
8441218 warnings: Vec::new(),
8451219 framework: framework_id,
1220+ container: None,
8461221 })
8471222 }
8481223
9291304 }
9301305 }
9311306 }
932− let built = build(log, secrets)?;
1307+ let built = build(reporter, log, secrets)?;
9331308 let mut finish = json!({
9341309 "worker": built.worker,
9351310 "warnings": built.warnings,
9361311 "detected": built.detected,
937− // Not stored by the deployments service yet, which ignores fields
938− // it does not know; the build's log names it too.
1312+ // What it is built with, shown beside `detected` on its settings.
9391313 "framework": built.framework,
9401314 });
1315+ if let Some(image) = built.container {
1316+ finish["container"] = image;
1317+ }
9411318 for warning in &built.warnings {
9421319 log.line(&format!("Warning: {warning}"));
9431320 }
9951372
9961373 Reads DEPLOY_ID and DEPLOY_TOKEN (from POST /repos/{owner}/{repo}/deployments/builds),
9971374 G1T_API (https://api.g1t.sh), and optionally ROOT_DIR, BUILD_COMMAND, OUTPUT_DIR,
998−BUILD_ENV and BUILD_SECRETS (JSON objects). The g1t/deploy action sets them all.
1375+BUILD_AS (auto, static, workers or docker), DOCKERFILE, and BUILD_ENV and
1376+BUILD_SECRETS (JSON objects). The g1t/deploy action sets them all. A container
1377+is built with the docker on PATH.
9991378
10001379 g1t-runner detect prints what the build would find, as key=value lines.";
10011380
10301409 if step_log {
10311410 let _ = std::fs::remove_dir_all(bundle_dir());
10321411 let _ = std::fs::remove_file(adapter_config_path());
1412+ let _ = std::fs::remove_dir_all(docker_config_dir());
10331413 }
10341414 let (sent, failed) = match outcome {
10351415 Ok(mut finish) => {
10771457 "--root-dir" => root = rest.next().cloned().unwrap_or_default(),
10781458 "--help" | "-h" => {
10791459 println!(
1080− "g1t-runner detect [--root-dir DIR]: prints kind, install, build, output-dir and framework (when one is found) as key=value lines."
1460+ "g1t-runner detect [--root-dir DIR]: prints kind, install, build, output-dir and framework (when one is found), and for a container dockerfile and port, as key=value lines. Reads ROOT_DIR, BUILD_COMMAND, OUTPUT_DIR, BUILD_AS (auto, static, workers or docker) and DOCKERFILE as a build does."
10811461 );
10821462 return 0;
10831463 }
10901470 let dir = project_dir_in(&checkout_dir(), &root);
10911471 let custom = std::env::var("BUILD_COMMAND").ok();
10921472 let output = std::env::var("OUTPUT_DIR").ok();
1093− match detect(&dir, custom.as_deref(), output.as_deref()) {
1473+ let build_as = BuildAs::parse(&std::env::var("BUILD_AS").unwrap_or_default());
1474+ let dockerfile = std::env::var("DOCKERFILE").ok();
1475+ match detect_as(&dir, custom.as_deref(), output.as_deref(), build_as, dockerfile.as_deref()) {
10941476 Ok(found) => {
10951477 print!("{}", output_lines(&found));
10961478 if let Some(problem) = &found.problem {
11171499 one_line(found.build.as_deref()),
11181500 one_line(found.output_dir.as_deref()),
11191501 );
1120− if let Some(framework) = &found.framework {
1121− lines.push_str(&format!("framework={}\n", framework.id));
1502+ if let Some(framework) = found.framework_id() {
1503+ lines.push_str(&format!("framework={framework}\n"));
1504+ }
1505+ // A container's Dockerfile (empty when g1t writes one) and its port.
1506+ if let Some(container) = &found.container {
1507+ lines.push_str(&format!("dockerfile={}\nport={}\n", one_line(container.dockerfile.as_deref()), container.port));
11221508 }
11231509 lines
11241510 }
12621648 output_dir: None,
12631649 problem: None,
12641650 pre_build: None,
1651+ container: None,
1652+ fallback: None,
12651653 };
12661654 assert_eq!(output_lines(&found), "kind=static\ninstall=npm ci\nbuild=a b\noutput-dir=\n");
12671655 let vite = project("lines", &[("package.json", r#"{ "devDependencies": { "vite": "7" } }"#)]);
13611749 Case {
13621750 name: "astro-server",
13631751 files: &[("package.json", r#"{ "dependencies": { "astro": "5", "@astrojs/node": "9" } }"#), ("astro.config.mjs", "export default { output: \"server\" }")],
1364− kind: "none",
1752+ kind: "container",
13651753 framework: Some("astro"),
1366− build: None,
1367− problem: Some("`output: 'static'`"),
1754+ build: Some(GENERATED),
13681755 ..CASE
13691756 },
13701757 Case {
13921779 Case {
13931780 name: "next-server",
13941781 files: &[("package.json", r#"{ "scripts": { "build": "next build" }, "dependencies": { "next": "16" } }"#), ("next.config.mjs", "export default {}")],
1395− kind: "none",
1782+ kind: "container",
13961783 framework: Some("nextjs"),
1397− build: None,
1398− problem: Some("`@opennextjs/cloudflare`"),
1784+ build: Some(GENERATED),
13991785 ..CASE
14001786 },
14011787 Case {
14601846 kind: "none",
14611847 framework: Some("sveltekit"),
14621848 build: None,
1463− problem: Some("`@sveltejs/adapter-static`"),
1849+ problem: Some("Or add a `Dockerfile`"),
14641850 ..CASE
14651851 },
14661852 Case {
14921878 Case {
14931879 name: "react-router-ssr",
14941880 files: &[("package.json", r#"{ "devDependencies": { "@react-router/dev": "7" } }"#), ("react-router.config.ts", "export default { ssr: true }")],
1495− kind: "none",
1881+ kind: "container",
14961882 framework: Some("react-router"),
1497− build: None,
1498− problem: Some("`ssr: false`"),
1883+ build: Some(GENERATED),
14991884 ..CASE
15001885 },
15011886 Case {
16272012 }
16282013 }
16292014
2015+ #[test]
2016+ fn build_as_and_dockerfiles_decide_in_order() {
2017+ let detect_with = |dir: &Path, build_as: BuildAs, dockerfile: Option<&str>| detect_as(dir, None, None, build_as, dockerfile).unwrap();
2018+ // A framework that builds a static site stays one beside a
2019+ // Dockerfile: it costs nothing while no one visits.
2020+ let vite = project("p-vite", &[("package.json", r#"{ "devDependencies": { "vite": "7" } }"#), ("Dockerfile", "FROM nginx\nEXPOSE 80\n")]);
2021+ assert_eq!(detect_with(&vite, BuildAs::Auto, None).kind, "static");
2022+ // Unless the project says Docker.
2023+ let found = detect_with(&vite, BuildAs::Docker, None);
2024+ assert_eq!((found.kind, found.framework_id()), ("container", Some("vite")));
2025+ let container = found.container.unwrap();
2026+ assert_eq!((container.dockerfile.as_deref(), container.port), (Some("Dockerfile"), 80));
2027+ // A Wrangler config wins in Auto, and not when it says Static site.
2028+ let workers = project("p-workers", &[("wrangler.jsonc", r#"{ "assets": { "directory": "public" } }"#), ("index.html", "")]);
2029+ assert_eq!(detect_with(&workers, BuildAs::Auto, None).kind, "workers");
2030+ assert_eq!(detect_with(&workers, BuildAs::Static, None).kind, "html");
2031+ // Workers, without a config or adapter, says what is missing.
2032+ let html = project("p-html", &[("index.html", "<h1>hi</h1>")]);
2033+ let found = detect_with(&html, BuildAs::Workers, None);
2034+ assert_eq!(found.kind, "none");
2035+ assert!(found.problem.unwrap().contains("no Wrangler config"));
2036+ // No framework and a Dockerfile: a container, built as the site it
2037+ // is without one while g1t.page runs no containers.
2038+ let both = project("p-both", &[("index.html", ""), ("Containerfile", "FROM nginx")]);
2039+ let found = detect_with(&both, BuildAs::Auto, None);
2040+ assert_eq!((found.kind, found.framework_id()), ("container", Some("dockerfile")));
2041+ assert_eq!(found.container.as_ref().unwrap().port, 8080);
2042+ assert_eq!(found.fallback.as_ref().map(|site| site.kind), Some("html"));
2043+ // A Dockerfile at a path of the project's choosing.
2044+ let chosen = project("p-chosen", &[("ops/web.Dockerfile", "FROM node\nEXPOSE 3000")]);
2045+ assert_eq!(detect_with(&chosen, BuildAs::Auto, None).kind, "none");
2046+ let found = detect_with(&chosen, BuildAs::Auto, Some("ops/web.Dockerfile"));
2047+ assert_eq!((found.kind, found.container.unwrap().port), ("container", 3000));
2048+ assert!(detect_as(&chosen, None, None, BuildAs::Auto, Some("nope")).is_err());
2049+ // A server g1t recognises gets a Dockerfile written for it.
2050+ let express = project("p-express", &[("package.json", r#"{ "scripts": { "start": "node index.js" }, "dependencies": { "express": "5" } }"#)]);
2051+ let found = detect_with(&express, BuildAs::Auto, None);
2052+ assert_eq!((found.kind, found.framework_id(), found.install.as_deref()), ("container", Some("node"), None));
2053+ assert!(found.fallback.is_none());
2054+ assert!(output_lines(&found).ends_with("framework=node\ndockerfile=\nport=8080\n"));
2055+ // Nothing at all says to add a Dockerfile.
2056+ let nothing = project("p-nothing", &[("README.md", "")]);
2057+ let found = detect_with(&nothing, BuildAs::Auto, None);
2058+ assert_eq!(found.kind, "none");
2059+ assert!(found.problem.as_deref().unwrap().starts_with("found nothing to serve"));
2060+ assert!(found.problem.unwrap().contains("Add a `Dockerfile`"));
2061+ let found = detect_with(&nothing, BuildAs::Docker, None);
2062+ assert!(found.problem.unwrap().contains("**Build as** is Docker"));
2063+ // A framework set up for a server, with a Dockerfile of its own.
2064+ let next = project("p-next", &[("package.json", r#"{ "dependencies": { "next": "16" } }"#), ("Dockerfile", "FROM node")]);
2065+ let found = detect_with(&next, BuildAs::Auto, None);
2066+ assert_eq!((found.kind, found.framework_id()), ("container", Some("nextjs")));
2067+ assert_eq!(found.container.unwrap().dockerfile.as_deref(), Some("Dockerfile"));
2068+ // OpenNext's adapter means Workers: it is not run as a server.
2069+ let opennext = project("p-opennext", &[("package.json", r#"{ "dependencies": { "next": "16", "@opennextjs/cloudflare": "1" } }"#)]);
2070+ assert_eq!(detect_with(&opennext, BuildAs::Auto, None).kind, "none");
2071+ for dir in [vite, workers, html, both, chosen, express, nothing, next, opennext] {
2072+ let _ = std::fs::remove_dir_all(dir);
2073+ }
2074+ }
2075+
2076+ #[test]
2077+ fn the_pushed_digest_is_the_one_in_the_apps_repository() {
2078+ let digest = "a".repeat(64);
2079+ let lines = format!("docker.io/x@sha256:{digest}\nregistry.cloudflare.com/acc/other@sha256:{digest}\nregistry.cloudflare.com/acc/g1t-web@sha256:{digest}\n");
2080+ assert_eq!(
2081+ pushed_digest(&lines, "registry.cloudflare.com", "acc/g1t-web"),
2082+ Some(format!("registry.cloudflare.com/acc/g1t-web@sha256:{digest}"))
2083+ );
2084+ assert_eq!(pushed_digest("registry.cloudflare.com/acc/g1t-web@sha256:short", "registry.cloudflare.com", "acc/g1t-web"), None);
2085+ let grant: RegistryGrant = serde_json::from_value(json!({
2086+ "registry": "registry.cloudflare.com", "repository": "acc/g1t-web", "tag": "dpl_1",
2087+ "username": "u", "password": "p", "max_image_bytes": 2_000_000_000u64, "instance_type": "lite"
2088+ }))
2089+ .unwrap();
2090+ assert_eq!((grant.max_image_bytes, grant.instance_type.as_deref()), (2_000_000_000, Some("lite")));
2091+ assert_eq!(size_text(1_450_000_000), "1.4 GB");
2092+ }
2093+
2094+ /// What a container g1t writes the Dockerfile for is built with.
2095+ const GENERATED: &str = "docker build -f Dockerfile (written by g1t) .";
2096+
16302097 fn frameworks_hugo() -> &'static str {
16312098 "if command -v hugo >/dev/null 2>&1; then hugo --minify; else npx --yes hugo-extended@0.166.0 --minify; fi"
16322099 }
+556−0
1+//! Projects that deploy as a container: their own `Dockerfile` (or
2+//! `Containerfile`, or the path the project chose), or, for a server g1t
3+//! recognises without one, a Dockerfile g1t writes, as `fly launch` does.
4+//! What is built here runs on Cloudflare Containers behind the app's
5+//! Worker (the deployments service's containers.ts). Nothing here runs
6+//! anything: it reads the project and says what to build.
7+
8+use std::path::Path;
9+
10+use anyhow::{Result, bail};
11+use serde_json::Value;
12+
13+/// The port a container listens on when its Dockerfile names none, and
14+/// what every Dockerfile g1t writes listens on. `PORT` says it, too.
15+pub(crate) const DEFAULT_PORT: u16 = 8080;
16+
17+/// What the project's **Build as** setting says it is.
18+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
19+pub(crate) enum BuildAs {
20+ /// Found from its files: see `detect` in deploy.rs.
21+ #[default]
22+ Auto,
23+ /// Built and served as a static site, whatever else it has.
24+ Static,
25+ /// A Workers project: its Wrangler config, or a framework's adapter.
26+ Workers,
27+ /// A container, from its Dockerfile or one g1t writes.
28+ Docker,
29+}
30+
31+impl BuildAs {
32+ /// `BUILD_AS`, as the deployments service sends the setting: `auto`,
33+ /// `static`, `workers` or `docker`; anything else is `auto`.
34+ pub(crate) fn parse(value: &str) -> BuildAs {
35+ match value.trim().to_ascii_lowercase().as_str() {
36+ "static" => BuildAs::Static,
37+ "workers" => BuildAs::Workers,
38+ "docker" | "container" => BuildAs::Docker,
39+ _ => BuildAs::Auto,
40+ }
41+ }
42+}
43+
44+/// The names a project's own Dockerfile is found by, in order.
45+const DOCKERFILES: [&str; 3] = ["Dockerfile", "Containerfile", "dockerfile"];
46+
47+/// The project's own Dockerfile, relative to the project: the path it
48+/// chose (which must exist), else the first of `DOCKERFILES`.
49+pub(crate) fn own_dockerfile(dir: &Path, chosen: Option<&str>) -> Result<Option<String>> {
50+ if let Some(chosen) = chosen.map(|c| c.trim().trim_start_matches("./")).filter(|c| !c.is_empty()) {
51+ if chosen.split('/').any(|part| part == "..") || chosen.starts_with('/') {
52+ bail!("the Dockerfile path `{chosen}` must be inside the project");
53+ }
54+ if !dir.join(chosen).is_file() {
55+ bail!("the Dockerfile `{chosen}` set in the project's settings does not exist");
56+ }
57+ return Ok(Some(chosen.to_owned()));
58+ }
59+ Ok(DOCKERFILES.iter().find(|name| dir.join(name).is_file()).map(|name| (*name).to_owned()))
60+}
61+
62+/// The port a Dockerfile's last stage says it listens on (its first
63+/// `EXPOSE` there), if it says.
64+pub(crate) fn exposed_port(dockerfile: &str) -> Option<u16> {
65+ let mut port = None;
66+ for line in dockerfile.lines() {
67+ let line = line.trim();
68+ let mut words = line.split_whitespace();
69+ let Some(word) = words.next() else { continue };
70+ if word.eq_ignore_ascii_case("FROM") {
71+ // Each stage starts afresh: only the last one's is what runs.
72+ port = None;
73+ } else if word.eq_ignore_ascii_case("EXPOSE") && port.is_none() {
74+ port = words.next().and_then(|p| p.split('/').next()).and_then(|p| p.parse::<u16>().ok()).filter(|p| *p > 0);
75+ }
76+ }
77+ port
78+}
79+
80+/// A server g1t can write a Dockerfile for.
81+#[derive(Debug, Clone, PartialEq, Eq)]
82+pub(crate) struct Server {
83+ /// As `g1t-runner detect` prints it when no framework names it:
84+ /// `node`, `django`, `flask`, `fastapi`, `python`, `go`, `rails`,
85+ /// `rack`, `laravel`, `php`.
86+ pub id: &'static str,
87+ /// For the build's log: `Node.js`, `Django`…
88+ pub name: &'static str,
89+ /// The Dockerfile, whole.
90+ pub dockerfile: String,
91+}
92+
93+/// What a generated Dockerfile leaves out of the image.
94+pub(crate) const DOCKERIGNORE: &str = ".git\nnode_modules\n.venv\n__pycache__\n.wrangler\n.g1t\n";
95+
96+fn header(name: &str) -> String {
97+ format!(
98+ "# Written by g1t for this {name} project, which has no Dockerfile of its own.\n\
99+ # To change how it is built or run, commit this file as `Dockerfile` and edit it.\n"
100+ )
101+}
102+
103+/// `package.json`, as read.
104+fn package(dir: &Path) -> Option<Value> {
105+ serde_json::from_str(&std::fs::read_to_string(dir.join("package.json")).ok()?).ok()
106+}
107+
108+fn has_dependency(package: &Value, name: &str) -> bool {
109+ ["dependencies", "devDependencies"].iter().any(|field| package[field].get(name).is_some())
110+}
111+
112+/// Packages that make a Node.js project a server of its own.
113+const NODE_SERVERS: [&str; 14] = [
114+ "express",
115+ "fastify",
116+ "koa",
117+ "@hapi/hapi",
118+ "@nestjs/core",
119+ "@hono/node-server",
120+ "restify",
121+ "polka",
122+ "@adonisjs/core",
123+ "sails",
124+ "socket.io",
125+ "@remix-run/serve",
126+ "@react-router/serve",
127+ "@feathersjs/feathers",
128+];
129+
130+/// The Node.js version to build with: `.nvmrc` or `.node-version` when it
131+/// names a major release g1t can pull, else 22.
132+fn node_major(dir: &Path) -> u32 {
133+ [".nvmrc", ".node-version"]
134+ .iter()
135+ .find_map(|name| std::fs::read_to_string(dir.join(name)).ok())
136+ .and_then(|text| {
137+ let digits: String = text.trim().trim_start_matches('v').chars().take_while(char::is_ascii_digit).collect();
138+ digits.parse::<u32>().ok()
139+ })
140+ .filter(|major| (18..=30).contains(major))
141+ .unwrap_or(22)
142+}
143+
144+/// How a Node.js project installs, in the image.
145+fn node_install(dir: &Path) -> &'static str {
146+ if dir.join("pnpm-lock.yaml").exists() {
147+ "corepack enable && pnpm install --frozen-lockfile --prod=false"
148+ } else if dir.join("yarn.lock").exists() {
149+ "corepack enable && yarn install"
150+ } else if dir.join("bun.lockb").exists() || dir.join("bun.lock").exists() {
151+ "npx --yes bun install"
152+ } else if dir.join("package-lock.json").exists() {
153+ "npm ci --include=dev"
154+ } else {
155+ "npm install --include=dev"
156+ }
157+}
158+
159+/// A Node.js server's Dockerfile: installed, built if it has a `build`
160+/// script, then started with `start`.
161+fn node_dockerfile(dir: &Path, name: &str, start: &str, build: Option<&str>) -> String {
162+ let build = build.map(|command| format!("RUN {command}\n")).unwrap_or_default();
163+ format!(
164+ "{header}FROM node:{major}-slim\n\
165+ WORKDIR /app\n\
166+ COPY . .\n\
167+ RUN {install}\n\
168+ {build}\
169+ ENV NODE_ENV=production HOST=0.0.0.0 PORT={DEFAULT_PORT}\n\
170+ EXPOSE {DEFAULT_PORT}\n\
171+ CMD {start}\n",
172+ header = header(name),
173+ major = node_major(dir),
174+ install = node_install(dir),
175+ )
176+}
177+
178+/// The command a Node.js server starts with, as a Dockerfile's exec form.
179+fn exec(words: &[&str]) -> String {
180+ let quoted: Vec<String> = words.iter().map(|w| format!("\"{w}\"")).collect();
181+ format!("[{}]", quoted.join(", "))
182+}
183+
184+/// A shell command as a Dockerfile's `CMD`, so `$PORT` is read when it starts.
185+fn shell(command: &str) -> String {
186+ exec(&["sh", "-c", &format!("exec {command}")])
187+}
188+
189+/// A Dockerfile for a framework that needs a server (`framework`, from
190+/// frameworks.rs), when g1t knows how to run it: Next.js with `next
191+/// start`, Astro with `@astrojs/node`, SvelteKit with `adapter-node`,
192+/// React Router and Remix with their own server.
193+pub(crate) fn for_framework(dir: &Path, framework: &str) -> Option<Server> {
194+ let package = package(dir)?;
195+ let scripts = &package["scripts"];
196+ let script = |name: &str| scripts[name].as_str();
197+ let has_build = script("build").is_some();
198+ let start = |fallback: &[&str]| if script("start").is_some() { exec(&["npm", "start"]) } else { exec(fallback) };
199+ let (name, command, own_build) = match framework {
200+ "nextjs" => ("Next.js", start(&["npx", "next", "start"]), "npx next build"),
201+ // `astro dev` is a common `start` script, so Astro's node adapter's
202+ // own server is run instead.
203+ "astro" if has_dependency(&package, "@astrojs/node") => ("Astro", exec(&["node", "./dist/server/entry.mjs"]), "npx astro build"),
204+ "sveltekit" if has_dependency(&package, "@sveltejs/adapter-node") => ("SvelteKit", exec(&["node", "build"]), "npx vite build"),
205+ "react-router" => ("React Router", start(&["npx", "react-router-serve", "./build/server/index.js"]), "npx react-router build"),
206+ "remix" => ("Remix", start(&["npx", "remix-serve", "./build/server/index.js"]), "npx remix vite:build"),
207+ _ => return None,
208+ };
209+ // Its own `build` script, else the framework's command.
210+ let build = if has_build { "npm run build" } else { own_build };
211+ Some(Server { id: "node", name, dockerfile: node_dockerfile(dir, name, &command, Some(build)) })
212+}
213+
214+/// A server g1t recognises in a project with no framework it knows, and
215+/// the Dockerfile it writes for it.
216+pub(crate) fn server(dir: &Path) -> Option<Server> {
217+ node_server(dir).or_else(|| python(dir)).or_else(|| go(dir)).or_else(|| ruby(dir)).or_else(|| php(dir))
218+}
219+
220+/// A Node.js server: a `start` script, with a server package or nothing
221+/// that looks like a static site (no `build` script and no `index.html`).
222+fn node_server(dir: &Path) -> Option<Server> {
223+ let package = package(dir)?;
224+ let scripts = &package["scripts"];
225+ let server_package = NODE_SERVERS.iter().any(|name| has_dependency(&package, name));
226+ let has_build = scripts["build"].is_string();
227+ let start = if scripts["start"].is_string() {
228+ exec(&["npm", "start"])
229+ } else if server_package {
230+ let main = package["main"].as_str().map(str::to_owned).or_else(|| {
231+ ["server.js", "index.js", "app.js", "server.mjs", "index.mjs"].iter().find(|f| dir.join(f).is_file()).map(|f| (*f).to_owned())
232+ })?;
233+ exec(&["node", &main])
234+ } else {
235+ return None;
236+ };
237+ if !server_package && (has_build || dir.join("index.html").exists()) {
238+ return None;
239+ }
240+ Some(Server { id: "node", name: "Node.js", dockerfile: node_dockerfile(dir, "Node.js", &start, has_build.then_some("npm run build")) })
241+}
242+
243+/// A Python web app: its requirements, and the file that starts it.
244+fn python(dir: &Path) -> Option<Server> {
245+ let requirements = ["requirements.txt", "pyproject.toml", "Pipfile"].iter().find(|f| dir.join(f).is_file())?;
246+ let text = ["requirements.txt", "pyproject.toml", "Pipfile"]
247+ .iter()
248+ .filter_map(|f| std::fs::read_to_string(dir.join(f)).ok())
249+ .collect::<Vec<_>>()
250+ .join("\n")
251+ .to_ascii_lowercase();
252+ let uses = |name: &str| {
253+ text.lines().any(|line| {
254+ let line = line.trim().trim_start_matches(['"', '\'']);
255+ line.starts_with(name) && !line[name.len()..].starts_with(|c: char| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'))
256+ })
257+ };
258+ let install = match *requirements {
259+ "requirements.txt" => "pip install --no-cache-dir -r requirements.txt",
260+ "Pipfile" => "pip install --no-cache-dir pipenv && pipenv install --system --deploy",
261+ _ => "pip install --no-cache-dir .",
262+ };
263+ let module = ["app", "main", "server", "wsgi", "asgi"].iter().find(|m| dir.join(format!("{m}.py")).is_file()).copied();
264+ let (id, name, extra, command) = if dir.join("manage.py").is_file() {
265+ // Django: the project's package is the folder with its `wsgi.py`.
266+ let project = std::fs::read_dir(dir)
267+ .ok()?
268+ .flatten()
269+ .find(|entry| entry.path().join("wsgi.py").is_file())?
270+ .file_name()
271+ .to_string_lossy()
272+ .into_owned();
273+ ("django", "Django", "gunicorn", format!("gunicorn --bind 0.0.0.0:$PORT {project}.wsgi"))
274+ } else if uses("fastapi") || uses("starlette") {
275+ let module = module?;
276+ ("fastapi", "FastAPI", "uvicorn", format!("uvicorn {module}:app --host 0.0.0.0 --port $PORT"))
277+ } else if uses("flask") {
278+ let module = module?;
279+ ("flask", "Flask", "gunicorn", format!("gunicorn --bind 0.0.0.0:$PORT {module}:app"))
280+ } else {
281+ let module = module?;
282+ ("python", "Python", "", format!("python {module}.py"))
283+ };
284+ let extra = if extra.is_empty() || uses(extra) { String::new() } else { format!(" && pip install --no-cache-dir {extra}") };
285+ let dockerfile = format!(
286+ "{header}FROM python:3.12-slim\n\
287+ WORKDIR /app\n\
288+ ENV PYTHONUNBUFFERED=1 PORT={DEFAULT_PORT}\n\
289+ COPY . .\n\
290+ RUN {install}{extra}\n\
291+ EXPOSE {DEFAULT_PORT}\n\
292+ CMD {cmd}\n",
293+ header = header(name),
294+ cmd = shell(&command),
295+ );
296+ Some(Server { id, name, dockerfile })
297+}
298+
299+/// Whether a Go file is `package main`.
300+fn is_main(path: &Path) -> bool {
301+ std::fs::read_to_string(path).is_ok_and(|text| {
302+ text.lines().map(str::trim).find(|line| line.starts_with("package ")).is_some_and(|line| line == "package main")
303+ })
304+}
305+
306+fn has_main(dir: &Path) -> bool {
307+ std::fs::read_dir(dir).is_ok_and(|entries| {
308+ entries.flatten().any(|entry| {
309+ let path = entry.path();
310+ path.extension().is_some_and(|e| e == "go") && !path.to_string_lossy().ends_with("_test.go") && is_main(&path)
311+ })
312+ })
313+}
314+
315+/// A Go program: `go.mod`, and a `package main` at the top or in one
316+/// folder of `cmd/`.
317+fn go(dir: &Path) -> Option<Server> {
318+ if !dir.join("go.mod").is_file() {
319+ return None;
320+ }
321+ let target = if has_main(dir) {
322+ ".".to_owned()
323+ } else {
324+ let mut commands: Vec<String> = std::fs::read_dir(dir.join("cmd"))
325+ .ok()?
326+ .flatten()
327+ .filter(|entry| has_main(&entry.path()))
328+ .map(|entry| format!("./cmd/{}", entry.file_name().to_string_lossy()))
329+ .collect();
330+ commands.sort();
331+ // More than one program: which to run is the project's to say.
332+ if commands.len() != 1 {
333+ return None;
334+ }
335+ commands.remove(0)
336+ };
337+ let dockerfile = format!(
338+ "{header}FROM golang:1.25 AS build\n\
339+ WORKDIR /src\n\
340+ COPY . .\n\
341+ RUN go mod download && CGO_ENABLED=0 go build -trimpath -ldflags=-s -o /out/app {target}\n\
342+ \n\
343+ FROM scratch\n\
344+ COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/\n\
345+ COPY --from=build /out/app /app\n\
346+ ENV PORT={DEFAULT_PORT}\n\
347+ EXPOSE {DEFAULT_PORT}\n\
348+ ENTRYPOINT [\"/app\"]\n",
349+ header = header("Go"),
350+ );
351+ Some(Server { id: "go", name: "Go", dockerfile })
352+}
353+
354+/// A Ruby web app: Rails, or any Rack app with a `config.ru`.
355+fn ruby(dir: &Path) -> Option<Server> {
356+ let gemfile = std::fs::read_to_string(dir.join("Gemfile")).ok()?;
357+ let rails = gemfile.lines().any(|line| {
358+ let line = line.trim();
359+ line.starts_with("gem \"rails\"") || line.starts_with("gem 'rails'")
360+ });
361+ let (id, name, setup, command) = if rails {
362+ (
363+ "rails",
364+ "Rails",
365+ "RUN if bundle exec rails -T assets:precompile 2>/dev/null | grep -q assets:precompile; then SECRET_KEY_BASE_DUMMY=1 bundle exec rails assets:precompile; fi\n",
366+ "bundle exec rails server -b 0.0.0.0 -p $PORT",
367+ )
368+ } else if dir.join("config.ru").is_file() {
369+ ("rack", "Rack", "", "bundle exec rackup -o 0.0.0.0 -p $PORT")
370+ } else {
371+ return None;
372+ };
373+ let dockerfile = format!(
374+ "{header}FROM ruby:3.3-slim\n\
375+ RUN apt-get update && apt-get install -y --no-install-recommends build-essential git libpq-dev libyaml-dev pkg-config && rm -rf /var/lib/apt/lists/*\n\
376+ WORKDIR /app\n\
377+ ENV RAILS_ENV=production RACK_ENV=production BUNDLE_WITHOUT=development:test RAILS_LOG_TO_STDOUT=1 RAILS_SERVE_STATIC_FILES=1 PORT={DEFAULT_PORT}\n\
378+ COPY . .\n\
379+ RUN bundle install\n\
380+ {setup}\
381+ EXPOSE {DEFAULT_PORT}\n\
382+ CMD {cmd}\n",
383+ header = header(name),
384+ cmd = shell(command),
385+ );
386+ Some(Server { id, name, dockerfile })
387+}
388+
389+/// A PHP app with Composer: Laravel, or any app with an `index.php`.
390+fn php(dir: &Path) -> Option<Server> {
391+ if !dir.join("composer.json").is_file() {
392+ return None;
393+ }
394+ let (id, name) = if dir.join("artisan").is_file() { ("laravel", "Laravel") } else { ("php", "PHP") };
395+ let root = if dir.join("public/index.php").is_file() {
396+ "/var/www/html/public"
397+ } else if dir.join("index.php").is_file() {
398+ "/var/www/html"
399+ } else {
400+ return None;
401+ };
402+ let dockerfile = format!(
403+ "{header}FROM composer:2 AS vendor\n\
404+ WORKDIR /app\n\
405+ COPY . .\n\
406+ RUN composer install --no-dev --prefer-dist --no-interaction --optimize-autoloader --ignore-platform-reqs\n\
407+ \n\
408+ FROM php:8.3-apache\n\
409+ ENV PORT={DEFAULT_PORT} APACHE_DOCUMENT_ROOT={root}\n\
410+ RUN sed -ri -e 's!/var/www/html!${{APACHE_DOCUMENT_ROOT}}!g' /etc/apache2/sites-available/*.conf /etc/apache2/apache2.conf \\\n \
411+ && sed -ri -e 's/^Listen 80$/Listen ${{PORT}}/' /etc/apache2/ports.conf \\\n \
412+ && sed -ri -e 's/:80>/:${{PORT}}>/' /etc/apache2/sites-available/000-default.conf \\\n \
413+ && a2enmod rewrite\n\
414+ COPY --from=vendor --chown=www-data:www-data /app /var/www/html\n\
415+ EXPOSE {DEFAULT_PORT}\n",
416+ header = header(name),
417+ );
418+ Some(Server { id, name, dockerfile })
419+}
420+
421+#[cfg(test)]
422+mod tests {
423+ use super::*;
424+
425+ fn project(name: &str, files: &[(&str, &str)]) -> std::path::PathBuf {
426+ let dir = std::env::temp_dir().join(format!("g1t-container-{name}-{}", std::process::id()));
427+ let _ = std::fs::remove_dir_all(&dir);
428+ std::fs::create_dir_all(&dir).unwrap();
429+ for (path, text) in files {
430+ let path = dir.join(path);
431+ std::fs::create_dir_all(path.parent().unwrap()).unwrap();
432+ std::fs::write(path, text).unwrap();
433+ }
434+ dir
435+ }
436+
437+ #[test]
438+ fn build_as_is_read_from_the_setting() {
439+ assert_eq!(BuildAs::parse("docker"), BuildAs::Docker);
440+ assert_eq!(BuildAs::parse(" Static "), BuildAs::Static);
441+ assert_eq!(BuildAs::parse("workers"), BuildAs::Workers);
442+ assert_eq!(BuildAs::parse(""), BuildAs::Auto);
443+ assert_eq!(BuildAs::parse("something"), BuildAs::Auto);
444+ }
445+
446+ #[test]
447+ fn a_dockerfile_is_found_by_its_names_or_the_chosen_path() {
448+ let dir = project("own", &[("Containerfile", "FROM scratch"), ("deploy/web.Dockerfile", "FROM scratch")]);
449+ assert_eq!(own_dockerfile(&dir, None).unwrap().as_deref(), Some("Containerfile"));
450+ assert_eq!(own_dockerfile(&dir, Some("./deploy/web.Dockerfile")).unwrap().as_deref(), Some("deploy/web.Dockerfile"));
451+ assert!(own_dockerfile(&dir, Some("missing/Dockerfile")).unwrap_err().to_string().contains("does not exist"));
452+ assert!(own_dockerfile(&dir, Some("../etc/passwd")).is_err());
453+ assert_eq!(own_dockerfile(&project("none", &[]), None).unwrap(), None);
454+ }
455+
456+ #[test]
457+ fn the_port_is_the_last_stages_first_expose() {
458+ assert_eq!(exposed_port("FROM node\nEXPOSE 3000\nEXPOSE 9229"), Some(3000));
459+ assert_eq!(exposed_port("FROM golang AS build\nEXPOSE 9000\nFROM scratch\nexpose 8000/tcp"), Some(8000));
460+ assert_eq!(exposed_port("FROM golang AS build\nEXPOSE 9000\nFROM scratch\n"), None);
461+ assert_eq!(exposed_port("FROM x\nEXPOSE $PORT"), None);
462+ }
463+
464+ #[test]
465+ fn node_servers_are_recognised_and_static_sites_are_not() {
466+ let express = project("express", &[("package.json", r#"{ "scripts": { "start": "node server.js" }, "dependencies": { "express": "5" } }"#), ("package-lock.json", "{}")]);
467+ let found = server(&express).unwrap();
468+ assert_eq!((found.id, found.name), ("node", "Node.js"));
469+ assert!(found.dockerfile.contains("FROM node:22-slim"));
470+ assert!(found.dockerfile.contains("RUN npm ci --include=dev"));
471+ assert!(found.dockerfile.contains("CMD [\"npm\", \"start\"]"));
472+ assert!(found.dockerfile.contains("EXPOSE 8080"));
473+ assert!(!found.dockerfile.contains("npm run build"));
474+ // A server package without a start script runs its main file.
475+ let main = project("main", &[("package.json", r#"{ "main": "src/app.js", "dependencies": { "fastify": "5" } }"#), (".nvmrc", "v20.11\n")]);
476+ let found = server(&main).unwrap();
477+ assert!(found.dockerfile.contains("CMD [\"node\", \"src/app.js\"]"));
478+ assert!(found.dockerfile.contains("FROM node:20-slim"));
479+ // A start script beside a build script and no server package is a
480+ // static site's preview server, not an app.
481+ let site = project("site", &[("package.json", r#"{ "scripts": { "build": "x", "start": "serve dist" } }"#)]);
482+ assert_eq!(server(&site), None);
483+ assert_eq!(server(&project("bare", &[("package.json", "{}")])), None);
484+ }
485+
486+ #[test]
487+ fn python_apps_get_the_server_their_framework_runs_on() {
488+ let flask = project("flask", &[("requirements.txt", "Flask==3.1\n"), ("app.py", "")]);
489+ let found = server(&flask).unwrap();
490+ assert_eq!(found.id, "flask");
491+ assert!(found.dockerfile.contains("pip install --no-cache-dir -r requirements.txt && pip install --no-cache-dir gunicorn"));
492+ assert!(found.dockerfile.contains(r#"CMD ["sh", "-c", "exec gunicorn --bind 0.0.0.0:$PORT app:app"]"#));
493+ let fastapi = project("fastapi", &[("requirements.txt", "fastapi\nuvicorn[standard]\n"), ("main.py", "")]);
494+ let found = server(&fastapi).unwrap();
495+ assert_eq!(found.id, "fastapi");
496+ assert!(!found.dockerfile.contains("pip install --no-cache-dir uvicorn"), "already required");
497+ assert!(found.dockerfile.contains("uvicorn main:app --host 0.0.0.0 --port $PORT"));
498+ let django = project("django", &[("requirements.txt", "Django>=5\ngunicorn\n"), ("manage.py", ""), ("shop/wsgi.py", "")]);
499+ let found = server(&django).unwrap();
500+ assert_eq!(found.id, "django");
501+ assert!(found.dockerfile.contains("gunicorn --bind 0.0.0.0:$PORT shop.wsgi"));
502+ // Requirements without an app to start (a docs site's) are not one.
503+ assert_eq!(server(&project("mkdocs", &[("requirements.txt", "mkdocs\n"), ("mkdocs.yml", "")])), None);
504+ // flask-cors is not Flask.
505+ let cors = project("cors", &[("requirements.txt", "flask-cors\n"), ("app.py", "")]);
506+ assert_eq!(server(&cors).unwrap().id, "python");
507+ }
508+
509+ #[test]
510+ fn go_ruby_and_php_apps_are_recognised() {
511+ let go_root = project("go", &[("go.mod", "module x\n"), ("main.go", "// x\npackage main\n"), ("main_test.go", "package main")]);
512+ let found = server(&go_root).unwrap();
513+ assert_eq!(found.id, "go");
514+ assert!(found.dockerfile.contains("go build -trimpath -ldflags=-s -o /out/app .\n"));
515+ assert!(found.dockerfile.contains("FROM scratch"));
516+ let go_cmd = project("gocmd", &[("go.mod", "module x\n"), ("lib.go", "package x"), ("cmd/api/main.go", "package main")]);
517+ assert!(server(&go_cmd).unwrap().dockerfile.contains("-o /out/app ./cmd/api\n"));
518+ let go_two = project("gotwo", &[("go.mod", "module x\n"), ("cmd/a/main.go", "package main"), ("cmd/b/main.go", "package main")]);
519+ assert_eq!(server(&go_two), None, "which program runs is the project's to say");
520+
521+ let rails = project("rails", &[("Gemfile", "source 'https://rubygems.org'\ngem \"rails\", \"~> 8.0\"\n"), ("config.ru", "")]);
522+ let found = server(&rails).unwrap();
523+ assert_eq!(found.id, "rails");
524+ assert!(found.dockerfile.contains("bundle exec rails server -b 0.0.0.0 -p $PORT"));
525+ let rack = project("rack", &[("Gemfile", "gem 'sinatra'\n"), ("config.ru", "run App")]);
526+ assert_eq!(server(&rack).unwrap().id, "rack");
527+ assert_eq!(server(&project("gems", &[("Gemfile", "gem 'rake'\n")])), None);
528+
529+ let laravel = project("laravel", &[("composer.json", "{}"), ("artisan", ""), ("public/index.php", "")]);
530+ let found = server(&laravel).unwrap();
531+ assert_eq!(found.id, "laravel");
532+ assert!(found.dockerfile.contains("APACHE_DOCUMENT_ROOT=/var/www/html/public"));
533+ assert!(found.dockerfile.contains("Listen ${PORT}"));
534+ assert_eq!(server(&project("composer", &[("composer.json", "{}")])), None);
535+ }
536+
537+ #[test]
538+ fn frameworks_that_need_a_server_get_one() {
539+ let next = project("next", &[("package.json", r#"{ "scripts": { "build": "next build", "start": "next start" }, "dependencies": { "next": "16" } }"#)]);
540+ let found = for_framework(&next, "nextjs").unwrap();
541+ assert!(found.dockerfile.contains("RUN npm run build\n"));
542+ assert!(found.dockerfile.contains("CMD [\"npm\", \"start\"]"));
543+ let bare = project("next-bare", &[("package.json", r#"{ "dependencies": { "next": "16" } }"#)]);
544+ let found = for_framework(&bare, "nextjs").unwrap();
545+ assert!(found.dockerfile.contains("RUN npx next build\n"));
546+ assert!(found.dockerfile.contains("CMD [\"npx\", \"next\", \"start\"]"));
547+ let astro = project("astro-node", &[("package.json", r#"{ "scripts": { "build": "astro build", "start": "astro dev" }, "dependencies": { "astro": "5", "@astrojs/node": "9" } }"#)]);
548+ assert!(for_framework(&astro, "astro").unwrap().dockerfile.contains("CMD [\"node\", \"./dist/server/entry.mjs\"]"));
549+ let kit = project("kit-auto", &[("package.json", r#"{ "devDependencies": { "@sveltejs/kit": "2", "@sveltejs/adapter-auto": "6" } }"#)]);
550+ assert_eq!(for_framework(&kit, "sveltekit"), None, "adapter-auto has no server to run");
551+ let rr = project("rr", &[("package.json", r#"{ "devDependencies": { "@react-router/dev": "7" } }"#)]);
552+ let found = for_framework(&rr, "react-router").unwrap();
553+ assert!(found.dockerfile.contains("RUN npx react-router build\n"));
554+ assert!(found.dockerfile.contains("react-router-serve"));
555+ }
556+}
+39−0
6969 let body: Value = serde_json::from_slice(&body).unwrap_or(Value::Null);
7070 let answer = if path.ends_with("/session") {
7171 json!({ "ok": true, "value": { "jwt": "complete-me", "buckets": [], "upload_url": "http://127.0.0.1:9/never" } })
72+ } else if path.ends_with("/registry") {
73+ // As the deployments service answers while it runs no containers.
74+ json!({ "ok": false, "error": { "code": "not_enabled", "message": "Docker deployments are not on." } })
7275 } else {
7376 json!({ "ok": true, "value": true })
7477 };
150153 assert_eq!(output.status.code(), Some(2));
151154 assert!(String::from_utf8_lossy(&output.stderr).contains("DEPLOY_ID"));
152155 }
156+
157+#[test]
158+fn a_container_is_built_as_its_site_while_g1t_page_runs_none() {
159+ let (api, seen) = fake_api();
160+ let dir = checkout("container-site", &[("index.html", "<h1>hello</h1>"), ("Dockerfile", "FROM nginx")]);
161+ let output = deploy(&dir, &api, &[]);
162+ let stdout = String::from_utf8_lossy(&output.stdout);
163+ assert!(output.status.success(), "{stdout}
164+{}", String::from_utf8_lossy(&output.stderr));
165+ assert!(stdout.contains("as a plain HTML site instead"), "{stdout}");
166+ let seen = seen.lock().unwrap();
167+ let steps: Vec<&str> = seen.iter().map(|(path, _)| path.as_str()).collect();
168+ assert_eq!(steps[1], "/deployments/jobs/dpl_1/registry");
169+ assert_eq!(seen.last().unwrap().1["detected"], "html");
170+ // A server with nothing else to serve fails, saying so.
171+ drop(seen);
172+ let server = checkout("container-only", &[("package.json", r#"{ "scripts": { "start": "node index.js" }, "dependencies": { "express": "5" } }"#)]);
173+ let output = deploy(&server, &api, &[]);
174+ assert_eq!(output.status.code(), Some(1));
175+ assert!(String::from_utf8_lossy(&output.stdout).contains("Docker deployments are not switched on"));
176+ // Build as Static site skips the Dockerfile altogether.
177+ let output = deploy(&dir, &api, &[("BUILD_AS", "static")]);
178+ assert!(output.status.success());
179+ let detect = runner().arg("detect").current_dir(&server).env_remove("GIT_REMOTE").output().unwrap();
180+ assert_eq!(
181+ String::from_utf8_lossy(&detect.stdout),
182+ "kind=container
183+install=
184+build=docker build -f Dockerfile (written by g1t) .
185+output-dir=
186+framework=node
187+dockerfile=
188+port=8080
189+"
190+ );
191+}

Comments

Sign in to comment.