Skip to content

Commit 7d8adab

Browse files
committed
Add HCS (Host Compute Service) backend for Windows containers
Add a new Windows-native backend using containerd for VHDX-based copy-on-write snapshots and container execution. New files: - lib/hcs_store.ml: Store implementation using containerd snapshots - lib/hcs_sandbox.ml: Sandbox using ctr run with OCI config - lib/hcs_fetch.ml: Base image fetcher using ctr image pull The store and sandbox are coupled through layerinfo.json files containing containerd snapshot keys and layer paths. Container networking uses HCN namespaces for NAT connectivity.
1 parent 9810eb2 commit 7d8adab

15 files changed

+933
-69
lines changed

example.spec

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@
77
; The result can then be found in /tank/HASH/rootfs/ (where HASH is displayed at the end of the build).
88

99
((build dev
10-
((from ocaml/opam@sha256:02f01da51f1ed2ae4191f143a46a508e2a34652c11ad2715e2bbe8e0d36fc30d)
10+
((from ocaml/opam:debian)
1111
(workdir /src)
1212
(user (uid 1000) (gid 1000)) ; Build as the "opam" user
1313
(run (shell "sudo chown opam /src"))
14-
(env OPAM_HASH "8187cd8d3681d53f5042b5da316fa3f5e005a247")
14+
(env OPAM_HASH "fb593fd72351e22b3778cfd880158a3c4542aa3f")
1515
(run
1616
(network host)
1717
(shell "sudo apt-get --allow-releaseinfo-change update"))

example.windows.hcs.spec

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
; This script builds OBuilder itself using the HCS backend on Windows.
2+
;
3+
; Run it from the top-level of the OBuilder source tree, e.g.
4+
;
5+
; obuilder build -f example.windows.hcs.spec . --store=hcs:C:\obuilder
6+
;
7+
8+
((from ocaml/opam:windows-server-msvc-ltsc2025-ocaml-5.4)
9+
(workdir "C:/src")
10+
; Copy just the opam files first (helps caching)
11+
(copy (src obuilder-spec.opam obuilder.opam) (dst ./))
12+
; Create a dummy dune-project so dune subst works for pinned dev packages
13+
(run (shell "echo (lang dune 3.0)> dune-project"))
14+
(run (shell "opam pin add -yn ."))
15+
; Install OCaml dependencies
16+
(run
17+
(network host)
18+
(shell "opam install --deps-only -t obuilder"))
19+
; Copy the rest of the source code
20+
(copy
21+
(src .)
22+
(dst "C:/src/")
23+
(exclude .git _build _opam))
24+
; Build and test
25+
(run (shell "opam exec -- dune build @install @runtest")))

lib/build.ml

Lines changed: 30 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,18 @@ let hostname = "builder"
99

1010
let healthcheck_base () =
1111
if Sys.win32 then
12-
Docker_sandbox.servercore () >>= fun (`Docker_image servercore) ->
13-
Lwt.return servercore
12+
let keyname = {|HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion|} in
13+
let valuename = "CurrentBuild" in
14+
Os.pread ["reg"; "query"; keyname; "/v"; valuename] >>= fun value ->
15+
let line = String.(value |> trim |> split_on_char '\n') |> Fun.flip List.nth 1 in
16+
Scanf.sscanf line " CurrentBuild REG_SZ %i" @@ fun version ->
17+
let tag = match version with
18+
| 17763 -> "ltsc2019"
19+
| 20348 -> "ltsc2022"
20+
| 26100 -> "ltsc2025"
21+
| _ -> "ltsc2025"
22+
in
23+
Lwt.return ("mcr.microsoft.com/windows/nanoserver:" ^ tag)
1424
else Lwt.return "busybox"
1525

1626
let healthcheck_ops =
@@ -149,11 +159,12 @@ module Make (Raw_store : S.STORE) (Sandbox : S.SANDBOX) (Fetch : S.FETCHER) = st
149159
Store.build t.store ?switch ~base ~id ~log (fun ~cancelled ~log result_tmp ->
150160
let argv = Sandbox.tar t.sandbox in
151161
let config = Config.v
152-
~cwd:"/"
162+
~cwd:(if Sys.win32 then "C:/" else "/")
153163
~argv
154164
~hostname
155165
~user:Obuilder_spec.root
156-
~env:["PATH", "/bin:/usr/bin"]
166+
~env:(if Sys.win32 then ["PATH", {|C:\Windows\System32;C:\Windows|}]
167+
else ["PATH", "/bin:/usr/bin"])
157168
~mount_secrets:[]
158169
~mounts:[]
159170
~network:[]
@@ -183,9 +194,18 @@ module Make (Raw_store : S.STORE) (Sandbox : S.SANDBOX) (Fetch : S.FETCHER) = st
183194
Fmt.pf f "@[<v2>%s: %a@]" context.workdir Obuilder_spec.pp_op op
184195

185196
let update_workdir ~(context:Context.t) path =
197+
let is_absolute =
198+
Astring.String.is_prefix ~affix:"/" path ||
199+
(* Windows absolute paths: C:\ or C:/ *)
200+
(String.length path >= 3 &&
201+
Char.uppercase_ascii path.[0] >= 'A' &&
202+
Char.uppercase_ascii path.[0] <= 'Z' &&
203+
path.[1] = ':' &&
204+
(path.[2] = '/' || path.[2] = '\\'))
205+
in
186206
let workdir =
187-
if Astring.String.is_prefix ~affix:"/" path then path
188-
else context.workdir ^ "/" ^ path
207+
if is_absolute then path
208+
else context.workdir // path
189209
in
190210
{ context with workdir }
191211

@@ -236,7 +256,8 @@ module Make (Raw_store : S.STORE) (Sandbox : S.SANDBOX) (Fetch : S.FETCHER) = st
236256
Store.build t.store ~id ~log (fun ~cancelled:_ ~log tmp ->
237257
Log.info (fun f -> f "Base image not present; importing %S…" base);
238258
let rootfs = tmp / "rootfs" in
239-
Os.sudo ["mkdir"; "-m"; "755"; "--"; rootfs] >>= fun () ->
259+
(if Sys.win32 then (Os.ensure_dir rootfs; Lwt.return_unit)
260+
else Os.sudo ["mkdir"; "-m"; "755"; "--"; rootfs]) >>= fun () ->
240261
Fetch.fetch ~log ~root ~rootfs base >>= fun env ->
241262
Os.write_file ~path:(tmp / "env")
242263
(Sexplib.Sexp.to_string_hum Saved_context.(sexp_of_t {env})) >>= fun () ->
@@ -293,19 +314,12 @@ module Make (Raw_store : S.STORE) (Sandbox : S.SANDBOX) (Fetch : S.FETCHER) = st
293314
| `Output -> Buffer.add_string buffer x
294315

295316
let healthcheck ?(timeout=300.0) t =
296-
Os.with_pipe_from_child (fun ~r ~w ->
297-
let result = Docker.Cmd.version ~stderr:(`FD_move_safely w) () in
298-
let r = Lwt_io.(of_fd ~mode:input) r ~close:Lwt.return in
299-
Lwt_io.read r >>= fun err ->
300-
result >>= function
301-
| Ok _desc -> Lwt_result.return ()
302-
| Error (`Msg m) -> Lwt_result.fail (`Msg (Fmt.str "%s@.%s" m (String.trim err)))
303-
) >>!= fun () ->
304317
let buffer = Buffer.create 1024 in
305318
let log = log_to buffer in
306319
(* Get the base image first, before starting the timer. *)
307320
let switch = Lwt_switch.create () in
308-
let context = Context.v ~shell:(Sandbox.shell t.sandbox) ~switch ~log ~src_dir:"/tmp" () in
321+
let src_dir = if Sys.win32 then {|C:\TEMP|} else "/tmp" in
322+
let context = Context.v ~shell:(Sandbox.shell t.sandbox) ~switch ~log ~src_dir () in
309323
healthcheck_base () >>= function healthcheck_base ->
310324
get_base t ~log healthcheck_base >>= function
311325
| Error (`Msg _) as x -> Lwt.return x

lib/build_log.ml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ let create path =
7878

7979
let finish t =
8080
match t.state with
81-
| `Finished -> invalid_arg "Log is already finished!"
81+
| `Finished -> Lwt.return_unit
8282
| `Open (fd, cond) ->
8383
t.state <- `Finished;
8484
Lwt_unix.close fd >|= fun () ->

lib/db_store.ml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,8 @@ module Make (Raw : S.STORE) = struct
6767
if Sys.file_exists log_file then Unix.unlink log_file;
6868
Build_log.create log_file >>= fun log ->
6969
Lwt.wakeup set_log log;
70-
fn ~cancelled ~log dir
70+
fn ~cancelled ~log dir >>= fun r ->
71+
Build_log.finish log >|= fun () -> r
7172
)
7273
>>!= fun () ->
7374
let now = Unix.(gmtime (gettimeofday () )) in

lib/docker_sandbox.ml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -355,7 +355,8 @@ let servercore =
355355
| 18363 -> "1909"
356356
| 19041 -> "2004"
357357
| 19042 -> "20H2"
358-
| _ -> "ltsc2022"
358+
| 20348 -> "ltsc2022"
359+
| _ -> "ltsc2025"
359360
in
360361
let img' = "mcr.microsoft.com/windows/servercore:" ^ version' in
361362
Log.info (fun f -> f "Windows host is build %i, will use tag %s." version img');

lib/hcs.ml

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
open Sexplib.Conv
2+
3+
let ( / ) = Filename.concat
4+
5+
type layerinfo = {
6+
snapshot_key : string;
7+
source : string;
8+
parent_layer_paths : string list;
9+
} [@@deriving sexp]
10+
11+
let layerinfo_path dir = dir / "layerinfo"
12+
13+
let write_layerinfo ~dir li =
14+
Os.write_file ~path:(layerinfo_path dir)
15+
(Sexplib.Sexp.to_string_hum (sexp_of_layerinfo li) ^ "\n")
16+
17+
let read_layerinfo dir =
18+
layerinfo_of_sexp (Sexplib.Sexp.load_sexp (layerinfo_path dir))
19+
20+
(* Parse the JSON output of `ctr snapshot prepare --mounts`.
21+
Format:
22+
[{"Type":"windows-layer","Source":"C:\\...\\snapshots\\N","Target":"",
23+
"Options":["rw","parentLayerPaths=[\"C:\\\\...\\\\snapshots\\\\M\"]"]}]
24+
Returns (source_path, parent_layer_paths). *)
25+
let parse_mount_json output =
26+
try
27+
let json = Yojson.Safe.from_string (String.trim output) in
28+
let open Yojson.Safe.Util in
29+
match to_list json with
30+
| [] -> ("", [])
31+
| mount :: _ ->
32+
let source = mount |> member "Source" |> to_string in
33+
let options = mount |> member "Options" |> to_list |> List.map to_string in
34+
let parents =
35+
List.find_map (fun opt ->
36+
match Astring.String.cut ~sep:"parentLayerPaths=" opt with
37+
| Some (_, json_str) ->
38+
(try
39+
let arr = Yojson.Safe.from_string json_str in
40+
Some (to_list arr |> List.map to_string)
41+
with _ -> None)
42+
| None -> None
43+
) options
44+
|> Option.value ~default:[]
45+
in
46+
(source, parents)
47+
with _ -> ("", [])

lib/hcs_fetch.ml

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
open Lwt.Infix
2+
3+
let ( / ) = Filename.concat
4+
5+
let ctr_exec args =
6+
let pp f = Os.pp_cmd f ("", "ctr" :: args) in
7+
Os.exec_result ~pp ("ctr" :: args)
8+
9+
let ctr_pread args =
10+
if Sys.win32 then
11+
Os.win32_pread ("ctr" :: args)
12+
else
13+
let pp f = Os.pp_cmd f ("", "ctr" :: args) in
14+
Os.pread_result ~pp ("ctr" :: args)
15+
16+
(* Parse the config digest from `ctr images inspect` tree output.
17+
Look for lines like: "application/vnd.docker.container.image.v1+json @sha256:..." *)
18+
let parse_config_digest output =
19+
let lines = String.split_on_char '\n' output in
20+
List.find_map (fun line ->
21+
if Astring.String.is_infix ~affix:"container.image.v1+json" line then
22+
match Astring.String.cut ~sep:"@" line with
23+
| Some (_, digest) ->
24+
let digest = String.trim digest in
25+
(* Extract just the digest, removing any trailing info like "(123 bytes)" *)
26+
(match Astring.String.cut ~sep:" " digest with
27+
| Some (d, _) -> Some d
28+
| None -> Some digest)
29+
| None -> None
30+
else None
31+
) lines
32+
33+
let parse_env_from_config output =
34+
try
35+
let json = Yojson.Safe.from_string output in
36+
let open Yojson.Safe.Util in
37+
let config = json |> member "config" in
38+
let env_list = config |> member "Env" |> to_list |> List.map to_string in
39+
List.filter_map (fun s ->
40+
match String.index_opt s '=' with
41+
| Some i ->
42+
let key = String.sub s 0 i in
43+
let value = String.sub s (i + 1) (String.length s - i - 1) in
44+
Some (key, value)
45+
| None -> None
46+
) env_list
47+
with _ -> []
48+
49+
50+
(* Parse the chain ID from `ctr images pull --print-chainid --local` output.
51+
The output contains a line like: "image chain ID: sha256:abc123..." *)
52+
let parse_chain_id output =
53+
let lines = String.split_on_char '\n' output in
54+
List.find_map (fun line ->
55+
match Astring.String.cut ~sep:"image chain ID: " line with
56+
| Some (_, chain_id) -> Some (String.trim chain_id)
57+
| None -> None
58+
) lines
59+
60+
(* Normalize image reference for containerd.
61+
Docker Hub images need docker.io/ prefix:
62+
- "ubuntu:latest" -> "docker.io/library/ubuntu:latest"
63+
- "ocaml/opam:tag" -> "docker.io/ocaml/opam:tag"
64+
- "mcr.microsoft.com/..." -> unchanged (already has registry)
65+
- "docker.io/..." -> unchanged *)
66+
let normalize_image_ref image =
67+
if String.contains image '/' then
68+
(* Has a slash - check if it starts with a registry *)
69+
let first_part =
70+
match String.index_opt image '/' with
71+
| Some i -> String.sub image 0 i
72+
| None -> image
73+
in
74+
(* If first part contains a dot or colon, it's a registry *)
75+
if String.contains first_part '.' || String.contains first_part ':' then
76+
image (* Already has registry prefix *)
77+
else
78+
"docker.io/" ^ image (* Docker Hub user/repo format *)
79+
else
80+
(* No slash - it's a Docker Hub library image *)
81+
"docker.io/library/" ^ image
82+
83+
let fetch ~log:(_log : Build_log.t) ~root:(_root : string) ~rootfs base : Config.env Lwt.t =
84+
let image = normalize_image_ref base in
85+
let hash = Sha256.to_hex (Sha256.string base) in
86+
let key = "obuilder-base-" ^ hash in
87+
(* Pull the image — on Windows containerd, pull also unpacks layers *)
88+
Log.info (fun f -> f "HCS fetch: pulling image %s (from %s)" image base);
89+
let platform = ["--platform"; "windows/amd64"] in
90+
(ctr_exec (["images"; "pull"] @ platform @ [image]) >>= function
91+
| Ok () -> Log.info (fun f -> f "HCS fetch: pull succeeded"); Lwt.return_unit
92+
| Error (`Msg m) -> Fmt.failwith "Failed to pull image %s: %s" image m)
93+
>>= fun () ->
94+
(* Get the image's chain ID (the snapshot key for the top layer).
95+
Using --local makes this fast since the image is already pulled. *)
96+
Log.info (fun f -> f "HCS fetch: getting chain ID");
97+
(ctr_pread (["images"; "pull"; "--print-chainid"; "--local"] @ platform @ [image]) >>= function
98+
| Ok output ->
99+
Log.info (fun f -> f "HCS fetch: got chainid output");
100+
(match parse_chain_id output with
101+
| Some chain_id -> Log.info (fun f -> f "HCS fetch: chain ID = %s" chain_id); Lwt.return chain_id
102+
| None -> Fmt.failwith "Could not find chain ID for image %s" image)
103+
| Error (`Msg m) ->
104+
Fmt.failwith "Failed to get chain ID for image %s: %s" image m)
105+
>>= fun chain_id ->
106+
(* Clean up any existing snapshots with this key first (for idempotency).
107+
Remove any snapshots that depend on our key, then remove our key itself. *)
108+
Log.info (fun f -> f "HCS fetch: cleaning up any existing snapshots for %s" key);
109+
let committed_key = key ^ "-committed" in
110+
(* Use ctr snapshot ls and parse to find snapshots that have our committed key as parent *)
111+
(ctr_pread ["snapshot"; "ls"] >>= function
112+
| Ok output ->
113+
let lines = String.split_on_char '\n' output in
114+
let children = lines |> List.filter_map (fun line ->
115+
(* Format: KEY\s+PARENT\s+KIND *)
116+
let parts = Astring.String.cuts ~empty:false ~sep:" " (String.trim line) in
117+
match parts with
118+
| child :: parent :: _ when parent = committed_key -> Some child
119+
| _ -> None
120+
) in
121+
Lwt_list.iter_s (fun child ->
122+
Log.info (fun f -> f "HCS fetch: removing child snapshot %s" child);
123+
ctr_exec ["snapshot"; "rm"; child] >>= fun _ -> Lwt.return_unit
124+
) children
125+
| Error _ -> Lwt.return_unit)
126+
>>= fun () ->
127+
(* Now remove the main snapshots *)
128+
(ctr_exec ["snapshot"; "rm"; key] >>= function
129+
| Ok () -> Log.info (fun f -> f "HCS fetch: removed existing snapshot"); Lwt.return_unit
130+
| Error (`Msg _) -> Log.info (fun f -> f "HCS fetch: no existing snapshot to remove"); Lwt.return_unit)
131+
>>= fun () ->
132+
(ctr_exec ["snapshot"; "rm"; committed_key] >>= function
133+
| Ok () -> Log.info (fun f -> f "HCS fetch: removed existing committed snapshot"); Lwt.return_unit
134+
| Error (`Msg m) -> Log.info (fun f -> f "HCS fetch: could not remove committed snapshot: %s" m); Lwt.return_unit)
135+
>>= fun () ->
136+
(* Prepare a writable snapshot from the image's top layer *)
137+
Log.info (fun f -> f "HCS fetch: preparing snapshot %s from %s" key chain_id);
138+
(ctr_pread ["snapshot"; "prepare"; "--mounts"; key; chain_id] >>= function
139+
| Ok mounts_json ->
140+
Log.info (fun f -> f "HCS fetch: snapshot prepared, parsing mount json");
141+
let source, parent_layer_paths = Hcs.parse_mount_json mounts_json in
142+
Log.info (fun f -> f "HCS fetch: source=%s, parents=%d" source (List.length parent_layer_paths));
143+
Log.info (fun f -> f "HCS fetch: writing layerinfo to %s" rootfs);
144+
Hcs.write_layerinfo ~dir:rootfs { snapshot_key = key; source; parent_layer_paths } >>= fun () ->
145+
Log.info (fun f -> f "HCS fetch: layerinfo written");
146+
Lwt.return_unit
147+
| Error (`Msg m) ->
148+
Fmt.failwith "Failed to prepare snapshot for base %s: %s" base m)
149+
>>= fun () ->
150+
(* Get environment variables from the image config.
151+
First get the config digest from inspect, then get the config content. *)
152+
Log.info (fun f -> f "HCS fetch: getting image config");
153+
(ctr_pread ["images"; "inspect"; image] >>= function
154+
| Ok inspect_output ->
155+
(match parse_config_digest inspect_output with
156+
| Some config_digest ->
157+
Log.info (fun f -> f "HCS fetch: config digest = %s" config_digest);
158+
ctr_pread ["content"; "get"; config_digest] >>= (function
159+
| Ok config_json ->
160+
Log.info (fun f -> f "HCS fetch: got config, parsing env");
161+
Lwt.return (parse_env_from_config config_json)
162+
| Error (`Msg m) ->
163+
Log.warn (fun f -> f "HCS fetch: failed to get config content: %s" m);
164+
Lwt.return [])
165+
| None ->
166+
Log.warn (fun f -> f "HCS fetch: could not find config digest in inspect output");
167+
Lwt.return [])
168+
| Error (`Msg m) ->
169+
Log.warn (fun f -> f "HCS fetch: failed to inspect image: %s" m);
170+
Lwt.return [])
171+
>>= fun env ->
172+
Log.info (fun f -> f "HCS fetch: done, got %d env vars" (List.length env));
173+
Lwt.return env

0 commit comments

Comments
 (0)