Source file docker_hub.ml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
(* SPDX-License-Identifier: MIT *)

(* OCaml translation of a shell script found here: https://stackoverflow.com/a/37759182 *)
(* More complete script also available here: https://github.com/moby/moby/blob/924edb948c2731df3b77697a8fcc85da3f6eef57/contrib/download-frozen-image-v2.sh *)

type fetch_errors = [
  | `Api_error of Http_lwt_client.response * string option
  | `Malformed_json of string
  | `Msg of string
]

let fmt = Printf.sprintf
let ( >>= ) = Result.bind
let ( >|= ) x f = Lwt.map f x

(* TODO: Export that somewhere? It sounds useful *)
let rec cmps = function
  | [] -> 0
  | f::fl ->
      match f () with
      | 0 -> cmps fl
      | n -> n

let hurl ~meth ~headers url =
  Http_lwt_client.request
    ~config:(`HTTP_1_1 Httpaf.Config.default) (* TODO: Remove this when https://github.com/roburio/http-lwt-client/issues/7 is fixed *)
    ~meth
    ~headers
    url
    (* TODO: This won't work once we handle things that aren't just short and simple JSON *)
    (fun _ acc body -> Lwt.return (Some (Option.value ~default:"" acc ^ body)))
    None
  >|= function
  | Ok ({Http_lwt_client.status = `OK; _}, Some body) -> Ok body
  | Ok (resp, body) -> Error (`Api_error (resp, body))
  | Error e -> Error e

module Json : sig
  type t

  val parse : string -> (t, [> `Malformed_json of string]) result

  val get : string -> t -> (t, [> `Malformed_json of string]) result
  val get_string : string -> t -> (string, [> `Malformed_json of string]) result
  val get_list : string -> t -> (t list, [> `Malformed_json of string]) result
  val get_string_list : string -> t -> (string list, [> `Malformed_json of string]) result

  val get_string_opt : string -> t -> string option

  val map :
    (t -> ('a, [> `Malformed_json of string] as 'b) result) ->
    t list ->
    ('a list, [> `Malformed_json of string] as 'b) result

  val pp : Format.formatter -> t -> unit
end = struct
  type t = Yojson.Safe.t

  let parse json =
    match Yojson.Safe.from_string json with
    | json -> Ok json
    | exception (Yojson.Json_error msg) -> Error (`Malformed_json msg)

  let get field = function
    | `Assoc l ->
        begin match List.find_opt (fun (k, _) -> String.equal k field) l with
        | Some (_, v) -> Ok v
        | None -> Error (`Malformed_json field)
        end
    | _ -> Error (`Malformed_json field)

  let get_opt field json =
    match get field json with
    | Ok x -> Some x
    | Error _ -> None

  let get_string field json =
    get field json >>= function
    | `String str -> Ok str
    | _ -> Error (`Malformed_json field)

  let get_string_opt field json =
    match get_opt field json with
    | Some (`String str) -> Some str
    | _ -> None

  let get_list field json =
    get field json >>= function
    | `List l -> Ok l
    | _ -> Error (`Malformed_json field)

  let map f l =
    List.fold_left (fun acc x ->
      acc >>= fun acc ->
      f x >>= fun x ->
      Ok (acc @ [x])
    ) (Ok []) l

  let get_string_list field json =
    get field json >>= function
    | `List l ->
        map (function
          | `String x -> Ok x
          | _ -> Error (`Malformed_json "not a list of string")
        ) l
    | _ -> Error (`Malformed_json field)

  let pp fmt json =
    Yojson.Safe.pretty_print fmt json
end

module Platform = struct
  type t = {
    os : string;
    arch : string;
    variant : string option;
  }

  let compare {os; arch; variant} x =
    cmps [
      (fun () -> String.compare os x.os);
      (fun () -> String.compare arch x.arch);
      (fun () -> Option.compare String.compare variant x.variant);
    ]

  let equal x y = compare x y = 0
end

module Image = struct
  type name = {name : string}
  type tag = {tag : string}
  type digest = {digest : string}

  (* Full format is shown in: https://github.com/distribution/distribution/blob/78b9c98c5c31c30d74f9acb7d96f98552f2cf78f/reference/regexp.go#L101 *)
  (* TODO: The current implementation is an approximation. Be more strict *)

  let fail ~func_name = raise (Invalid_argument func_name)

  let make_name ~func_name name =
    let aux = function
      | 'a'..'z' | '0'..'9' | '.' | '_' | '-' -> true
      | _ -> false
    in
    match String.split_on_char '/' name with
    | [""; ""] -> fail ~func_name
    | [x; y] when String.for_all aux x && String.for_all aux y -> {name}
    | _ -> fail ~func_name

  let make_tag ~func_name tag =
    let aux = function
      | 'a'..'z' | 'A'..'Z' | '0'..'9' | '.' | '_' | '-' -> true
      | _ -> false
    in
    match tag with
    | "" -> fail ~func_name
    | tag when String.for_all aux tag -> {tag}
    | _ -> fail ~func_name

  let make_digest ~func_name digest =
    let aux = function
      | 'a'..'z' | 'A'..'Z' | '0'..'9' | '.' | '_' | '-' | '+' -> true
      | _ -> false
    in
    match String.split_on_char ':' digest with
    | [""; ""] -> fail ~func_name
    | [x; y] when String.for_all aux x && String.for_all aux y -> {digest}
    | _ -> fail ~func_name

  let parse ~func_name image =
    let parse_name_and_tag image =
      let parse_name image =
        match String.split_on_char '/' image with
        | [_; _] -> make_name ~func_name image
        | [_] -> make_name ~func_name ("library/"^image)
        | _ -> fail ~func_name (* TODO: add support for non-docker-hub domains *)
      in
      match String.split_on_char ':' image with
      | [image; tag] -> (parse_name image, make_tag ~func_name tag)
      | [image] -> (parse_name image, make_tag ~func_name "latest")
      | _ -> fail ~func_name
    in
    match String.split_on_char '@' image with
    | [image; digest] -> (parse_name_and_tag image, Some (make_digest ~func_name digest))
    | [image] -> (parse_name_and_tag image, None)
    | _ -> fail ~func_name

  let from_string image =
    let func_name = "Docker_hub.Image.from_string" in
    let (name, tag), digest = parse ~func_name image in
    (name, tag, digest)

  let with_digest image =
    let func_name = "Docker_hub.Image.with_digest" in
    match parse ~func_name image with
    | (_, None) -> raise (Invalid_argument func_name)
    | ((name, tag), Some digest) -> (name, tag, digest)

  let without_digest image =
    let func_name = "Docker_hub.Image.without_digest" in
    match parse ~func_name image with
    | (_, Some _) -> raise (Invalid_argument func_name)
    | ((name, tag), None) -> (name, tag)

  let ignore_digest image =
    let func_name = "Docker_hub.Image.ignore_digest" in
    Stdlib.fst (parse ~func_name image)

  let to_string {name} {tag} = function
    | None -> fmt "%s:%s" name tag
    | Some {digest} -> fmt "%s:%s@%s" name tag digest

  let name_to_string {name} = name
  let tag_to_string {tag} = tag
  let digest_to_string {digest} = digest
end

module Token = struct
  type t = {
    json : Json.t;
    token : string;
    name : string;
  }

  let fetch {Image.name} =
    hurl ~meth:`GET
      ~headers:[]
      (fmt "https://auth.docker.io/token?service=registry.docker.io&scope=repository:%s:pull" name)
    >|= function
    | Ok json ->
        Json.parse json >>= fun json ->
        Json.get_string "token" json >>= fun token ->
        Ok {json; token; name}
    | Error e -> Error e

  let pp fmt {json; token = _; name = _} =
    Json.pp fmt json
end

let check_media_type media_type media_type' =
  if String.equal media_type media_type' then
    Ok ()
  else
    Error (`Malformed_json (fmt "mediaType: %s != %s" media_type media_type'))

module Manifest = struct
  type t = {
    json : Json.t;
    config_digest : string;
    rootfs_digest : string;
  }

  let media_type = "application/vnd.docker.distribution.manifest.v2+json"
  let config_media_type = "application/vnd.docker.container.image.v1+json"
  let rootfs_media_type = "application/vnd.docker.image.rootfs.diff.tar.gzip"

  let fetch {Image.digest} {Token.token; name; _} =
    hurl ~meth:`GET
      ~headers:[("Accept", media_type); ("Authorization", fmt "Bearer %s" token)]
      (fmt "https://registry-1.docker.io/v2/%s/manifests/%s" name digest)
    >|= function
    | Ok json ->
        Json.parse json >>= fun json ->
        begin
          Json.get "config" json >>= fun config ->
          Json.get_string "mediaType" config >>= fun config_media_type' ->
          check_media_type config_media_type config_media_type' >>= fun () ->
          Json.get_string "digest" config
        end >>= fun config_digest ->
        begin
          Json.get_list "layers" json >>= function
          | [] | _::_::_ -> Error (`Msg "Does not support multiple layers yet") (* TODO *)
          | [layer] ->
              Json.get_string "mediaType" layer >>= fun rootfs_media_type' ->
              check_media_type rootfs_media_type rootfs_media_type' >>= fun () ->
              Json.get_string "digest" layer
        end >>= fun rootfs_digest ->
        Ok {json; config_digest; rootfs_digest}
    | Error e -> Error e

  let pp fmt {json; config_digest = _; rootfs_digest = _} =
    Json.pp fmt json
end

let parse_platform json =
  Json.get_string "os" json >>= fun os ->
  Json.get_string "architecture" json >>= fun arch ->
  let variant = Json.get_string_opt "variant" json in
  Ok {Platform.os; arch; variant}

module Manifests = struct
  type elt = {
    platform : Platform.t;
    digest : Image.digest;
  }

  type t = {
    json : Json.t;
    elements : elt list;
  }

  let get_elt json =
    Json.get_string "digest" json >>= fun digest ->
    Json.get "platform" json >>= fun platform ->
    parse_platform platform >>= fun platform ->
    Ok {platform; digest = {Image.digest}}

  let media_type = "application/vnd.docker.distribution.manifest.list.v2+json"

  let fetch {Image.tag} {Token.token; name; _} =
    hurl ~meth:`GET
      ~headers:[("Accept", media_type); ("Authorization", fmt "Bearer %s" token)]
      (fmt "https://registry-1.docker.io/v2/%s/manifests/%s" name tag)
    >|= function
    | Ok json ->
        Json.parse json >>= fun json ->
        Json.get_list "manifests" json >>= fun elements ->
        Json.map get_elt elements >>= fun elements ->
        Ok {json; elements}
    | Error e -> Error e

  let elements {elements; _} = elements

  let pp fmt {json; elements = _} =
    Json.pp fmt json
end

module Config = struct
  type t = {
    json : Json.t;
    env : string list;
    platform : Platform.t;
  }

  let fetch {Manifest.config_digest; _} {Token.token; name; _} =
    hurl ~meth:`GET
      ~headers:[("Accept", Manifest.config_media_type); ("Authorization", fmt "Bearer %s" token)]
      (fmt "https://registry-1.docker.io/v2/%s/blobs/%s" name config_digest)
    >|= function
    | Ok json ->
        Json.parse json >>= fun json ->
        (Json.get "config" json >>= Json.get_string_list "Env") >>= fun env ->
        parse_platform json >>= fun platform ->
        Ok {json; env; platform}
    | Error e -> Error e

  let env {env; _} = env
  let platform {platform; _} = platform

  let pp fmt {json; env = _; platform = _} =
    Json.pp fmt json
end

let fetch_rootfs ~output_file {Manifest.rootfs_digest; _} {Token.token; name; _} =
  let ( >>= ) = Lwt.bind in
  hurl ~meth:`GET
    ~headers:[("Accept", Manifest.rootfs_media_type); ("Authorization", fmt "Bearer %s" token)]
    (fmt "https://registry-1.docker.io/v2/%s/blobs/%s" name rootfs_digest)
  >>= function
  | Ok x ->
      Lwt_io.with_file ~mode:Lwt_io.Output (Fpath.to_string output_file) begin fun ch ->
        Lwt_io.write ch x >|= fun () ->
        Ok ()
      end
  | Error e -> Lwt.return (Error e)