Skip to content

Commit 06b72ff

Browse files
committed
feat(registry): add a daemon-free oci registry client
- image references, sha256 digests and bearer challenge parsing - an oci distribution v2 client over HttpURLConnection - linux/amd64 selection out of a multi-architecture index - deterministic gzipped tar layers - config and manifest assembly from a base plus one layer - publish orchestration that moves only the blobs the target lacks
2 parents 374fb33 + cfc2d9f commit 06b72ff

27 files changed

Lines changed: 2408 additions & 0 deletions
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package io.github.intisy.docker.registry;
2+
3+
import java.io.UnsupportedEncodingException;
4+
import java.net.URLEncoder;
5+
import java.util.HashMap;
6+
import java.util.Map;
7+
import java.util.regex.Matcher;
8+
import java.util.regex.Pattern;
9+
10+
/**
11+
* A parsed {@code WWW-Authenticate: Bearer ...} challenge.
12+
*
13+
* @author Finn Birich
14+
*/
15+
public final class AuthChallenge {
16+
private static final String BEARER_PREFIX = "Bearer ";
17+
private static final Pattern PARAMETER = Pattern.compile("([a-zA-Z_]+)=\"([^\"]*)\"");
18+
19+
private final String realm;
20+
private final String service;
21+
private final String scope;
22+
23+
private AuthChallenge(String realm, String service, String scope) {
24+
this.realm = realm;
25+
this.service = service;
26+
this.scope = scope;
27+
}
28+
29+
/**
30+
* @return null when the header is absent or is not a bearer challenge, which is the normal
31+
* case for an unauthenticated registry rather than an error.
32+
*/
33+
public static AuthChallenge parse(String header) {
34+
if (header == null || !header.startsWith(BEARER_PREFIX)) {
35+
return null;
36+
}
37+
Map<String, String> parameters = new HashMap<String, String>();
38+
Matcher matcher = PARAMETER.matcher(header.substring(BEARER_PREFIX.length()));
39+
while (matcher.find()) {
40+
parameters.put(matcher.group(1), matcher.group(2));
41+
}
42+
if (!parameters.containsKey("realm")) {
43+
return null;
44+
}
45+
return new AuthChallenge(parameters.get("realm"), parameters.get("service"), parameters.get("scope"));
46+
}
47+
48+
public String realm() {
49+
return realm;
50+
}
51+
52+
public String service() {
53+
return service;
54+
}
55+
56+
public String scope() {
57+
return scope;
58+
}
59+
60+
public String tokenUrl(String requestedScope) {
61+
StringBuilder url = new StringBuilder(realm);
62+
url.append('?');
63+
if (service != null) {
64+
url.append("service=").append(encode(service)).append('&');
65+
}
66+
url.append("scope=").append(encode(requestedScope));
67+
return url.toString();
68+
}
69+
70+
private static String encode(String value) {
71+
try {
72+
return URLEncoder.encode(value, "UTF-8");
73+
} catch (UnsupportedEncodingException impossible) {
74+
throw new IllegalStateException("UTF-8 is required by every JRE", impossible);
75+
}
76+
}
77+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package io.github.intisy.docker.registry;
2+
3+
import java.io.IOException;
4+
import java.io.InputStream;
5+
import java.security.MessageDigest;
6+
import java.security.NoSuchAlgorithmException;
7+
8+
/**
9+
* @author Finn Birich
10+
*/
11+
public final class Digests {
12+
private static final char[] HEX = "0123456789abcdef".toCharArray();
13+
private static final int BUFFER_BYTES = 64 * 1024;
14+
15+
public static String sha256(byte[] content) {
16+
MessageDigest digest = newDigest();
17+
digest.update(content);
18+
return format(digest.digest());
19+
}
20+
21+
public static String sha256(InputStream content) throws IOException {
22+
MessageDigest digest = newDigest();
23+
byte[] buffer = new byte[BUFFER_BYTES];
24+
int read;
25+
while ((read = content.read(buffer)) != -1) {
26+
digest.update(buffer, 0, read);
27+
}
28+
return format(digest.digest());
29+
}
30+
31+
private static MessageDigest newDigest() {
32+
try {
33+
return MessageDigest.getInstance("SHA-256");
34+
} catch (NoSuchAlgorithmException impossible) {
35+
throw new IllegalStateException("SHA-256 is required by every JRE", impossible);
36+
}
37+
}
38+
39+
private static String format(byte[] raw) {
40+
StringBuilder hex = new StringBuilder("sha256:");
41+
for (int i = 0; i < raw.length; i++) {
42+
int value = raw[i] & 0xff;
43+
hex.append(HEX[value >>> 4]).append(HEX[value & 0x0f]);
44+
}
45+
return hex.toString();
46+
}
47+
48+
private Digests() {}
49+
}
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
package io.github.intisy.docker.registry;
2+
3+
import com.google.gson.Gson;
4+
import com.google.gson.JsonArray;
5+
import com.google.gson.JsonObject;
6+
7+
import java.nio.charset.StandardCharsets;
8+
import java.util.List;
9+
10+
/**
11+
* Rewrites a base image's config and manifest so that they carry one additional layer.
12+
*
13+
* @author Finn Birich
14+
*/
15+
public final class ImageAssembler {
16+
private static final Gson GSON = new Gson();
17+
18+
public static final class Assembled {
19+
private final String configJson;
20+
private final String configDigest;
21+
private final long configSize;
22+
private final String manifestJson;
23+
private final String manifestMediaType;
24+
25+
Assembled(String configJson, String configDigest, long configSize,
26+
String manifestJson, String manifestMediaType) {
27+
this.configJson = configJson;
28+
this.configDigest = configDigest;
29+
this.configSize = configSize;
30+
this.manifestJson = manifestJson;
31+
this.manifestMediaType = manifestMediaType;
32+
}
33+
34+
public String configJson() {
35+
return configJson;
36+
}
37+
38+
public String configDigest() {
39+
return configDigest;
40+
}
41+
42+
public long configSize() {
43+
return configSize;
44+
}
45+
46+
public String manifestJson() {
47+
return manifestJson;
48+
}
49+
50+
public String manifestMediaType() {
51+
return manifestMediaType;
52+
}
53+
}
54+
55+
public static Assembled append(String baseConfigJson, String baseManifestJson,
56+
Layer layer, ImageConfigOverrides overrides) {
57+
JsonObject config = GSON.fromJson(baseConfigJson, JsonObject.class);
58+
config.getAsJsonObject("rootfs").getAsJsonArray("diff_ids").add(layer.diffId());
59+
addHistory(config);
60+
applyOverrides(config, overrides);
61+
62+
String configJson = GSON.toJson(config);
63+
byte[] configBytes = configJson.getBytes(StandardCharsets.UTF_8);
64+
String configDigest = Digests.sha256(configBytes);
65+
66+
JsonObject manifest = GSON.fromJson(baseManifestJson, JsonObject.class);
67+
JsonObject configDescriptor = manifest.getAsJsonObject("config");
68+
configDescriptor.addProperty("digest", configDigest);
69+
configDescriptor.addProperty("size", configBytes.length);
70+
71+
JsonObject layerDescriptor = new JsonObject();
72+
layerDescriptor.addProperty("mediaType", layerMediaTypeMatching(manifest));
73+
layerDescriptor.addProperty("digest", layer.digest());
74+
layerDescriptor.addProperty("size", layer.size());
75+
manifest.getAsJsonArray("layers").add(layerDescriptor);
76+
77+
String mediaType = manifest.has("mediaType")
78+
? manifest.get("mediaType").getAsString()
79+
: RegistryClient.OCI_MANIFEST;
80+
return new Assembled(configJson, configDigest, configBytes.length, GSON.toJson(manifest), mediaType);
81+
}
82+
83+
/**
84+
* @implNote a docker-schema2 manifest and an OCI manifest use different layer media types, and
85+
* mixing them in one manifest is rejected. Taking the type from the layers already there keeps
86+
* the assembled manifest internally consistent whichever shape the base image used. The
87+
* {@code OCI_LAYER_GZIP} fallback only applies when the base manifest has no layers at all; a
88+
* real base image always has at least one, so this never mixes a Docker manifest with an OCI
89+
* layer type in practice.
90+
*/
91+
private static String layerMediaTypeMatching(JsonObject manifest) {
92+
JsonArray layers = manifest.getAsJsonArray("layers");
93+
if (layers != null && layers.size() > 0) {
94+
JsonObject first = layers.get(0).getAsJsonObject();
95+
if (first.has("mediaType")) {
96+
return first.get("mediaType").getAsString();
97+
}
98+
}
99+
return RegistryClient.OCI_LAYER_GZIP;
100+
}
101+
102+
/**
103+
* @implNote no {@code created} timestamp is written deliberately: a wall-clock value would
104+
* change the config bytes, and therefore the config digest and manifest, on every build, so
105+
* two builds of identical inputs would push different images. Same determinism property as
106+
* why {@link LayerBuilder} pins mtime to zero.
107+
*/
108+
private static void addHistory(JsonObject config) {
109+
JsonArray history = config.getAsJsonArray("history");
110+
if (history == null) {
111+
history = new JsonArray();
112+
config.add("history", history);
113+
}
114+
JsonObject entry = new JsonObject();
115+
entry.addProperty("created_by", "docker-java registry assembler");
116+
history.add(entry);
117+
}
118+
119+
private static void applyOverrides(JsonObject config, ImageConfigOverrides overrides) {
120+
JsonObject inner = config.getAsJsonObject("config");
121+
if (inner == null) {
122+
inner = new JsonObject();
123+
config.add("config", inner);
124+
}
125+
if (overrides.entrypoint() != null) {
126+
inner.add("Entrypoint", toArray(overrides.entrypoint()));
127+
}
128+
if (overrides.clearCmd()) {
129+
inner.remove("Cmd");
130+
} else if (overrides.cmd() != null) {
131+
inner.add("Cmd", toArray(overrides.cmd()));
132+
}
133+
if (overrides.env() != null) {
134+
inner.add("Env", toArray(overrides.env()));
135+
}
136+
if (overrides.workingDir() != null) {
137+
inner.addProperty("WorkingDir", overrides.workingDir());
138+
}
139+
}
140+
141+
private static JsonArray toArray(List<String> values) {
142+
JsonArray array = new JsonArray();
143+
for (String value : values) {
144+
array.add(value);
145+
}
146+
return array;
147+
}
148+
149+
private ImageAssembler() {}
150+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package io.github.intisy.docker.registry;
2+
3+
import java.util.List;
4+
5+
/**
6+
* What to stamp into the assembled image's config.
7+
*
8+
* @implNote {@code cmd} is three-state: never calling {@code withCmd} leaves the base Cmd alone;
9+
* {@code withCmd(null)} removes it; {@code withCmd(list)}, empty list included, replaces it. This
10+
* matters because a base JRE image ships a Cmd that must be removed, via {@code withCmd(null)},
11+
* or the container runs that instead of ours. {@code entrypoint} is only two-state: never calling
12+
* {@code withEntrypoint} leaves the base value alone, any list (including empty) replaces it;
13+
* there is no way to clear it.
14+
*
15+
* @author Finn Birich
16+
*/
17+
public final class ImageConfigOverrides {
18+
private List<String> entrypoint;
19+
private List<String> cmd;
20+
private List<String> env;
21+
private String workingDir;
22+
private boolean clearCmd;
23+
24+
public ImageConfigOverrides withEntrypoint(List<String> value) {
25+
this.entrypoint = value;
26+
return this;
27+
}
28+
29+
public ImageConfigOverrides withCmd(List<String> value) {
30+
this.cmd = value;
31+
this.clearCmd = value == null;
32+
return this;
33+
}
34+
35+
public ImageConfigOverrides withEnv(List<String> value) {
36+
this.env = value;
37+
return this;
38+
}
39+
40+
public ImageConfigOverrides withWorkingDir(String value) {
41+
this.workingDir = value;
42+
return this;
43+
}
44+
45+
List<String> entrypoint() {
46+
return entrypoint;
47+
}
48+
49+
List<String> cmd() {
50+
return cmd;
51+
}
52+
53+
boolean clearCmd() {
54+
return clearCmd;
55+
}
56+
57+
List<String> env() {
58+
return env;
59+
}
60+
61+
String workingDir() {
62+
return workingDir;
63+
}
64+
}

0 commit comments

Comments
 (0)