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 project with a site and a Dockerfile deploys as a container when the Dockerfile runs a server (CMD, ENTRYPOINT, or EXPOSE on an image that is not only a file server), and as the site otherwise, and the build's log says which and why; before, any framework that builds a site ignored the Dockerfile, so a Vite front end with its own server lost its back end, and a bare index.html beside an nginx Dockerfile asked for a container. A Dockerfile's port is its EXPOSE, else the PORT it sets with ENV; Next.js's standalone output runs its own server.js; Rust servers on axum, actix-web and the like get a Dockerfile; images are built without a provenance attestation, as wrangler builds them, so the digest pushed is the image's own; and the messages that said g1t.page runs no servers say what to change instead.

syntaqxcommitted Parent1f5404eBrowse files
5 files+396−540/5 viewed
+115−26
655655 /// For a container: its Dockerfile, or the one g1t writes.
656656 pub container: Option<ContainerBuild>,
657657 /// What it would be served as without its Dockerfile, when it is a
658− /// container only for having one: built so while g1t.page runs no
659− /// containers (`not_enabled` from the registry step).
658+ /// container only for having one: built so where Docker deployments
659+ /// are switched off (`not_enabled` from the registry step).
660660 fallback: Option<Box<Detected>>,
661+ /// Why it is built as this and not as the other thing it could be (a
662+ /// site beside a Dockerfile), for the build's log.
663+ pub reason: Option<String>,
661664 }
662665
663666 /// How a container is built.
685688 pre_build: None,
686689 container: None,
687690 fallback: None,
691+ reason: None,
688692 }
689693 }
690694
733737 pre_build: None,
734738 container: Some(build),
735739 fallback: None,
740+ reason: None,
736741 })
737742 }
738743
747752 /// - **Docker**: a container, from its Dockerfile or one g1t writes.
748753 /// - **Static site**: built and served as files, whatever else it has.
749754 /// - **Workers**: its Wrangler config or a framework adapter's Worker.
750−/// - **Auto**: a Wrangler config is a Workers project; a framework that
751−/// builds a static site or a Worker is that, which costs nothing while
752−/// no one visits; a framework set up to need a server is a container
755+/// - **Auto**, in order: a Wrangler config (or a framework's Workers
756+/// adapter) is a Workers project; a site beside a Dockerfile of the
757+/// project's own is a container when the Dockerfile runs a server
758+/// (`container::runs_server`: `CMD`, `ENTRYPOINT`, or `EXPOSE` on an
759+/// image that is not only a file server), else the site, which costs
760+/// nothing while no one visits; any other framework that builds a site
761+/// is that site; a framework set up to need a server is a container
753762 /// when it has a Dockerfile, or when g1t can write one; a project with
754763 /// no framework g1t knows is a container when it has a Dockerfile or is
755−/// a server g1t recognises (a Node.js, Python, Go, Ruby or PHP app),
756−/// else a static site, else nothing, saying to add a Dockerfile.
764+/// a server g1t recognises (a Node.js, Python, Go, Ruby, PHP or Rust
765+/// app), else a static site, else nothing, saying to add a Dockerfile.
766+/// Each choice between a site and a container says why (`reason`).
757767 pub(crate) fn detect_as(
758768 dir: &Path,
759769 custom_build: Option<&str>,
791801 }
792802 BuildAs::Auto => {
793803 let site = detect_site(dir, custom_build, chosen_output, false)?;
794− if site.kind == "workers" || (site.framework.is_some() && site.problem.is_none()) {
804+ if site.kind == "workers" {
795805 return Ok(site);
796806 }
807+ // A site beside a Dockerfile of the project's own: the
808+ // Dockerfile, when it runs a server; else the site, which g1t
809+ // serves for nothing while no one visits.
810+ let serves = site.problem.is_none() && (site.kind == "static" || site.kind == "html");
811+ if serves && let Some(path) = &own {
812+ let text = std::fs::read_to_string(dir.join(path)).unwrap_or_default();
813+ let (server, why) = container::runs_server(&text);
814+ let what = if site.kind == "html" { "plain HTML site" } else { "static site" };
815+ if !server {
816+ let mut site = site;
817+ site.reason = Some(format!(
818+ "Building it as a {what}, not from {path}: {why}, and g1t serves the site itself. \
819+ Set **Build as** to Docker to run the container instead."
820+ ));
821+ return Ok(site);
822+ }
823+ let mut found = as_container(dir, site.framework.clone(), own.clone()).context("its Dockerfile was not read")?;
824+ found.reason = Some(format!(
825+ "Building it as a container from {path}, not as the {what} it also has: {why}. \
826+ Set **Build as** to Static site to serve the site instead."
827+ ));
828+ found.fallback = Some(Box::new(site));
829+ return Ok(found);
830+ }
831+ if site.framework.is_some() && site.problem.is_none() {
832+ return Ok(site);
833+ }
797834 if let Some(problem) = site.problem.clone() {
798835 // A framework set up to need a server.
799836 let found = as_container(dir, site.framework.clone(), own);
801838 Detected::nothing(site.framework, site.install, format!("{problem} Or add a `Dockerfile` to run it as a container."))
802839 }));
803840 }
804− let has_own = own.is_some();
841+ // A Dockerfile with nothing else to serve, or a server g1t
842+ // recognises, which it writes one for.
805843 if let Some(mut found) = as_container(dir, None, own) {
806− // A Dockerfile beside a site that could be served as it is.
807− if has_own && (site.kind == "static" || site.kind == "html") {
808− found.fallback = Some(Box::new(site));
844+ if let (Some(server), Some(folder)) = (found.container.as_ref().and_then(|c| c.generated.as_ref()), &site.output_dir) {
845+ found.reason = Some(format!(
846+ "Building it as a {} server, not as a site of `{folder}`. Set **Build as** to Static site to serve only `{folder}` instead.",
847+ server.name
848+ ));
809849 }
810850 return Ok(found);
811851 }
848888 pre_build,
849889 container: None,
850890 fallback: None,
891+ reason: None,
851892 });
852893 }
853894 let build = custom_build
868909 problem: None,
869910 container: None,
870911 fallback: None,
912+ reason: None,
871913 });
872914 }
873915 // A command or a folder of the project's own says it knows better, as
889931 (None, None) => "none",
890932 _ => "static",
891933 };
892− Ok(Detected { kind, framework, install, pre_build: build.clone(), build, output_dir, problem: None, container: None, fallback: None })
934+ Ok(Detected { kind, framework, install, pre_build: build.clone(), build, output_dir, problem: None, container: None, fallback: None, reason: None })
893935 }
894936
895937 /// The compatibility date a Worker gets when its config names none.
936978 bundle.with_file_name(format!("{}.wrangler.json", bundle.file_name().unwrap_or_default().to_string_lossy()))
937979 }
938980
939−/// What a container build says when g1t.page runs no containers.
940−const CONTAINERS_OFF: &str = "This project deploys as a container, and Docker deployments are not switched on for g1t.page yet. \
981+/// What a container build says where Docker deployments are switched off
982+/// (a self-hosted g1t with `CONTAINERS` set to `off`).
983+const CONTAINERS_OFF: &str = "This project deploys as a container, and Docker deployments are switched off on this g1t. \
941984 Set **Build as** to Static site or Workers under Settings, Deployments if it can be built as one.";
942985
943986 /// Where a container's registry login is kept for the build: its own
10631106 std::fs::create_dir_all(&config)?;
10641107 let image = format!("{}/{}:{}", grant.registry, grant.repository, grant.tag);
10651108 let quote = |path: &Path| format!("'{}'", path.display().to_string().replace('\'', r"'\''"));
1109+ // One image for one platform, with no attestation beside it: what
1110+ // `wrangler containers build` pushes, so the digest pushed is the
1111+ // image's own manifest, not an index Containers would have to choose from.
10661112 let built = step(
10671113 log,
1068− &format!("docker build --platform linux/amd64 --pull -f {} -t {image} .", quote(&dockerfile)),
1114+ &format!("docker build --platform linux/amd64 --provenance=false --pull -f {} -t {image} .", quote(&dockerfile)),
10691115 &secrets,
10701116 );
10711117 let pushed = built.and_then(|()| {
11091155 if let Some(framework) = &plan.framework {
11101156 log.line(&format!("Detected {}: framework={}.", framework.name, framework.id));
11111157 }
1158+ if let Some(reason) = &plan.reason {
1159+ log.line(reason);
1160+ }
11121161 // Said before anything is installed: it would fail after, less clearly.
11131162 if let Some(problem) = &plan.problem {
11141163 bail!("{problem}");
11251174 container: Some(image),
11261175 });
11271176 }
1128− // g1t.page runs no containers yet: the site it is without its
1129− // Dockerfile, as it was built before.
1177+ // Docker deployments are switched off here: the site it is
1178+ // without its Dockerfile.
11301179 None => match plan.fallback.take() {
11311180 Some(site) => {
11321181 log.line(&format!("Building it as a {} site instead, as it is without its Dockerfile.", if site.kind == "html" { "plain HTML" } else { "static" }));
15801629
15811630 #[test]
15821631 fn a_refusal_reaches_the_step_with_its_reason() {
1583− let base = answering("409 Conflict", r#"{"ok":false,"error":{"code":"not_enabled","message":"Docker deployments are not switched on for g1t.page yet."}}"#);
1632+ let base = answering("409 Conflict", r#"{"ok":false,"error":{"code":"not_enabled","message":"Docker deployments are switched off on this g1t."}}"#);
15841633 let answer = Reporter { base, token: "t".into() }.send("registry", json!({})).unwrap();
15851634 assert_eq!(answer["ok"], false);
15861635 assert_eq!(answer["error"]["code"], "not_enabled");
17301779 pre_build: None,
17311780 container: None,
17321781 fallback: None,
1782+ reason: None,
17331783 };
17341784 assert_eq!(output_lines(&found), "kind=static\ninstall=npm ci\nbuild=a b\noutput-dir=\n");
17351785 let vite = project("lines", &[("package.json", r#"{ "devDependencies": { "vite": "7" } }"#)]);
20962146 fn build_as_and_dockerfiles_decide_in_order() {
20972147 let detect_with = |dir: &Path, build_as: BuildAs, dockerfile: Option<&str>| detect_as(dir, None, None, build_as, dockerfile).unwrap();
20982148 // A framework that builds a static site stays one beside a
2099− // Dockerfile: it costs nothing while no one visits.
2149+ // Dockerfile that only serves its files: it costs nothing while no
2150+ // one visits.
21002151 let vite = project("p-vite", &[("package.json", r#"{ "devDependencies": { "vite": "7" } }"#), ("Dockerfile", "FROM nginx\nEXPOSE 80\n")]);
2101− assert_eq!(detect_with(&vite, BuildAs::Auto, None).kind, "static");
2152+ let found = detect_with(&vite, BuildAs::Auto, None);
2153+ assert_eq!(found.kind, "static");
2154+ assert!(found.reason.unwrap().contains("only serves files with `nginx`"));
2155+ // Beside a Dockerfile that runs a server of its own, the server:
2156+ // the site alone would leave its back end out.
2157+ let full = project(
2158+ "p-vite-server",
2159+ &[
2160+ ("package.json", r#"{ "devDependencies": { "vite": "7" } }"#),
2161+ (
2162+ "Dockerfile",
2163+ "FROM node:22-slim AS build\nRUN npm ci && npm run build\nFROM node:22-slim\nCOPY --from=build /app /app\nENV PORT=3000\nCMD [\"node\", \"server.js\"]\n",
2164+ ),
2165+ ],
2166+ );
2167+ let found = detect_with(&full, BuildAs::Auto, None);
2168+ assert_eq!((found.kind, found.framework_id()), ("container", Some("vite")));
2169+ assert_eq!(found.container.as_ref().unwrap().port, 3000);
2170+ assert_eq!(found.fallback.as_ref().map(|site| site.kind), Some("static"));
2171+ assert!(found.reason.unwrap().contains("not as the static site it also has: its Dockerfile starts a server of its own (`CMD`)"));
21022172 // Unless the project says Docker.
21032173 let found = detect_with(&vite, BuildAs::Docker, None);
21042174 assert_eq!((found.kind, found.framework_id()), ("container", Some("vite")));
21132183 let found = detect_with(&html, BuildAs::Workers, None);
21142184 assert_eq!(found.kind, "none");
21152185 assert!(found.problem.unwrap().contains("no Wrangler config"));
2116− // No framework and a Dockerfile: a container, built as the site it
2117− // is without one while g1t.page runs no containers.
2118− let both = project("p-both", &[("index.html", ""), ("Containerfile", "FROM nginx")]);
2186+ // No framework, a site and a Dockerfile that runs a server: a
2187+ // container, built as the site it is without one where Docker
2188+ // deployments are switched off.
2189+ let both = project("p-both", &[("index.html", ""), ("Containerfile", "FROM python:3.12-slim\nEXPOSE 5000\n")]);
21192190 let found = detect_with(&both, BuildAs::Auto, None);
21202191 assert_eq!((found.kind, found.framework_id()), ("container", Some("dockerfile")));
2121− assert_eq!(found.container.as_ref().unwrap().port, 8080);
2192+ assert_eq!(found.container.as_ref().unwrap().port, 5000);
21222193 assert_eq!(found.fallback.as_ref().map(|site| site.kind), Some("html"));
2194+ // A site and a Dockerfile that says neither how it starts nor
2195+ // where it listens: the site.
2196+ let bare = project("p-bare", &[("index.html", ""), ("Dockerfile", "FROM nginx")]);
2197+ let found = detect_with(&bare, BuildAs::Auto, None);
2198+ assert_eq!(found.kind, "html");
2199+ assert!(found.reason.unwrap().contains("neither starts a server"));
2200+ // A Dockerfile and nothing else is a container, whatever it says.
2201+ let alone = project("p-alone", &[("Dockerfile", "FROM nginx")]);
2202+ assert_eq!(detect_with(&alone, BuildAs::Auto, None).kind, "container");
21232203 // A Dockerfile at a path of the project's choosing.
21242204 let chosen = project("p-chosen", &[("ops/web.Dockerfile", "FROM node\nEXPOSE 3000")]);
21252205 assert_eq!(detect_with(&chosen, BuildAs::Auto, None).kind, "none");
21482228 // OpenNext's adapter means Workers: it is not run as a server.
21492229 let opennext = project("p-opennext", &[("package.json", r#"{ "dependencies": { "next": "16", "@opennextjs/cloudflare": "1" } }"#)]);
21502230 assert_eq!(detect_with(&opennext, BuildAs::Auto, None).kind, "none");
2151− for dir in [vite, workers, html, both, chosen, express, nothing, next, opennext] {
2231+ // A server g1t recognises beside a folder it could serve: the
2232+ // server, saying so.
2233+ let public = project(
2234+ "p-public",
2235+ &[("package.json", r#"{ "scripts": { "start": "node index.js" }, "dependencies": { "express": "5" } }"#), ("public/index.html", "")],
2236+ );
2237+ let found = detect_with(&public, BuildAs::Auto, None);
2238+ assert_eq!(found.kind, "container");
2239+ assert!(found.reason.unwrap().contains("as a Node.js server, not as a site of `public`"));
2240+ for dir in [vite, full, workers, html, both, bare, alone, chosen, express, nothing, next, opennext, public] {
21522241 let _ = std::fs::remove_dir_all(dir);
21532242 }
21542243 }
+251−15
5959 Ok(DOCKERFILES.iter().find(|name| dir.join(name).is_file()).map(|name| (*name).to_owned()))
6060 }
6161
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;
62+/// The instructions of a Dockerfile's last stage, the one that runs: each
63+/// as its keyword in capitals and the rest of its line. Lines continued
64+/// with `\` are joined, and comments dropped.
65+fn last_stage(dockerfile: &str) -> Vec<(String, String)> {
66+ let mut joined = Vec::new();
67+ let mut current = String::new();
6668 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") {
69+ let trimmed = line.trim();
70+ if current.is_empty() && (trimmed.is_empty() || trimmed.starts_with('#')) {
71+ continue;
72+ }
73+ match trimmed.strip_suffix('\\') {
74+ Some(start) => {
75+ current.push_str(start);
76+ current.push(' ');
77+ }
78+ None => {
79+ current.push_str(trimmed);
80+ joined.push(std::mem::take(&mut current));
81+ }
82+ }
83+ }
84+ if !current.is_empty() {
85+ joined.push(current);
86+ }
87+ let mut stage = Vec::new();
88+ for line in joined {
89+ let (word, rest) = line.split_once(char::is_whitespace).unwrap_or((line.as_str(), ""));
90+ let word = word.to_ascii_uppercase();
91+ if word == "FROM" {
7192 // 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);
93+ stage.clear();
7594 }
95+ stage.push((word, rest.trim().to_owned()));
7696 }
77− port
97+ stage
7898 }
7999
100+/// The port a Dockerfile's last stage says it listens on: its first
101+/// `EXPOSE` there, else the `PORT` it sets with `ENV`, if it says.
102+pub(crate) fn exposed_port(dockerfile: &str) -> Option<u16> {
103+ let stage = last_stage(dockerfile);
104+ let port = |text: &str| text.split('/').next().and_then(|p| p.trim_matches('"').parse::<u16>().ok()).filter(|p| *p > 0);
105+ let exposed = stage.iter().filter(|(word, _)| word == "EXPOSE").find_map(|(_, rest)| rest.split_whitespace().next().and_then(port));
106+ exposed.or_else(|| {
107+ stage.iter().rev().filter(|(word, _)| word == "ENV").find_map(|(_, rest)| {
108+ // `ENV PORT=3000 OTHER=x`, or the old `ENV PORT 3000`.
109+ if let Some(value) = rest.split_whitespace().find_map(|pair| pair.strip_prefix("PORT=")) {
110+ return port(value);
111+ }
112+ let mut words = rest.split_whitespace();
113+ (words.next() == Some("PORT")).then(|| words.next().and_then(port)).flatten()
114+ })
115+ })
116+}
117+
118+/// Images that serve a folder of files and nothing else, as the base of a
119+/// Dockerfile that only copies a site into them.
120+const FILE_SERVERS: [&str; 8] = [
121+ "nginx",
122+ "nginxinc/nginx-unprivileged",
123+ "httpd",
124+ "caddy",
125+ "lighttpd",
126+ "joseluisq/static-web-server",
127+ "pierrezemb/gostatic",
128+ "busybox",
129+];
130+
131+/// What a Dockerfile's last stage is: whether it runs a server of the
132+/// project's own, and in words, why, for the build's log.
133+///
134+/// It does when that stage says how it starts (`CMD` or `ENTRYPOINT`), or
135+/// says it listens (`EXPOSE`) on an image that is not only a file server
136+/// (nginx, httpd, Caddy…). A Dockerfile that only copies a built site into
137+/// such an image, or says neither, is taken to serve files, which g1t
138+/// serves itself, for nothing while no one visits.
139+pub(crate) fn runs_server(dockerfile: &str) -> (bool, String) {
140+ let stage = last_stage(dockerfile);
141+ let has = |word: &str| stage.iter().any(|(w, _)| w == word);
142+ // `FROM [--platform=…] image[:tag|@digest] [AS name]`
143+ let base = stage
144+ .first()
145+ .filter(|(word, _)| word == "FROM")
146+ .and_then(|(_, rest)| rest.split_whitespace().find(|w| !w.starts_with("--")))
147+ .map(|image| {
148+ let image = image.split('@').next().unwrap_or(image);
149+ // A tag is after the last `/`, so a registry's port is kept.
150+ let name = match image.rsplit_once(':') {
151+ Some((name, tag)) if !tag.contains('/') => name,
152+ _ => image,
153+ };
154+ name.trim_start_matches("docker.io/").trim_start_matches("library/").to_ascii_lowercase()
155+ })
156+ .unwrap_or_default();
157+ let starts = ["CMD", "ENTRYPOINT"].into_iter().find(|word| has(word));
158+ if let Some(word) = starts {
159+ return (true, format!("its Dockerfile starts a server of its own (`{word}`)"));
160+ }
161+ if has("EXPOSE") {
162+ if FILE_SERVERS.contains(&base.as_str()) {
163+ return (false, format!("its Dockerfile only serves files with `{base}`"));
164+ }
165+ return (true, "its Dockerfile runs a server (`EXPOSE`)".to_owned());
166+ }
167+ (false, "its Dockerfile neither starts a server (`CMD`, `ENTRYPOINT`) nor listens on a port (`EXPOSE`)".to_owned())
168+}
169+
80170 /// A server g1t can write a Dockerfile for.
81171 #[derive(Debug, Clone, PartialEq, Eq)]
82172 pub(crate) struct Server {
83173 /// As `g1t-runner detect` prints it when no framework names it:
84174 /// `node`, `django`, `flask`, `fastapi`, `python`, `go`, `rails`,
85− /// `rack`, `laravel`, `php`.
175+ /// `rack`, `laravel`, `php`, `rust`.
86176 pub id: &'static str,
87177 /// For the build's log: `Node.js`, `Django`…
88178 pub name: &'static str,
166256 COPY . .\n\
167257 RUN {install}\n\
168258 {build}\
169− ENV NODE_ENV=production HOST=0.0.0.0 PORT={DEFAULT_PORT}\n\
259+ ENV NODE_ENV=production HOST=0.0.0.0 HOSTNAME=0.0.0.0 PORT={DEFAULT_PORT}\n\
170260 EXPOSE {DEFAULT_PORT}\n\
171261 CMD {start}\n",
172262 header = header(name),
175265 )
176266 }
177267
268+/// Whether a Next.js app builds a standalone server (`output:
269+/// 'standalone'`), which `next start` does not run.
270+fn next_standalone(dir: &Path) -> bool {
271+ ["next.config.js", "next.config.mjs", "next.config.ts", "next.config.cjs"].iter().any(|name| {
272+ std::fs::read_to_string(dir.join(name)).is_ok_and(|text| {
273+ let text: String = text.chars().filter(|c| !c.is_whitespace()).collect();
274+ ["output:\"standalone\"", "output:'standalone'", "output:`standalone`"].iter().any(|form| text.contains(form))
275+ })
276+ })
277+}
278+
178279 /// The command a Node.js server starts with, as a Dockerfile's exec form.
179280 fn exec(words: &[&str]) -> String {
180281 let quoted: Vec<String> = words.iter().map(|w| format!("\"{w}\"")).collect();
197298 let has_build = script("build").is_some();
198299 let start = |fallback: &[&str]| if script("start").is_some() { exec(&["npm", "start"]) } else { exec(fallback) };
199300 let (name, command, own_build) = match framework {
301+ // A standalone build is its own server, with the static files it
302+ // serves copied beside it, as Next.js's guide does.
303+ "nextjs" if next_standalone(dir) => {
304+ let build = if has_build { "npm run build" } else { "npx next build" };
305+ let build = format!(
306+ "{build} && cp -r .next/static .next/standalone/.next/static && if [ -d public ]; then cp -r public .next/standalone/public; fi"
307+ );
308+ return Some(Server {
309+ id: "node",
310+ name: "Next.js",
311+ dockerfile: node_dockerfile(dir, "Next.js", &exec(&["node", ".next/standalone/server.js"]), Some(&build)),
312+ });
313+ }
200314 "nextjs" => ("Next.js", start(&["npx", "next", "start"]), "npx next build"),
201315 // `astro dev` is a common `start` script, so Astro's node adapter's
202316 // own server is run instead.
214328 /// A server g1t recognises in a project with no framework it knows, and
215329 /// the Dockerfile it writes for it.
216330 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))
331+ node_server(dir)
332+ .or_else(|| python(dir))
333+ .or_else(|| go(dir))
334+ .or_else(|| ruby(dir))
335+ .or_else(|| php(dir))
336+ .or_else(|| rust(dir))
218337 }
219338
339+/// Crates that make a Rust program a web server.
340+const RUST_SERVERS: [&str; 7] = ["axum", "actix-web", "rocket", "warp", "poem", "salvo", "tide"];
341+
342+/// A Rust web server: a `Cargo.toml` with one program, depending on a web
343+/// framework.
344+fn rust(dir: &Path) -> Option<Server> {
345+ let manifest: toml::Value = toml::from_str(&std::fs::read_to_string(dir.join("Cargo.toml")).ok()?).ok()?;
346+ let package = manifest.get("package")?.get("name")?.as_str()?;
347+ let dependencies = manifest.get("dependencies")?.as_table()?;
348+ if !RUST_SERVERS.iter().any(|name| dependencies.contains_key(*name)) {
349+ return None;
350+ }
351+ // Its one `[[bin]]`, else the package's own program.
352+ let bins: Vec<&str> = manifest
353+ .get("bin")
354+ .and_then(|bins| bins.as_array())
355+ .map(|bins| bins.iter().filter_map(|bin| bin.get("name")?.as_str()).collect())
356+ .unwrap_or_default();
357+ let program = match bins.as_slice() {
358+ [] if dir.join("src/main.rs").is_file() => package,
359+ [one] => one,
360+ // None, or more than one: which to run is the project's to say.
361+ _ => return None,
362+ };
363+ if !program.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
364+ return None;
365+ }
366+ let dockerfile = format!(
367+ "{header}FROM rust:1-bookworm AS build\n\
368+ WORKDIR /src\n\
369+ COPY . .\n\
370+ RUN cargo build --release --locked --bin {program} || cargo build --release --bin {program}\n\
371+ RUN cp target/release/{program} /app\n\
372+ \n\
373+ FROM debian:bookworm-slim\n\
374+ RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/*\n\
375+ COPY --from=build /app /app\n\
376+ ENV PORT={DEFAULT_PORT}\n\
377+ EXPOSE {DEFAULT_PORT}\n\
378+ ENTRYPOINT [\"/app\"]\n",
379+ header = header("Rust"),
380+ );
381+ Some(Server { id: "rust", name: "Rust", dockerfile })
382+}
383+
220384 /// A Node.js server: a `start` script, with a server package or nothing
221385 /// that looks like a static site (no `build` script and no `index.html`).
222386 fn node_server(dir: &Path) -> Option<Server> {
459623 assert_eq!(exposed_port("FROM golang AS build\nEXPOSE 9000\nFROM scratch\nexpose 8000/tcp"), Some(8000));
460624 assert_eq!(exposed_port("FROM golang AS build\nEXPOSE 9000\nFROM scratch\n"), None);
461625 assert_eq!(exposed_port("FROM x\nEXPOSE $PORT"), None);
626+ // Without an `EXPOSE`, the `PORT` it sets.
627+ assert_eq!(exposed_port("FROM node\nENV NODE_ENV=production PORT=3000\nCMD node ."), Some(3000));
628+ assert_eq!(exposed_port("FROM node\nENV PORT 4000"), Some(4000));
629+ assert_eq!(exposed_port("FROM node\nENV PORT=1\nFROM node\nENV PORT=5000\nEXPOSE 7000"), Some(7000));
630+ // Continued lines and comments.
631+ assert_eq!(exposed_port("FROM node\n# EXPOSE 1\nRUN a \\\n && b\nEXPOSE \\\n 9000"), Some(9000));
632+ }
633+
634+ #[test]
635+ fn a_dockerfile_runs_a_server_unless_it_only_serves_files() {
636+ let (server, why) = runs_server("FROM node:22-slim\nCOPY . .\nCMD [\"node\", \"server.js\"]\n");
637+ assert!(server);
638+ assert_eq!(why, "its Dockerfile starts a server of its own (`CMD`)");
639+ assert!(runs_server("FROM golang AS build\nRUN go build\nFROM scratch\nENTRYPOINT [\"/app\"]").0);
640+ assert!(runs_server("FROM python:3.12\nEXPOSE 5000").0);
641+ // A site copied into a file server, by any of its names.
642+ for base in ["nginx", "nginx:1.27-alpine", "docker.io/library/nginx:latest", "httpd@sha256:abc", "--platform=linux/amd64 caddy:2"] {
643+ let (server, why) = runs_server(&format!("FROM node AS build\nCMD x\nFROM {base}\nCOPY --from=build /app/dist /srv\nEXPOSE 80\n"));
644+ assert!(!server, "{base}");
645+ assert!(why.contains("only serves files"), "{why}");
646+ }
647+ // nginx with a start of its own is the project's to say.
648+ assert!(runs_server("FROM nginx\nCOPY . .\nCMD [\"nginx\", \"-g\", \"daemon off;\"]").0);
649+ assert!(!runs_server("FROM node\nCOPY . .").0);
650+ // A registry with a port is not a tag.
651+ assert!(runs_server("FROM registry.local:5000/nginx\nEXPOSE 80").0);
652+ }
653+
654+ #[test]
655+ fn rust_servers_are_recognised() {
656+ let axum = project("axum", &[("Cargo.toml", "[package]\nname = \"todo-api\"\n\n[dependencies]\naxum = \"0.8\"\n"), ("src/main.rs", "fn main() {}")]);
657+ let found = server(&axum).unwrap();
658+ assert_eq!((found.id, found.name), ("rust", "Rust"));
659+ assert!(found.dockerfile.contains("--bin todo-api"));
660+ assert!(found.dockerfile.contains("ENTRYPOINT [\"/app\"]"));
661+ assert!(runs_server(&found.dockerfile).0);
662+ let lib = project("rust-lib", &[("Cargo.toml", "[package]\nname = \"x\"\n[dependencies]\nserde = \"1\"\n"), ("src/main.rs", "")]);
663+ assert_eq!(server(&lib), None, "not a web server");
664+ let two = project("rust-two", &[("Cargo.toml", "[package]\nname = \"x\"\n[dependencies]\naxum = \"0.8\"\n[[bin]]\nname = \"a\"\n[[bin]]\nname = \"b\"\n")]);
665+ assert_eq!(server(&two), None, "which program runs is the project's to say");
666+ }
667+
668+ #[test]
669+ fn every_dockerfile_g1t_writes_runs_a_server_on_its_port() {
670+ let projects = [
671+ project("w-node", &[("package.json", r#"{ "scripts": { "start": "node i.js" }, "dependencies": { "express": "5" } }"#)]),
672+ project("w-flask", &[("requirements.txt", "flask\n"), ("app.py", "")]),
673+ project("w-go", &[("go.mod", "module x\n"), ("main.go", "package main")]),
674+ project("w-rack", &[("Gemfile", "gem 'sinatra'\n"), ("config.ru", "")]),
675+ project("w-php", &[("composer.json", "{}"), ("index.php", "")]),
676+ ];
677+ for dir in &projects {
678+ let found = server(dir).unwrap();
679+ assert_eq!(exposed_port(&found.dockerfile), Some(DEFAULT_PORT), "{}", found.id);
680+ // PHP's Apache image starts itself: it has no `CMD` of the file's own.
681+ assert!(runs_server(&found.dockerfile).0, "{}", found.id);
682+ }
683+ }
684+
685+ #[test]
686+ fn a_standalone_next_app_runs_its_own_server() {
687+ let next = project(
688+ "next-standalone",
689+ &[
690+ ("package.json", r#"{ "scripts": { "build": "next build", "start": "next start" }, "dependencies": { "next": "16" } }"#),
691+ ("next.config.mjs", "export default {\n output: \"standalone\",\n};\n"),
692+ ],
693+ );
694+ let found = for_framework(&next, "nextjs").unwrap();
695+ assert!(found.dockerfile.contains("RUN npm run build && cp -r .next/static .next/standalone/.next/static"));
696+ assert!(found.dockerfile.contains("CMD [\"node\", \".next/standalone/server.js\"]"));
697+ assert!(found.dockerfile.contains("HOSTNAME=0.0.0.0"));
462698 }
463699
464700 #[test]
+9−9
211211 next = next.static_site(package.build_or("npx next build"), "out");
212212 } else if !wrangler {
213213 next.problem = Some(
214− "This is a Next.js app that needs a server, and g1t.page does not run servers. Set `output: 'export'` in \
215− `next.config` to build it as a static site (served from `out`), or deploy it as a Worker with OpenNext's \
216− Cloudflare adapter, `@opennextjs/cloudflare`, and a `wrangler.jsonc`."
214+ "This is a Next.js app that needs a server, and g1t could not write a Dockerfile to run it. Set \
215+ `output: 'export'` in `next.config` to build it as a static site (served from `out`), or deploy it as a \
216+ Worker with OpenNext's Cloudflare adapter, `@opennextjs/cloudflare`, and a `wrangler.jsonc`."
217217 .to_owned(),
218218 );
219219 }
231231 }
232232 } else if !wrangler && (sets(&text, "output", "server") || sets(&text, "output", "hybrid")) {
233233 astro.problem = Some(
234− "This Astro site renders on a server (`output: 'server'`), and g1t.page does not run servers. Use \
235− `output: 'static'` (the default) to build a static site, or add the `@astrojs/cloudflare` adapter to run \
236− it as a Worker."
234+ "This Astro site renders on a server (`output: 'server'`) with no adapter g1t can start. Add \
235+ `@astrojs/node` to run it as a container, use `output: 'static'` (the default) to build a static site, \
236+ or add the `@astrojs/cloudflare` adapter to run it as a Worker."
237237 .to_owned(),
238238 );
239239 }
278278 None => {
279279 kit.problem = Some(
280280 "This SvelteKit app has no adapter g1t.page can serve. Use `@sveltejs/adapter-static` to build a static \
281− site, or `@sveltejs/adapter-cloudflare` to run it as a Worker; `adapter-auto`, `adapter-node` and the \
282− others need a host of their own."
281+ site, `@sveltejs/adapter-cloudflare` to run it as a Worker, or `@sveltejs/adapter-node` to run it as a \
282+ container; `adapter-auto` and the others need a host of their own."
283283 .to_owned(),
284284 );
285285 }
322322 } else {
323323 let file = if id == "remix" { "the Remix plugin's options in `vite.config`" } else { "`react-router.config.ts`" };
324324 found.problem = Some(format!(
325− "This {name} app renders on a server, and g1t.page does not run servers. Set `ssr: false` in {file} \
325+ "This {name} app renders on a server, and g1t could not write a Dockerfile to run it. Set `ssr: false` in {file} \
326326 to build it as a single-page app (served from `build/client`), or deploy it as a Worker with \
327327 `@cloudflare/vite-plugin` and a `wrangler.jsonc`."
328328 ));
+20−4
155155 }
156156
157157 #[test]
158−fn a_container_is_built_as_its_site_while_g1t_page_runs_none() {
158+fn a_container_is_built_as_its_site_where_containers_are_switched_off() {
159159 let (api, seen) = fake_api();
160− let dir = checkout("container-site", &[("index.html", "<h1>hello</h1>"), ("Dockerfile", "FROM nginx")]);
160+ let dir = checkout(
161+ "container-site",
162+ &[("index.html", "<h1>hello</h1>"), ("server.js", ""), ("Dockerfile", "FROM node:22-slim\nCOPY . .\nCMD [\"node\", \"server.js\"]\n")],
163+ );
161164 let output = deploy(&dir, &api, &[]);
162165 let stdout = String::from_utf8_lossy(&output.stdout);
163166 assert!(output.status.success(), "{stdout}
164167 {}", String::from_utf8_lossy(&output.stderr));
168+ assert!(stdout.contains("Building it as a container from Dockerfile, not as the plain HTML site it also has"), "{stdout}");
165169 assert!(stdout.contains("as a plain HTML site instead"), "{stdout}");
166170 let seen = seen.lock().unwrap();
167171 let steps: Vec<&str> = seen.iter().map(|(path, _)| path.as_str()).collect();
168172 assert_eq!(steps[1], "/deployments/jobs/dpl_1/registry");
169173 assert_eq!(seen.last().unwrap().1["detected"], "html");
174+ drop(seen);
175+ // A Dockerfile that only serves the site with nginx: the site, with no
176+ // image asked for.
177+ let (api, seen) = fake_api();
178+ let files = checkout("files-only", &[("index.html", "<h1>hello</h1>"), ("Dockerfile", "FROM nginx:alpine\nCOPY . /usr/share/nginx/html\nEXPOSE 80\n")]);
179+ let output = deploy(&files, &api, &[]);
180+ let stdout = String::from_utf8_lossy(&output.stdout);
181+ assert!(output.status.success(), "{stdout}");
182+ assert!(stdout.contains("its Dockerfile only serves files with `nginx`"), "{stdout}");
183+ let seen = seen.lock().unwrap();
184+ assert!(!seen.iter().any(|(path, _)| path.ends_with("/registry")));
185+ assert_eq!(seen.last().unwrap().1["detected"], "html");
186+ drop(seen);
170187 // A server with nothing else to serve fails, saying so.
171− drop(seen);
172188 let server = checkout("container-only", &[("package.json", r#"{ "scripts": { "start": "node index.js" }, "dependencies": { "express": "5" } }"#)]);
173189 let output = deploy(&server, &api, &[]);
174190 assert_eq!(output.status.code(), Some(1));
175− assert!(String::from_utf8_lossy(&output.stdout).contains("Docker deployments are not switched on"));
191+ assert!(String::from_utf8_lossy(&output.stdout).contains("Docker deployments are switched off"));
176192 // Build as Static site skips the Dockerfile altogether.
177193 let output = deploy(&dir, &api, &[("BUILD_AS", "static")]);
178194 assert!(output.status.success());
+1−0
181181 rack: "Rack",
182182 laravel: "Laravel",
183183 php: "PHP",
184+ rust: "Rust",
184185 };
185186
186187 /** A framework id a build reported, if it is one g1t knows. */

Comments

Sign in to comment.