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.
5 files+396−540/5 viewed
| 655 | 655 | /// For a container: its Dockerfile, or the one g1t writes. | |
| 656 | 656 | pub container: Option<ContainerBuild>, | |
| 657 | 657 | /// 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). | |
| 660 | 660 | 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>, | |
| 661 | 664 | } | |
| 662 | 665 | ||
| 663 | 666 | /// How a container is built. | |
| ⋯ | |||
| 685 | 688 | pre_build: None, | |
| 686 | 689 | container: None, | |
| 687 | 690 | fallback: None, | |
| 691 | + | reason: None, | |
| 688 | 692 | } | |
| 689 | 693 | } | |
| 690 | 694 | ||
| ⋯ | |||
| 733 | 737 | pre_build: None, | |
| 734 | 738 | container: Some(build), | |
| 735 | 739 | fallback: None, | |
| 740 | + | reason: None, | |
| 736 | 741 | }) | |
| 737 | 742 | } | |
| 738 | 743 | ||
| ⋯ | |||
| 747 | 752 | /// - **Docker**: a container, from its Dockerfile or one g1t writes. | |
| 748 | 753 | /// - **Static site**: built and served as files, whatever else it has. | |
| 749 | 754 | /// - **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 | |
| 753 | 762 | /// when it has a Dockerfile, or when g1t can write one; a project with | |
| 754 | 763 | /// 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`). | |
| 757 | 767 | pub(crate) fn detect_as( | |
| 758 | 768 | dir: &Path, | |
| 759 | 769 | custom_build: Option<&str>, | |
| ⋯ | |||
| 791 | 801 | } | |
| 792 | 802 | BuildAs::Auto => { | |
| 793 | 803 | 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" { | |
| 795 | 805 | return Ok(site); | |
| 796 | 806 | } | |
| 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 | + | } | |
| 797 | 834 | if let Some(problem) = site.problem.clone() { | |
| 798 | 835 | // A framework set up to need a server. | |
| 799 | 836 | let found = as_container(dir, site.framework.clone(), own); | |
| ⋯ | |||
| 801 | 838 | Detected::nothing(site.framework, site.install, format!("{problem} Or add a `Dockerfile` to run it as a container.")) | |
| 802 | 839 | })); | |
| 803 | 840 | } | |
| 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. | |
| 805 | 843 | 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 | + | )); | |
| 809 | 849 | } | |
| 810 | 850 | return Ok(found); | |
| 811 | 851 | } | |
| ⋯ | |||
| 848 | 888 | pre_build, | |
| 849 | 889 | container: None, | |
| 850 | 890 | fallback: None, | |
| 891 | + | reason: None, | |
| 851 | 892 | }); | |
| 852 | 893 | } | |
| 853 | 894 | let build = custom_build | |
| ⋯ | |||
| 868 | 909 | problem: None, | |
| 869 | 910 | container: None, | |
| 870 | 911 | fallback: None, | |
| 912 | + | reason: None, | |
| 871 | 913 | }); | |
| 872 | 914 | } | |
| 873 | 915 | // A command or a folder of the project's own says it knows better, as | |
| ⋯ | |||
| 889 | 931 | (None, None) => "none", | |
| 890 | 932 | _ => "static", | |
| 891 | 933 | }; | |
| 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 }) | |
| 893 | 935 | } | |
| 894 | 936 | ||
| 895 | 937 | /// The compatibility date a Worker gets when its config names none. | |
| ⋯ | |||
| 936 | 978 | bundle.with_file_name(format!("{}.wrangler.json", bundle.file_name().unwrap_or_default().to_string_lossy())) | |
| 937 | 979 | } | |
| 938 | 980 | ||
| 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. \ | |
| 941 | 984 | Set **Build as** to Static site or Workers under Settings, Deployments if it can be built as one."; | |
| 942 | 985 | ||
| 943 | 986 | /// Where a container's registry login is kept for the build: its own | |
| ⋯ | |||
| 1063 | 1106 | std::fs::create_dir_all(&config)?; | |
| 1064 | 1107 | let image = format!("{}/{}:{}", grant.registry, grant.repository, grant.tag); | |
| 1065 | 1108 | 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. | |
| 1066 | 1112 | let built = step( | |
| 1067 | 1113 | 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)), | |
| 1069 | 1115 | &secrets, | |
| 1070 | 1116 | ); | |
| 1071 | 1117 | let pushed = built.and_then(|()| { | |
| ⋯ | |||
| 1109 | 1155 | if let Some(framework) = &plan.framework { | |
| 1110 | 1156 | log.line(&format!("Detected {}: framework={}.", framework.name, framework.id)); | |
| 1111 | 1157 | } | |
| 1158 | + | if let Some(reason) = &plan.reason { | |
| 1159 | + | log.line(reason); | |
| 1160 | + | } | |
| 1112 | 1161 | // Said before anything is installed: it would fail after, less clearly. | |
| 1113 | 1162 | if let Some(problem) = &plan.problem { | |
| 1114 | 1163 | bail!("{problem}"); | |
| ⋯ | |||
| 1125 | 1174 | container: Some(image), | |
| 1126 | 1175 | }); | |
| 1127 | 1176 | } | |
| 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. | |
| 1130 | 1179 | None => match plan.fallback.take() { | |
| 1131 | 1180 | Some(site) => { | |
| 1132 | 1181 | log.line(&format!("Building it as a {} site instead, as it is without its Dockerfile.", if site.kind == "html" { "plain HTML" } else { "static" })); | |
| ⋯ | |||
| 1580 | 1629 | ||
| 1581 | 1630 | #[test] | |
| 1582 | 1631 | 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."}}"#); | |
| 1584 | 1633 | let answer = Reporter { base, token: "t".into() }.send("registry", json!({})).unwrap(); | |
| 1585 | 1634 | assert_eq!(answer["ok"], false); | |
| 1586 | 1635 | assert_eq!(answer["error"]["code"], "not_enabled"); | |
| ⋯ | |||
| 1730 | 1779 | pre_build: None, | |
| 1731 | 1780 | container: None, | |
| 1732 | 1781 | fallback: None, | |
| 1782 | + | reason: None, | |
| 1733 | 1783 | }; | |
| 1734 | 1784 | assert_eq!(output_lines(&found), "kind=static\ninstall=npm ci\nbuild=a b\noutput-dir=\n"); | |
| 1735 | 1785 | let vite = project("lines", &[("package.json", r#"{ "devDependencies": { "vite": "7" } }"#)]); | |
| ⋯ | |||
| 2096 | 2146 | fn build_as_and_dockerfiles_decide_in_order() { | |
| 2097 | 2147 | let detect_with = |dir: &Path, build_as: BuildAs, dockerfile: Option<&str>| detect_as(dir, None, None, build_as, dockerfile).unwrap(); | |
| 2098 | 2148 | // 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. | |
| 2100 | 2151 | 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`)")); | |
| 2102 | 2172 | // Unless the project says Docker. | |
| 2103 | 2173 | let found = detect_with(&vite, BuildAs::Docker, None); | |
| 2104 | 2174 | assert_eq!((found.kind, found.framework_id()), ("container", Some("vite"))); | |
| ⋯ | |||
| 2113 | 2183 | let found = detect_with(&html, BuildAs::Workers, None); | |
| 2114 | 2184 | assert_eq!(found.kind, "none"); | |
| 2115 | 2185 | 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")]); | |
| 2119 | 2190 | let found = detect_with(&both, BuildAs::Auto, None); | |
| 2120 | 2191 | 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); | |
| 2122 | 2193 | 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"); | |
| 2123 | 2203 | // A Dockerfile at a path of the project's choosing. | |
| 2124 | 2204 | let chosen = project("p-chosen", &[("ops/web.Dockerfile", "FROM node\nEXPOSE 3000")]); | |
| 2125 | 2205 | assert_eq!(detect_with(&chosen, BuildAs::Auto, None).kind, "none"); | |
| ⋯ | |||
| 2148 | 2228 | // OpenNext's adapter means Workers: it is not run as a server. | |
| 2149 | 2229 | let opennext = project("p-opennext", &[("package.json", r#"{ "dependencies": { "next": "16", "@opennextjs/cloudflare": "1" } }"#)]); | |
| 2150 | 2230 | 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] { | |
| 2152 | 2241 | let _ = std::fs::remove_dir_all(dir); | |
| 2153 | 2242 | } | |
| 2154 | 2243 | } | |
| 59 | 59 | Ok(DOCKERFILES.iter().find(|name| dir.join(name).is_file()).map(|name| (*name).to_owned())) | |
| 60 | 60 | } | |
| 61 | 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; | |
| 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(); | |
| 66 | 68 | 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" { | |
| 71 | 92 | // 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(); | |
| 75 | 94 | } | |
| 95 | + | stage.push((word, rest.trim().to_owned())); | |
| 76 | 96 | } | |
| 77 | − | port | |
| 97 | + | stage | |
| 78 | 98 | } | |
| 79 | 99 | ||
| 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 | + | ||
| 80 | 170 | /// A server g1t can write a Dockerfile for. | |
| 81 | 171 | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 82 | 172 | pub(crate) struct Server { | |
| 83 | 173 | /// As `g1t-runner detect` prints it when no framework names it: | |
| 84 | 174 | /// `node`, `django`, `flask`, `fastapi`, `python`, `go`, `rails`, | |
| 85 | − | /// `rack`, `laravel`, `php`. | |
| 175 | + | /// `rack`, `laravel`, `php`, `rust`. | |
| 86 | 176 | pub id: &'static str, | |
| 87 | 177 | /// For the build's log: `Node.js`, `Django`… | |
| 88 | 178 | pub name: &'static str, | |
| ⋯ | |||
| 166 | 256 | COPY . .\n\ | |
| 167 | 257 | RUN {install}\n\ | |
| 168 | 258 | {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\ | |
| 170 | 260 | EXPOSE {DEFAULT_PORT}\n\ | |
| 171 | 261 | CMD {start}\n", | |
| 172 | 262 | header = header(name), | |
| ⋯ | |||
| 175 | 265 | ) | |
| 176 | 266 | } | |
| 177 | 267 | ||
| 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 | + | ||
| 178 | 279 | /// The command a Node.js server starts with, as a Dockerfile's exec form. | |
| 179 | 280 | fn exec(words: &[&str]) -> String { | |
| 180 | 281 | let quoted: Vec<String> = words.iter().map(|w| format!("\"{w}\"")).collect(); | |
| ⋯ | |||
| 197 | 298 | let has_build = script("build").is_some(); | |
| 198 | 299 | let start = |fallback: &[&str]| if script("start").is_some() { exec(&["npm", "start"]) } else { exec(fallback) }; | |
| 199 | 300 | 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 | + | } | |
| 200 | 314 | "nextjs" => ("Next.js", start(&["npx", "next", "start"]), "npx next build"), | |
| 201 | 315 | // `astro dev` is a common `start` script, so Astro's node adapter's | |
| 202 | 316 | // own server is run instead. | |
| ⋯ | |||
| 214 | 328 | /// A server g1t recognises in a project with no framework it knows, and | |
| 215 | 329 | /// the Dockerfile it writes for it. | |
| 216 | 330 | 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)) | |
| 218 | 337 | } | |
| 219 | 338 | ||
| 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 | + | ||
| 220 | 384 | /// A Node.js server: a `start` script, with a server package or nothing | |
| 221 | 385 | /// that looks like a static site (no `build` script and no `index.html`). | |
| 222 | 386 | fn node_server(dir: &Path) -> Option<Server> { | |
| ⋯ | |||
| 459 | 623 | assert_eq!(exposed_port("FROM golang AS build\nEXPOSE 9000\nFROM scratch\nexpose 8000/tcp"), Some(8000)); | |
| 460 | 624 | assert_eq!(exposed_port("FROM golang AS build\nEXPOSE 9000\nFROM scratch\n"), None); | |
| 461 | 625 | 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")); | |
| 462 | 698 | } | |
| 463 | 699 | ||
| 464 | 700 | #[test] | |
| 211 | 211 | next = next.static_site(package.build_or("npx next build"), "out"); | |
| 212 | 212 | } else if !wrangler { | |
| 213 | 213 | 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`." | |
| 217 | 217 | .to_owned(), | |
| 218 | 218 | ); | |
| 219 | 219 | } | |
| ⋯ | |||
| 231 | 231 | } | |
| 232 | 232 | } else if !wrangler && (sets(&text, "output", "server") || sets(&text, "output", "hybrid")) { | |
| 233 | 233 | 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." | |
| 237 | 237 | .to_owned(), | |
| 238 | 238 | ); | |
| 239 | 239 | } | |
| ⋯ | |||
| 278 | 278 | None => { | |
| 279 | 279 | kit.problem = Some( | |
| 280 | 280 | "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." | |
| 283 | 283 | .to_owned(), | |
| 284 | 284 | ); | |
| 285 | 285 | } | |
| ⋯ | |||
| 322 | 322 | } else { | |
| 323 | 323 | let file = if id == "remix" { "the Remix plugin's options in `vite.config`" } else { "`react-router.config.ts`" }; | |
| 324 | 324 | 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} \ | |
| 326 | 326 | to build it as a single-page app (served from `build/client`), or deploy it as a Worker with \ | |
| 327 | 327 | `@cloudflare/vite-plugin` and a `wrangler.jsonc`." | |
| 328 | 328 | )); | |
| 155 | 155 | } | |
| 156 | 156 | ||
| 157 | 157 | #[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() { | |
| 159 | 159 | 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 | + | ); | |
| 161 | 164 | let output = deploy(&dir, &api, &[]); | |
| 162 | 165 | let stdout = String::from_utf8_lossy(&output.stdout); | |
| 163 | 166 | assert!(output.status.success(), "{stdout} | |
| 164 | 167 | {}", 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}"); | |
| 165 | 169 | assert!(stdout.contains("as a plain HTML site instead"), "{stdout}"); | |
| 166 | 170 | let seen = seen.lock().unwrap(); | |
| 167 | 171 | let steps: Vec<&str> = seen.iter().map(|(path, _)| path.as_str()).collect(); | |
| 168 | 172 | assert_eq!(steps[1], "/deployments/jobs/dpl_1/registry"); | |
| 169 | 173 | 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); | |
| 170 | 187 | // A server with nothing else to serve fails, saying so. | |
| 171 | − | drop(seen); | |
| 172 | 188 | let server = checkout("container-only", &[("package.json", r#"{ "scripts": { "start": "node index.js" }, "dependencies": { "express": "5" } }"#)]); | |
| 173 | 189 | let output = deploy(&server, &api, &[]); | |
| 174 | 190 | 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")); | |
| 176 | 192 | // Build as Static site skips the Dockerfile altogether. | |
| 177 | 193 | let output = deploy(&dir, &api, &[("BUILD_AS", "static")]); | |
| 178 | 194 | assert!(output.status.success()); |
| 181 | 181 | rack: "Rack", | |
| 182 | 182 | laravel: "Laravel", | |
| 183 | 183 | php: "PHP", | |
| 184 | + | rust: "Rust", | |
| 184 | 185 | }; | |
| 185 | 186 | ||
| 186 | 187 | /** A framework id a build reported, if it is one g1t knows. */ |
Comments
Sign in to comment.